opencode-copilot-account-switcher 0.2.8 → 0.3.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.
@@ -1,4 +1,21 @@
1
1
  export type FetchLike = (request: Request | URL | string, init?: RequestInit) => Promise<Response>;
2
+ export type CopilotRetryNotifier = {
3
+ started: (state: {
4
+ remaining: number;
5
+ }) => Promise<void>;
6
+ progress: (state: {
7
+ remaining: number;
8
+ }) => Promise<void>;
9
+ repairWarning: (state: {
10
+ remaining: number;
11
+ }) => Promise<void>;
12
+ completed: (state: {
13
+ remaining: number;
14
+ }) => Promise<void>;
15
+ stopped: (state: {
16
+ remaining: number;
17
+ }) => Promise<void>;
18
+ };
2
19
  type JsonRecord = Record<string, unknown>;
3
20
  export type CopilotRetryContext = {
4
21
  client?: {
@@ -27,14 +44,28 @@ export type CopilotRetryContext = {
27
44
  };
28
45
  }>;
29
46
  };
47
+ tui?: {
48
+ showToast?: (options: {
49
+ body: {
50
+ title?: string;
51
+ message: string;
52
+ variant: "info" | "success" | "warning" | "error";
53
+ duration?: number;
54
+ };
55
+ query?: undefined;
56
+ }) => Promise<unknown>;
57
+ };
30
58
  };
31
59
  directory?: string;
32
60
  serverUrl?: URL;
61
+ lastAccountSwitchAt?: number;
62
+ clearAccountSwitchContext?: () => Promise<void>;
33
63
  wait?: (ms: number) => Promise<void>;
34
64
  patchPart?: (request: {
35
65
  url: string;
36
66
  init: RequestInit;
37
67
  }) => Promise<unknown>;
68
+ notifier?: CopilotRetryNotifier;
38
69
  };
39
70
  export declare function isRetryableCopilotFetchError(error: unknown): boolean;
40
71
  export declare function createCopilotRetryingFetch(baseFetch: FetchLike, options?: CopilotRetryContext): (request: Request | URL | string, init?: RequestInit) => Promise<Response>;
@@ -13,7 +13,6 @@ const RETRYABLE_MESSAGES = [
13
13
  "unable to verify the first certificate",
14
14
  "self-signed certificate in certificate chain",
15
15
  ];
16
- const INPUT_ID_REPAIR_HARD_LIMIT = 64;
17
16
  const INTERNAL_SESSION_HEADER = "x-opencode-session-id";
18
17
  const defaultDebugLogFile = (() => {
19
18
  const tmp = process.env.TEMP || process.env.TMP || "/tmp";
@@ -110,34 +109,64 @@ function collectLongInputIdCandidates(payload) {
110
109
  return [{ item: item, payloadIndex, idLength: id.length }];
111
110
  });
112
111
  }
113
- function pickCandidateByServerIndexHint(candidates, serverReportedIndex) {
114
- const hintedPayloadIndex = serverReportedIndex - 1;
115
- return candidates
116
- .filter((candidate) => candidate.payloadIndex >= hintedPayloadIndex)
117
- .sort((left, right) => left.payloadIndex - right.payloadIndex)[0];
118
- }
119
- function getTargetedLongInputId(payload, serverReportedIndex, reportedLength) {
112
+ function getTargetedLongInputIdSelection(payload, serverReportedIndex, reportedLength) {
120
113
  const matches = collectLongInputIdCandidates(payload);
121
- if (matches.length === 0)
122
- return undefined;
114
+ if (matches.length === 0) {
115
+ return {
116
+ strategy: "ambiguous",
117
+ candidates: [],
118
+ reportedLengthMatched: reportedLength === undefined,
119
+ };
120
+ }
123
121
  const lengthMatches = reportedLength
124
122
  ? matches.filter((item) => item.idLength === reportedLength)
125
123
  : matches;
126
- if (lengthMatches.length === 1)
127
- return lengthMatches[0].item;
128
- if (matches.length === 1)
129
- return matches[0].item;
124
+ if (reportedLength !== undefined && lengthMatches.length === 0) {
125
+ return {
126
+ strategy: "ambiguous",
127
+ candidates: [],
128
+ reportedLengthMatched: false,
129
+ };
130
+ }
131
+ if (lengthMatches.length === 1) {
132
+ return {
133
+ candidate: lengthMatches[0],
134
+ strategy: reportedLength !== undefined && matches.length > 1 ? "reported-length" : "single-long-id",
135
+ candidates: lengthMatches,
136
+ reportedLengthMatched: true,
137
+ };
138
+ }
139
+ if (matches.length === 1) {
140
+ return {
141
+ candidate: matches[0],
142
+ strategy: "single-long-id",
143
+ candidates: matches,
144
+ reportedLengthMatched: reportedLength === undefined,
145
+ };
146
+ }
130
147
  const narrowedCandidates = lengthMatches.length > 0 ? lengthMatches : matches;
131
148
  if (typeof serverReportedIndex === "number") {
132
- return pickCandidateByServerIndexHint(narrowedCandidates, serverReportedIndex)?.item;
149
+ const hintedCandidates = narrowedCandidates.filter((candidate) => candidate.payloadIndex === serverReportedIndex || candidate.payloadIndex + 1 === serverReportedIndex);
150
+ if (hintedCandidates.length === 1) {
151
+ return {
152
+ candidate: hintedCandidates[0],
153
+ strategy: "index-hint",
154
+ candidates: narrowedCandidates,
155
+ reportedLengthMatched: true,
156
+ };
157
+ }
133
158
  }
134
- return undefined;
159
+ return {
160
+ strategy: "ambiguous",
161
+ candidates: narrowedCandidates,
162
+ reportedLengthMatched: true,
163
+ };
135
164
  }
136
165
  function stripTargetedLongInputId(payload, serverReportedIndex, reportedLength) {
137
166
  const input = payload.input;
138
167
  if (!Array.isArray(input))
139
168
  return payload;
140
- const target = getTargetedLongInputId(payload, serverReportedIndex, reportedLength);
169
+ const target = getTargetedLongInputIdSelection(payload, serverReportedIndex, reportedLength).candidate?.item;
141
170
  if (!target)
142
171
  return payload;
143
172
  let changed = false;
@@ -181,6 +210,21 @@ function buildRetryInit(init, payload) {
181
210
  body: JSON.stringify(payload),
182
211
  };
183
212
  }
213
+ const noopNotifier = {
214
+ started: async () => { },
215
+ progress: async () => { },
216
+ repairWarning: async () => { },
217
+ completed: async () => { },
218
+ stopped: async () => { },
219
+ };
220
+ async function notify(notifier, event, remaining) {
221
+ try {
222
+ await notifier[event]({ remaining });
223
+ }
224
+ catch (error) {
225
+ console.warn(`[copilot-network-retry] notifier ${event} failed`, error);
226
+ }
227
+ }
184
228
  function stripInternalSessionHeaderFromRequest(request) {
185
229
  if (!(request instanceof Request))
186
230
  return request;
@@ -200,12 +244,18 @@ function getHeader(request, init, name) {
200
244
  return undefined;
201
245
  }
202
246
  function getTargetedInputId(payload, serverReportedIndex, reportedLength) {
203
- const target = getTargetedLongInputId(payload, serverReportedIndex, reportedLength);
247
+ const target = getTargetedLongInputIdSelection(payload, serverReportedIndex, reportedLength).candidate?.item;
204
248
  const id = target?.id;
205
249
  if (typeof id !== "string")
206
250
  return undefined;
207
251
  return id;
208
252
  }
253
+ function logCleanupStopped(reason, details) {
254
+ debugLog("input-id retry cleanup-stopped", {
255
+ reason,
256
+ ...(details ?? {}),
257
+ });
258
+ }
209
259
  function stripOpenAIItemId(part) {
210
260
  const metadata = part.metadata;
211
261
  if (!metadata || typeof metadata !== "object")
@@ -316,14 +366,25 @@ async function repairSessionPart(sessionID, failingId, ctx) {
316
366
  return false;
317
367
  }
318
368
  }
319
- async function maybeRetryInputIdTooLong(request, init, response, baseFetch, ctx, sessionID) {
369
+ async function maybeRetryInputIdTooLong(request, init, response, baseFetch, requestPayload, ctx, sessionID, startedNotified = false) {
320
370
  if (response.status !== 400) {
321
- return { response, retried: false, nextInit: init, retryState: undefined };
371
+ return {
372
+ response,
373
+ retried: false,
374
+ nextInit: init,
375
+ nextPayload: requestPayload,
376
+ retryState: undefined,
377
+ };
322
378
  }
323
- const requestPayload = parseJsonBody(init);
324
379
  if (!requestPayload || !hasLongInputIds(requestPayload)) {
325
380
  debugLog("skip input-id retry: request has no long ids");
326
- return { response, retried: false, nextInit: init, retryState: undefined };
381
+ return {
382
+ response,
383
+ retried: false,
384
+ nextInit: init,
385
+ nextPayload: requestPayload,
386
+ retryState: undefined,
387
+ };
327
388
  }
328
389
  debugLog("input-id retry candidate", {
329
390
  status: response.status,
@@ -335,7 +396,13 @@ async function maybeRetryInputIdTooLong(request, init, response, baseFetch, ctx,
335
396
  .catch(() => "");
336
397
  if (!responseText) {
337
398
  debugLog("skip input-id retry: empty response body");
338
- return { response, retried: false, nextInit: init, retryState: undefined };
399
+ return {
400
+ response,
401
+ retried: false,
402
+ nextInit: init,
403
+ nextPayload: requestPayload,
404
+ retryState: undefined,
405
+ };
339
406
  }
340
407
  let parsed = parseInputIdTooLongDetails(responseText);
341
408
  let matched = parsed.matched;
@@ -361,31 +428,61 @@ async function maybeRetryInputIdTooLong(request, init, response, baseFetch, ctx,
361
428
  reportedLength: parsed.reportedLength,
362
429
  });
363
430
  if (!matched) {
364
- return { response, retried: false, nextInit: init, retryState: undefined };
365
- }
366
- if (parsed.serverReportedIndex === undefined) {
367
- debugLog("skip input-id retry: missing server input index", {
368
- reportedLength: parsed.reportedLength,
369
- });
370
- return { response, retried: false, nextInit: init, retryState: undefined };
431
+ return {
432
+ response,
433
+ retried: false,
434
+ nextInit: init,
435
+ nextPayload: requestPayload,
436
+ retryState: undefined,
437
+ };
371
438
  }
372
439
  const payloadCandidates = getPayloadCandidates(requestPayload);
440
+ const targetSelection = getTargetedLongInputIdSelection(requestPayload, parsed.serverReportedIndex, parsed.reportedLength);
373
441
  debugLog("input-id retry payload candidates", {
374
442
  serverReportedIndex: parsed.serverReportedIndex,
375
443
  candidates: payloadCandidates,
376
444
  });
377
445
  const failingId = getTargetedInputId(requestPayload, parsed.serverReportedIndex, parsed.reportedLength);
378
- const targetedPayload = payloadCandidates.find((item) => item.idLength === parsed.reportedLength) ?? payloadCandidates[0];
379
- if (targetedPayload) {
380
- debugLog("input-id retry payload target", {
381
- targetedPayloadIndex: targetedPayload.payloadIndex,
382
- itemKind: targetedPayload.itemKind,
383
- idLength: targetedPayload.idLength,
384
- idPreview: targetedPayload.idPreview,
446
+ const targetedPayload = targetSelection.candidate
447
+ ? payloadCandidates.find((item) => item.payloadIndex === targetSelection.candidate?.payloadIndex)
448
+ : undefined;
449
+ debugLog("input-id retry payload target", {
450
+ serverReportedIndex: parsed.serverReportedIndex,
451
+ targetedPayloadIndex: targetedPayload?.payloadIndex,
452
+ itemKind: targetedPayload?.itemKind,
453
+ idLength: targetedPayload?.idLength,
454
+ idPreview: targetedPayload?.idPreview,
455
+ strategy: targetSelection.strategy,
456
+ });
457
+ const remainingBefore = countLongInputIdCandidates(requestPayload);
458
+ if (!targetSelection.candidate) {
459
+ logCleanupStopped("evidence-insufficient", {
460
+ serverReportedIndex: parsed.serverReportedIndex,
461
+ reportedLength: parsed.reportedLength,
462
+ candidateCount: targetSelection.candidates.length,
463
+ reportedLengthMatched: targetSelection.reportedLengthMatched,
385
464
  });
465
+ return {
466
+ response,
467
+ retried: false,
468
+ nextInit: init,
469
+ nextPayload: requestPayload,
470
+ retryState: {
471
+ previousServerReportedIndex: parsed.serverReportedIndex,
472
+ previousErrorMessagePreview: buildMessagePreview(responseText),
473
+ remainingLongIdCandidatesBefore: remainingBefore,
474
+ remainingLongIdCandidatesAfter: remainingBefore,
475
+ previousReportedLength: parsed.reportedLength,
476
+ notifiedStarted: startedNotified || remainingBefore > 0,
477
+ repairFailed: false,
478
+ stopReason: "evidence-insufficient",
479
+ },
480
+ };
386
481
  }
482
+ const notifiedStarted = startedNotified || remainingBefore > 0;
483
+ let repairFailed = false;
387
484
  if (sessionID && failingId) {
388
- await repairSessionPart(sessionID, failingId, ctx).catch(() => false);
485
+ repairFailed = !(await repairSessionPart(sessionID, failingId, ctx).catch(() => false));
389
486
  }
390
487
  const sanitized = stripTargetedLongInputId(requestPayload, parsed.serverReportedIndex, parsed.reportedLength);
391
488
  if (sanitized === requestPayload) {
@@ -394,11 +491,15 @@ async function maybeRetryInputIdTooLong(request, init, response, baseFetch, ctx,
394
491
  response,
395
492
  retried: false,
396
493
  nextInit: init,
494
+ nextPayload: requestPayload,
397
495
  retryState: {
398
496
  previousServerReportedIndex: parsed.serverReportedIndex,
399
497
  previousErrorMessagePreview: buildMessagePreview(responseText),
400
- remainingLongIdCandidatesBefore: countLongInputIdCandidates(requestPayload),
401
- remainingLongIdCandidatesAfter: countLongInputIdCandidates(requestPayload),
498
+ remainingLongIdCandidatesBefore: remainingBefore,
499
+ remainingLongIdCandidatesAfter: remainingBefore,
500
+ previousReportedLength: parsed.reportedLength,
501
+ notifiedStarted,
502
+ repairFailed,
402
503
  },
403
504
  };
404
505
  }
@@ -411,14 +512,17 @@ async function maybeRetryInputIdTooLong(request, init, response, baseFetch, ctx,
411
512
  const retryState = {
412
513
  previousServerReportedIndex: parsed.serverReportedIndex,
413
514
  previousErrorMessagePreview: buildMessagePreview(responseText),
414
- remainingLongIdCandidatesBefore: countLongInputIdCandidates(requestPayload),
515
+ remainingLongIdCandidatesBefore: remainingBefore,
415
516
  remainingLongIdCandidatesAfter: countLongInputIdCandidates(parseJsonBody(nextInit)),
517
+ previousReportedLength: parsed.reportedLength,
518
+ notifiedStarted,
519
+ repairFailed,
416
520
  };
417
521
  debugLog("input-id retry response", {
418
522
  status: retried.status,
419
523
  contentType: retried.headers.get("content-type") ?? undefined,
420
524
  });
421
- return { response: retried, retried: true, nextInit, retryState };
525
+ return { response: retried, retried: true, nextInit, nextPayload: sanitized, retryState };
422
526
  }
423
527
  function toRetryableSystemError(error) {
424
528
  const base = error instanceof Error ? error : new Error(String(error));
@@ -468,9 +572,29 @@ async function getInputIdRetryErrorDetails(response) {
468
572
  return undefined;
469
573
  return {
470
574
  serverReportedIndex: parsed.serverReportedIndex,
575
+ reportedLength: parsed.reportedLength,
471
576
  errorMessagePreview: buildMessagePreview(message),
472
577
  };
473
578
  }
579
+ async function parseJsonRequestPayload(request, init) {
580
+ const initPayload = parseJsonBody(init);
581
+ if (initPayload)
582
+ return initPayload;
583
+ if (!(request instanceof Request))
584
+ return undefined;
585
+ try {
586
+ const body = await request.clone().text();
587
+ if (!body)
588
+ return undefined;
589
+ const parsed = JSON.parse(body);
590
+ if (!parsed || typeof parsed !== "object")
591
+ return undefined;
592
+ return parsed;
593
+ }
594
+ catch {
595
+ return undefined;
596
+ }
597
+ }
474
598
  function withStreamDebugLogs(response, request) {
475
599
  if (!isDebugEnabled())
476
600
  return response;
@@ -519,7 +643,7 @@ export function isRetryableCopilotFetchError(error) {
519
643
  return RETRYABLE_MESSAGES.some((part) => message.includes(part));
520
644
  }
521
645
  export function createCopilotRetryingFetch(baseFetch, options) {
522
- void options;
646
+ const notifier = options?.notifier ?? noopNotifier;
523
647
  return async function retryingFetch(request, init) {
524
648
  const sessionID = getHeader(request, init, INTERNAL_SESSION_HEADER);
525
649
  const safeRequest = stripInternalSessionHeaderFromRequest(request);
@@ -535,6 +659,7 @@ export function createCopilotRetryingFetch(baseFetch, options) {
535
659
  url: safeRequest instanceof Request ? safeRequest.url : safeRequest instanceof URL ? safeRequest.href : String(safeRequest),
536
660
  isCopilot: isCopilotUrl(safeRequest),
537
661
  });
662
+ let currentPayload = await parseJsonRequestPayload(safeRequest, effectiveInit);
538
663
  try {
539
664
  const response = await baseFetch(safeRequest, effectiveInit);
540
665
  debugLog("fetch resolved", {
@@ -545,27 +670,42 @@ export function createCopilotRetryingFetch(baseFetch, options) {
545
670
  let currentResponse = response;
546
671
  let currentInit = effectiveInit;
547
672
  let attempts = 0;
548
- while (attempts < INPUT_ID_REPAIR_HARD_LIMIT) {
549
- const remainingCandidates = countLongInputIdCandidates(parseJsonBody(currentInit));
550
- if (remainingCandidates === 0)
551
- break;
552
- const result = await maybeRetryInputIdTooLong(safeRequest, currentInit, currentResponse, baseFetch, options, sessionID);
673
+ let shouldContinueInputIdRepair = countLongInputIdCandidates(currentPayload) > 0;
674
+ let startedNotified = false;
675
+ let finishedNotified = false;
676
+ let repairWarningNotified = false;
677
+ while (shouldContinueInputIdRepair) {
678
+ shouldContinueInputIdRepair = false;
679
+ const result = await maybeRetryInputIdTooLong(safeRequest, currentInit, currentResponse, baseFetch, currentPayload, options, sessionID, startedNotified);
553
680
  currentResponse = result.response;
554
681
  currentInit = result.nextInit;
682
+ currentPayload = result.nextPayload;
555
683
  if (result.retryState) {
684
+ if (!startedNotified && result.retryState.notifiedStarted) {
685
+ startedNotified = true;
686
+ await notify(notifier, "started", result.retryState.remainingLongIdCandidatesBefore);
687
+ }
688
+ if (result.retryState.repairFailed && !repairWarningNotified) {
689
+ await notify(notifier, "repairWarning", result.retryState.remainingLongIdCandidatesBefore);
690
+ repairWarningNotified = true;
691
+ }
556
692
  const currentError = await getInputIdRetryErrorDetails(currentResponse);
557
- let stopReason;
558
- if (result.retryState.remainingLongIdCandidatesAfter >= result.retryState.remainingLongIdCandidatesBefore) {
693
+ let stopReason = result.retryState.stopReason;
694
+ const madeProgress = result.retryState.remainingLongIdCandidatesAfter < result.retryState.remainingLongIdCandidatesBefore;
695
+ if (!stopReason && result.retryState.remainingLongIdCandidatesAfter >= result.retryState.remainingLongIdCandidatesBefore) {
559
696
  stopReason = "remaining-candidates-not-reduced";
560
697
  }
561
- else if (currentError) {
562
- const serverIndexChanged = result.retryState.previousServerReportedIndex !== currentError.serverReportedIndex;
563
- const errorMessageChanged = result.retryState.previousErrorMessagePreview !== currentError.errorMessagePreview;
564
- if (!serverIndexChanged && !errorMessageChanged) {
565
- stopReason = "server-error-unchanged";
566
- }
698
+ if (!stopReason &&
699
+ currentError &&
700
+ result.retryState.remainingLongIdCandidatesAfter > 0 &&
701
+ result.retryState.previousServerReportedIndex === currentError.serverReportedIndex &&
702
+ result.retryState.previousReportedLength === currentError.reportedLength) {
703
+ stopReason = "same-server-item-persists";
567
704
  }
568
- if (currentError || stopReason) {
705
+ if (!stopReason && currentError && result.retryState.remainingLongIdCandidatesAfter === 0) {
706
+ stopReason = "local-candidates-exhausted";
707
+ }
708
+ if ((currentError || stopReason) && stopReason !== "evidence-insufficient") {
569
709
  debugLog("input-id retry progress", {
570
710
  attempt: attempts + 1,
571
711
  previousServerReportedIndex: result.retryState.previousServerReportedIndex,
@@ -578,11 +718,40 @@ export function createCopilotRetryingFetch(baseFetch, options) {
578
718
  stopReason,
579
719
  });
580
720
  }
581
- if (stopReason)
721
+ if (stopReason === "local-candidates-exhausted") {
722
+ logCleanupStopped("local-candidates-exhausted", {
723
+ attempt: attempts + 1,
724
+ previousServerReportedIndex: result.retryState.previousServerReportedIndex,
725
+ currentServerReportedIndex: currentError?.serverReportedIndex,
726
+ });
727
+ }
728
+ if (stopReason) {
729
+ await notify(notifier, "stopped", result.retryState.remainingLongIdCandidatesAfter);
730
+ finishedNotified = true;
582
731
  break;
732
+ }
733
+ if (result.retried && madeProgress && result.retryState.remainingLongIdCandidatesAfter > 0) {
734
+ await notify(notifier, "progress", result.retryState.remainingLongIdCandidatesAfter);
735
+ }
736
+ if (result.retried && result.retryState.remainingLongIdCandidatesAfter === 0 && currentResponse.ok) {
737
+ await notify(notifier, "completed", 0);
738
+ finishedNotified = true;
739
+ }
740
+ if (result.retried && result.retryState.remainingLongIdCandidatesAfter === 0 && !currentResponse.ok) {
741
+ await notify(notifier, "stopped", 0);
742
+ finishedNotified = true;
743
+ }
744
+ if (result.retried && madeProgress && result.retryState.remainingLongIdCandidatesAfter > 0) {
745
+ shouldContinueInputIdRepair = true;
746
+ }
583
747
  }
584
- if (!result.retried)
748
+ if (!result.retried) {
749
+ if (startedNotified && !finishedNotified) {
750
+ await notify(notifier, "stopped", countLongInputIdCandidates(currentPayload));
751
+ finishedNotified = true;
752
+ }
585
753
  break;
754
+ }
586
755
  attempts += 1;
587
756
  }
588
757
  return withStreamDebugLogs(currentResponse, safeRequest);
@@ -0,0 +1,33 @@
1
+ export declare const ACCOUNT_SWITCH_TTL_MS: number;
2
+ type ToastVariant = "info" | "success" | "warning" | "error";
3
+ type RetryToastState = {
4
+ remaining: number;
5
+ };
6
+ type RetryToastClient = {
7
+ tui?: {
8
+ showToast?: (options: {
9
+ body: {
10
+ title?: string;
11
+ message: string;
12
+ variant: ToastVariant;
13
+ duration?: number;
14
+ };
15
+ query?: undefined;
16
+ }) => Promise<unknown>;
17
+ };
18
+ };
19
+ type RetryNotifierContext = {
20
+ client?: RetryToastClient;
21
+ lastAccountSwitchAt?: number;
22
+ getLastAccountSwitchAt?: () => Promise<number | undefined> | number | undefined;
23
+ clearAccountSwitchContext?: (lastAccountSwitchAt?: number) => Promise<void>;
24
+ now?: () => number;
25
+ };
26
+ export declare function createCopilotRetryNotifier(ctx: RetryNotifierContext): {
27
+ started: (state: RetryToastState) => Promise<void>;
28
+ progress: (state: RetryToastState) => Promise<void>;
29
+ repairWarning: (state: RetryToastState) => Promise<void>;
30
+ completed: (state: RetryToastState) => Promise<void>;
31
+ stopped: (state: RetryToastState) => Promise<void>;
32
+ };
33
+ export {};
@@ -0,0 +1,69 @@
1
+ export const ACCOUNT_SWITCH_TTL_MS = 30 * 60 * 1000;
2
+ function buildPrefix(lastAccountSwitchAt) {
3
+ if (typeof lastAccountSwitchAt !== "number")
4
+ return "Copilot 输入 ID 自动清理中";
5
+ return "正在清理可能因账号切换遗留的非法输入 ID";
6
+ }
7
+ async function resolveToastAccountSwitchAt(ctx) {
8
+ if (ctx.lastAccountSwitchAt !== undefined && ctx.lastAccountSwitchAt !== null) {
9
+ return ctx.lastAccountSwitchAt;
10
+ }
11
+ return ctx.getLastAccountSwitchAt?.();
12
+ }
13
+ function isAccountSwitchContextExpired(lastAccountSwitchAt, now) {
14
+ if (typeof lastAccountSwitchAt !== "number")
15
+ return false;
16
+ return now() - lastAccountSwitchAt >= ACCOUNT_SWITCH_TTL_MS;
17
+ }
18
+ async function clearContext(ctx) {
19
+ try {
20
+ await ctx.clearAccountSwitchContext?.();
21
+ }
22
+ catch (error) {
23
+ console.warn("[copilot-retry-notifier] failed to clear account-switch context", error);
24
+ }
25
+ }
26
+ export function createCopilotRetryNotifier(ctx) {
27
+ let lastExpiredContextClearedAt;
28
+ async function send(variant, detail, state, clear = false) {
29
+ const now = ctx.now ?? Date.now;
30
+ const lastAccountSwitchAt = await resolveToastAccountSwitchAt(ctx);
31
+ try {
32
+ await ctx.client?.tui?.showToast?.({
33
+ body: {
34
+ variant,
35
+ message: `${buildPrefix(lastAccountSwitchAt)}:${detail},剩余 ${state.remaining} 项。`,
36
+ },
37
+ });
38
+ }
39
+ catch (error) {
40
+ console.warn("[copilot-retry-notifier] failed to show toast", error);
41
+ }
42
+ if (!clear
43
+ && isAccountSwitchContextExpired(lastAccountSwitchAt, now)
44
+ && lastAccountSwitchAt !== lastExpiredContextClearedAt) {
45
+ lastExpiredContextClearedAt = lastAccountSwitchAt;
46
+ await clearContext({
47
+ ...ctx,
48
+ clearAccountSwitchContext: async () => {
49
+ await ctx.clearAccountSwitchContext?.(lastAccountSwitchAt);
50
+ },
51
+ });
52
+ }
53
+ if (!clear)
54
+ return;
55
+ await clearContext({
56
+ ...ctx,
57
+ clearAccountSwitchContext: async () => {
58
+ await ctx.clearAccountSwitchContext?.(lastAccountSwitchAt);
59
+ },
60
+ });
61
+ }
62
+ return {
63
+ started: async (state) => send("info", "已开始自动清理", state),
64
+ progress: async (state) => send("info", "自动清理仍在继续", state),
65
+ repairWarning: async (state) => send("warning", "会话回写失败,继续尝试仅清理请求体", state),
66
+ completed: async (state) => send("success", "自动清理已完成", state, true),
67
+ stopped: async (state) => send("warning", "自动清理已停止", state, true),
68
+ };
69
+ }
@@ -1,5 +1,11 @@
1
1
  import type { StoreFile } from "./store.js";
2
2
  import type { MenuAction } from "./ui/menu.js";
3
+ export declare function persistAccountSwitch(input: {
4
+ store: StoreFile;
5
+ name: string;
6
+ at: number;
7
+ writeStore: (store: StoreFile) => Promise<void>;
8
+ }): Promise<void>;
3
9
  export declare function applyMenuAction(input: {
4
10
  action: MenuAction;
5
11
  store: StoreFile;
@@ -1,3 +1,9 @@
1
+ export async function persistAccountSwitch(input) {
2
+ input.store.active = input.name;
3
+ input.store.accounts[input.name].lastUsed = input.at;
4
+ input.store.lastAccountSwitchAt = input.at;
5
+ await input.writeStore(input.store);
6
+ }
1
7
  export async function applyMenuAction(input) {
2
8
  if (input.action.type === "toggle-loop-safety") {
3
9
  input.store.loopSafetyEnabled = input.store.loopSafetyEnabled !== true;
@@ -25,6 +25,7 @@ type CopilotPluginHooksWithChatHeaders = CopilotPluginHooks & {
25
25
  export declare function buildPluginHooks(input: {
26
26
  auth: NonNullable<CopilotPluginHooks["auth"]>;
27
27
  loadStore?: () => Promise<StoreFile | undefined>;
28
+ writeStore?: (store: StoreFile) => Promise<void>;
28
29
  loadOfficialConfig?: (input: {
29
30
  getAuth: () => Promise<CopilotAuthState | undefined>;
30
31
  provider?: CopilotProviderConfig;
@@ -33,5 +34,7 @@ export declare function buildPluginHooks(input: {
33
34
  client?: CopilotRetryContext["client"];
34
35
  directory?: CopilotRetryContext["directory"];
35
36
  serverUrl?: CopilotRetryContext["serverUrl"];
37
+ clearAccountSwitchContext?: (lastAccountSwitchAt?: number) => Promise<void>;
38
+ now?: () => number;
36
39
  }): CopilotPluginHooksWithChatHeaders;
37
40
  export {};
@@ -1,11 +1,42 @@
1
1
  import { createLoopSafetySystemTransform, isCopilotProvider, } from "./loop-safety-plugin.js";
2
2
  import { createCopilotRetryingFetch, } from "./copilot-network-retry.js";
3
- import { readStoreSafe } from "./store.js";
3
+ import { createCopilotRetryNotifier } from "./copilot-retry-notifier.js";
4
+ import { readStoreSafe, writeStore } from "./store.js";
4
5
  import { loadOfficialCopilotConfig, } from "./upstream/copilot-loader-adapter.js";
6
+ function readRetryStoreContext(store) {
7
+ if (!store)
8
+ return undefined;
9
+ const maybeLastAccountSwitchAt = store.lastAccountSwitchAt;
10
+ return {
11
+ networkRetryEnabled: store.networkRetryEnabled,
12
+ lastAccountSwitchAt: typeof maybeLastAccountSwitchAt === "number" ? maybeLastAccountSwitchAt : undefined,
13
+ };
14
+ }
5
15
  export function buildPluginHooks(input) {
6
16
  const loadStore = input.loadStore ?? readStoreSafe;
17
+ const persistStore = input.writeStore ?? writeStore;
7
18
  const loadOfficialConfig = input.loadOfficialConfig ?? loadOfficialCopilotConfig;
8
19
  const createRetryFetch = input.createRetryFetch ?? createCopilotRetryingFetch;
20
+ const getLatestLastAccountSwitchAt = async () => {
21
+ const store = readRetryStoreContext(await loadStore().catch(() => undefined));
22
+ return store?.lastAccountSwitchAt;
23
+ };
24
+ const clearAccountSwitchContext = input.clearAccountSwitchContext ?? (async (capturedLastAccountSwitchAt) => {
25
+ if (capturedLastAccountSwitchAt === undefined)
26
+ return;
27
+ try {
28
+ const latestStore = await loadStore().catch(() => undefined);
29
+ if (!latestStore)
30
+ return;
31
+ if (latestStore.lastAccountSwitchAt !== capturedLastAccountSwitchAt)
32
+ return;
33
+ delete latestStore.lastAccountSwitchAt;
34
+ await persistStore(latestStore);
35
+ }
36
+ catch (error) {
37
+ console.warn("[plugin-hooks] failed to clear account-switch context", error);
38
+ }
39
+ });
9
40
  const loader = async (getAuth, provider) => {
10
41
  const config = await loadOfficialConfig({
11
42
  getAuth: getAuth,
@@ -13,7 +44,7 @@ export function buildPluginHooks(input) {
13
44
  });
14
45
  if (!config)
15
46
  return {};
16
- const store = await loadStore().catch(() => undefined);
47
+ const store = readRetryStoreContext(await loadStore().catch(() => undefined));
17
48
  if (store?.networkRetryEnabled !== true) {
18
49
  return config;
19
50
  }
@@ -23,6 +54,15 @@ export function buildPluginHooks(input) {
23
54
  client: input.client,
24
55
  directory: input.directory,
25
56
  serverUrl: input.serverUrl,
57
+ lastAccountSwitchAt: store.lastAccountSwitchAt,
58
+ notifier: createCopilotRetryNotifier({
59
+ client: input.client,
60
+ lastAccountSwitchAt: store.lastAccountSwitchAt,
61
+ getLastAccountSwitchAt: getLatestLastAccountSwitchAt,
62
+ clearAccountSwitchContext,
63
+ now: input.now,
64
+ }),
65
+ clearAccountSwitchContext: async () => clearAccountSwitchContext(store.lastAccountSwitchAt),
26
66
  }),
27
67
  };
28
68
  };
package/dist/plugin.d.ts CHANGED
@@ -1,2 +1,10 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin";
2
+ import { type StoreFile } from "./store.js";
3
+ export declare function activateAddedAccount(input: {
4
+ store: StoreFile;
5
+ name: string;
6
+ switchAccount: () => Promise<void>;
7
+ writeStore: (store: StoreFile) => Promise<void>;
8
+ now?: () => number;
9
+ }): Promise<void>;
2
10
  export declare const CopilotAccountSwitcher: Plugin;
package/dist/plugin.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { stdin as input, stdout as output } from "node:process";
3
- import { applyMenuAction } from "./plugin-actions.js";
3
+ import { applyMenuAction, persistAccountSwitch } from "./plugin-actions.js";
4
4
  import { buildPluginHooks } from "./plugin-hooks.js";
5
5
  import { isTTY } from "./ui/ansi.js";
6
6
  import { showAccountActions, showMenu } from "./ui/menu.js";
@@ -443,6 +443,16 @@ async function switchAccount(client, entry) {
443
443
  body: payload,
444
444
  });
445
445
  }
446
+ export async function activateAddedAccount(input) {
447
+ await input.writeStore(input.store);
448
+ await input.switchAccount();
449
+ await persistAccountSwitch({
450
+ store: input.store,
451
+ name: input.name,
452
+ at: (input.now ?? now)(),
453
+ writeStore: input.writeStore,
454
+ });
455
+ }
446
456
  export const CopilotAccountSwitcher = async (input) => {
447
457
  const client = input.client;
448
458
  const directory = input.directory;
@@ -487,8 +497,12 @@ export const CopilotAccountSwitcher = async (input) => {
487
497
  const { name, entry } = await promptAccountEntry([]);
488
498
  store.accounts[name] = entry;
489
499
  store.active = name;
490
- await writeStore(store);
491
- await switchAccount(client, entry);
500
+ await activateAddedAccount({
501
+ store,
502
+ name,
503
+ switchAccount: () => switchAccount(client, entry),
504
+ writeStore,
505
+ });
492
506
  // fallthrough to menu
493
507
  }
494
508
  if (!Object.values(store.accounts).some((entry) => entry.user || entry.email || (entry.orgs && entry.orgs.length > 0))) {
@@ -590,9 +604,17 @@ export const CopilotAccountSwitcher = async (input) => {
590
604
  entry.name = buildName(entry, user?.login);
591
605
  store.accounts[entry.name] = entry;
592
606
  store.active = store.active ?? entry.name;
593
- await writeStore(store);
594
- if (store.active === entry.name)
595
- await switchAccount(client, entry);
607
+ if (store.active === entry.name) {
608
+ await activateAddedAccount({
609
+ store,
610
+ name: entry.name,
611
+ switchAccount: () => switchAccount(client, entry),
612
+ writeStore,
613
+ });
614
+ }
615
+ else {
616
+ await writeStore(store);
617
+ }
596
618
  continue;
597
619
  }
598
620
  const manual = await promptAccountEntry(Object.keys(store.accounts));
@@ -606,9 +628,17 @@ export const CopilotAccountSwitcher = async (input) => {
606
628
  manual.entry.name = buildName(manual.entry, user?.login);
607
629
  store.accounts[manual.entry.name] = manual.entry;
608
630
  store.active = store.active ?? manual.entry.name;
609
- await writeStore(store);
610
- if (store.active === manual.entry.name)
611
- await switchAccount(client, manual.entry);
631
+ if (store.active === manual.entry.name) {
632
+ await activateAddedAccount({
633
+ store,
634
+ name: manual.entry.name,
635
+ switchAccount: () => switchAccount(client, manual.entry),
636
+ writeStore,
637
+ });
638
+ }
639
+ else {
640
+ await writeStore(store);
641
+ }
612
642
  continue;
613
643
  }
614
644
  if (action.type === "import") {
@@ -700,9 +730,12 @@ export const CopilotAccountSwitcher = async (input) => {
700
730
  continue;
701
731
  }
702
732
  await switchAccount(client, entry);
703
- store.active = name;
704
- store.accounts[name].lastUsed = now();
705
- await writeStore(store);
733
+ await persistAccountSwitch({
734
+ store,
735
+ name,
736
+ at: now(),
737
+ writeStore,
738
+ });
706
739
  console.log("Switched account. If a later Copilot session hits input[*].id too long after switching, enable Copilot Network Retry from the menu.");
707
740
  continue;
708
741
  }
package/dist/store.d.ts CHANGED
@@ -53,6 +53,7 @@ export type StoreFile = {
53
53
  accounts: Record<string, AccountEntry>;
54
54
  autoRefresh?: boolean;
55
55
  refreshMinutes?: number;
56
+ lastAccountSwitchAt?: number;
56
57
  lastQuotaRefresh?: number;
57
58
  loopSafetyEnabled?: boolean;
58
59
  networkRetryEnabled?: boolean;
package/dist/store.js CHANGED
@@ -16,6 +16,9 @@ export function parseStore(raw) {
16
16
  const data = raw ? JSON.parse(raw) : { accounts: {} };
17
17
  if (!data.accounts)
18
18
  data.accounts = {};
19
+ if (typeof data.lastAccountSwitchAt !== "number" || Number.isNaN(data.lastAccountSwitchAt)) {
20
+ delete data.lastAccountSwitchAt;
21
+ }
19
22
  if (data.loopSafetyEnabled !== false)
20
23
  data.loopSafetyEnabled = true;
21
24
  if (data.networkRetryEnabled !== true)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-copilot-account-switcher",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "description": "GitHub Copilot account switcher plugin for OpenCode",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",