opencode-copilot-account-switcher 0.2.8 → 0.3.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.
@@ -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,37 @@ export type CopilotRetryContext = {
27
44
  };
28
45
  }>;
29
46
  };
47
+ part?: {
48
+ update?: (input: {
49
+ sessionID: string;
50
+ messageID: string;
51
+ partID: string;
52
+ directory?: string;
53
+ part?: JsonRecord;
54
+ }) => Promise<unknown>;
55
+ };
56
+ tui?: {
57
+ showToast?: (options: {
58
+ body: {
59
+ title?: string;
60
+ message: string;
61
+ variant: "info" | "success" | "warning" | "error";
62
+ duration?: number;
63
+ };
64
+ query?: undefined;
65
+ }) => Promise<unknown>;
66
+ };
30
67
  };
31
68
  directory?: string;
32
69
  serverUrl?: URL;
70
+ lastAccountSwitchAt?: number;
71
+ clearAccountSwitchContext?: () => Promise<void>;
33
72
  wait?: (ms: number) => Promise<void>;
34
73
  patchPart?: (request: {
35
74
  url: string;
36
75
  init: RequestInit;
37
76
  }) => Promise<unknown>;
77
+ notifier?: CopilotRetryNotifier;
38
78
  };
39
79
  export declare function isRetryableCopilotFetchError(error: unknown): boolean;
40
80
  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")
@@ -268,6 +318,32 @@ async function repairSessionPart(sessionID, failingId, ctx) {
268
318
  },
269
319
  body: JSON.stringify(body),
270
320
  };
321
+ if (ctx?.client?.part?.update) {
322
+ try {
323
+ await ctx.client.part.update({
324
+ sessionID,
325
+ messageID: match.messageID,
326
+ partID: match.partID,
327
+ directory: ctx.directory,
328
+ part: body,
329
+ });
330
+ debugLog("input-id retry session repair", {
331
+ partID: match.partID,
332
+ messageID: match.messageID,
333
+ sessionID,
334
+ });
335
+ return true;
336
+ }
337
+ catch (error) {
338
+ debugLog("input-id retry session repair failed", {
339
+ partID: match.partID,
340
+ messageID: match.messageID,
341
+ sessionID,
342
+ error: String(error instanceof Error ? error.message : error),
343
+ });
344
+ return false;
345
+ }
346
+ }
271
347
  if (ctx?.patchPart) {
272
348
  try {
273
349
  await ctx.patchPart({ url: url.href, init });
@@ -316,14 +392,25 @@ async function repairSessionPart(sessionID, failingId, ctx) {
316
392
  return false;
317
393
  }
318
394
  }
319
- async function maybeRetryInputIdTooLong(request, init, response, baseFetch, ctx, sessionID) {
395
+ async function maybeRetryInputIdTooLong(request, init, response, baseFetch, requestPayload, ctx, sessionID, startedNotified = false) {
320
396
  if (response.status !== 400) {
321
- return { response, retried: false, nextInit: init, retryState: undefined };
397
+ return {
398
+ response,
399
+ retried: false,
400
+ nextInit: init,
401
+ nextPayload: requestPayload,
402
+ retryState: undefined,
403
+ };
322
404
  }
323
- const requestPayload = parseJsonBody(init);
324
405
  if (!requestPayload || !hasLongInputIds(requestPayload)) {
325
406
  debugLog("skip input-id retry: request has no long ids");
326
- return { response, retried: false, nextInit: init, retryState: undefined };
407
+ return {
408
+ response,
409
+ retried: false,
410
+ nextInit: init,
411
+ nextPayload: requestPayload,
412
+ retryState: undefined,
413
+ };
327
414
  }
328
415
  debugLog("input-id retry candidate", {
329
416
  status: response.status,
@@ -335,7 +422,13 @@ async function maybeRetryInputIdTooLong(request, init, response, baseFetch, ctx,
335
422
  .catch(() => "");
336
423
  if (!responseText) {
337
424
  debugLog("skip input-id retry: empty response body");
338
- return { response, retried: false, nextInit: init, retryState: undefined };
425
+ return {
426
+ response,
427
+ retried: false,
428
+ nextInit: init,
429
+ nextPayload: requestPayload,
430
+ retryState: undefined,
431
+ };
339
432
  }
340
433
  let parsed = parseInputIdTooLongDetails(responseText);
341
434
  let matched = parsed.matched;
@@ -361,31 +454,61 @@ async function maybeRetryInputIdTooLong(request, init, response, baseFetch, ctx,
361
454
  reportedLength: parsed.reportedLength,
362
455
  });
363
456
  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 };
457
+ return {
458
+ response,
459
+ retried: false,
460
+ nextInit: init,
461
+ nextPayload: requestPayload,
462
+ retryState: undefined,
463
+ };
371
464
  }
372
465
  const payloadCandidates = getPayloadCandidates(requestPayload);
466
+ const targetSelection = getTargetedLongInputIdSelection(requestPayload, parsed.serverReportedIndex, parsed.reportedLength);
373
467
  debugLog("input-id retry payload candidates", {
374
468
  serverReportedIndex: parsed.serverReportedIndex,
375
469
  candidates: payloadCandidates,
376
470
  });
377
471
  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,
472
+ const targetedPayload = targetSelection.candidate
473
+ ? payloadCandidates.find((item) => item.payloadIndex === targetSelection.candidate?.payloadIndex)
474
+ : undefined;
475
+ debugLog("input-id retry payload target", {
476
+ serverReportedIndex: parsed.serverReportedIndex,
477
+ targetedPayloadIndex: targetedPayload?.payloadIndex,
478
+ itemKind: targetedPayload?.itemKind,
479
+ idLength: targetedPayload?.idLength,
480
+ idPreview: targetedPayload?.idPreview,
481
+ strategy: targetSelection.strategy,
482
+ });
483
+ const remainingBefore = countLongInputIdCandidates(requestPayload);
484
+ if (!targetSelection.candidate) {
485
+ logCleanupStopped("evidence-insufficient", {
486
+ serverReportedIndex: parsed.serverReportedIndex,
487
+ reportedLength: parsed.reportedLength,
488
+ candidateCount: targetSelection.candidates.length,
489
+ reportedLengthMatched: targetSelection.reportedLengthMatched,
385
490
  });
491
+ return {
492
+ response,
493
+ retried: false,
494
+ nextInit: init,
495
+ nextPayload: requestPayload,
496
+ retryState: {
497
+ previousServerReportedIndex: parsed.serverReportedIndex,
498
+ previousErrorMessagePreview: buildMessagePreview(responseText),
499
+ remainingLongIdCandidatesBefore: remainingBefore,
500
+ remainingLongIdCandidatesAfter: remainingBefore,
501
+ previousReportedLength: parsed.reportedLength,
502
+ notifiedStarted: startedNotified || remainingBefore > 0,
503
+ repairFailed: false,
504
+ stopReason: "evidence-insufficient",
505
+ },
506
+ };
386
507
  }
508
+ const notifiedStarted = startedNotified || remainingBefore > 0;
509
+ let repairFailed = false;
387
510
  if (sessionID && failingId) {
388
- await repairSessionPart(sessionID, failingId, ctx).catch(() => false);
511
+ repairFailed = !(await repairSessionPart(sessionID, failingId, ctx).catch(() => false));
389
512
  }
390
513
  const sanitized = stripTargetedLongInputId(requestPayload, parsed.serverReportedIndex, parsed.reportedLength);
391
514
  if (sanitized === requestPayload) {
@@ -394,11 +517,15 @@ async function maybeRetryInputIdTooLong(request, init, response, baseFetch, ctx,
394
517
  response,
395
518
  retried: false,
396
519
  nextInit: init,
520
+ nextPayload: requestPayload,
397
521
  retryState: {
398
522
  previousServerReportedIndex: parsed.serverReportedIndex,
399
523
  previousErrorMessagePreview: buildMessagePreview(responseText),
400
- remainingLongIdCandidatesBefore: countLongInputIdCandidates(requestPayload),
401
- remainingLongIdCandidatesAfter: countLongInputIdCandidates(requestPayload),
524
+ remainingLongIdCandidatesBefore: remainingBefore,
525
+ remainingLongIdCandidatesAfter: remainingBefore,
526
+ previousReportedLength: parsed.reportedLength,
527
+ notifiedStarted,
528
+ repairFailed,
402
529
  },
403
530
  };
404
531
  }
@@ -411,14 +538,17 @@ async function maybeRetryInputIdTooLong(request, init, response, baseFetch, ctx,
411
538
  const retryState = {
412
539
  previousServerReportedIndex: parsed.serverReportedIndex,
413
540
  previousErrorMessagePreview: buildMessagePreview(responseText),
414
- remainingLongIdCandidatesBefore: countLongInputIdCandidates(requestPayload),
541
+ remainingLongIdCandidatesBefore: remainingBefore,
415
542
  remainingLongIdCandidatesAfter: countLongInputIdCandidates(parseJsonBody(nextInit)),
543
+ previousReportedLength: parsed.reportedLength,
544
+ notifiedStarted,
545
+ repairFailed,
416
546
  };
417
547
  debugLog("input-id retry response", {
418
548
  status: retried.status,
419
549
  contentType: retried.headers.get("content-type") ?? undefined,
420
550
  });
421
- return { response: retried, retried: true, nextInit, retryState };
551
+ return { response: retried, retried: true, nextInit, nextPayload: sanitized, retryState };
422
552
  }
423
553
  function toRetryableSystemError(error) {
424
554
  const base = error instanceof Error ? error : new Error(String(error));
@@ -468,9 +598,29 @@ async function getInputIdRetryErrorDetails(response) {
468
598
  return undefined;
469
599
  return {
470
600
  serverReportedIndex: parsed.serverReportedIndex,
601
+ reportedLength: parsed.reportedLength,
471
602
  errorMessagePreview: buildMessagePreview(message),
472
603
  };
473
604
  }
605
+ async function parseJsonRequestPayload(request, init) {
606
+ const initPayload = parseJsonBody(init);
607
+ if (initPayload)
608
+ return initPayload;
609
+ if (!(request instanceof Request))
610
+ return undefined;
611
+ try {
612
+ const body = await request.clone().text();
613
+ if (!body)
614
+ return undefined;
615
+ const parsed = JSON.parse(body);
616
+ if (!parsed || typeof parsed !== "object")
617
+ return undefined;
618
+ return parsed;
619
+ }
620
+ catch {
621
+ return undefined;
622
+ }
623
+ }
474
624
  function withStreamDebugLogs(response, request) {
475
625
  if (!isDebugEnabled())
476
626
  return response;
@@ -519,7 +669,7 @@ export function isRetryableCopilotFetchError(error) {
519
669
  return RETRYABLE_MESSAGES.some((part) => message.includes(part));
520
670
  }
521
671
  export function createCopilotRetryingFetch(baseFetch, options) {
522
- void options;
672
+ const notifier = options?.notifier ?? noopNotifier;
523
673
  return async function retryingFetch(request, init) {
524
674
  const sessionID = getHeader(request, init, INTERNAL_SESSION_HEADER);
525
675
  const safeRequest = stripInternalSessionHeaderFromRequest(request);
@@ -535,6 +685,7 @@ export function createCopilotRetryingFetch(baseFetch, options) {
535
685
  url: safeRequest instanceof Request ? safeRequest.url : safeRequest instanceof URL ? safeRequest.href : String(safeRequest),
536
686
  isCopilot: isCopilotUrl(safeRequest),
537
687
  });
688
+ let currentPayload = await parseJsonRequestPayload(safeRequest, effectiveInit);
538
689
  try {
539
690
  const response = await baseFetch(safeRequest, effectiveInit);
540
691
  debugLog("fetch resolved", {
@@ -545,27 +696,42 @@ export function createCopilotRetryingFetch(baseFetch, options) {
545
696
  let currentResponse = response;
546
697
  let currentInit = effectiveInit;
547
698
  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);
699
+ let shouldContinueInputIdRepair = countLongInputIdCandidates(currentPayload) > 0;
700
+ let startedNotified = false;
701
+ let finishedNotified = false;
702
+ let repairWarningNotified = false;
703
+ while (shouldContinueInputIdRepair) {
704
+ shouldContinueInputIdRepair = false;
705
+ const result = await maybeRetryInputIdTooLong(safeRequest, currentInit, currentResponse, baseFetch, currentPayload, options, sessionID, startedNotified);
553
706
  currentResponse = result.response;
554
707
  currentInit = result.nextInit;
708
+ currentPayload = result.nextPayload;
555
709
  if (result.retryState) {
710
+ if (!startedNotified && result.retryState.notifiedStarted) {
711
+ startedNotified = true;
712
+ await notify(notifier, "started", result.retryState.remainingLongIdCandidatesBefore);
713
+ }
714
+ if (result.retryState.repairFailed && !repairWarningNotified) {
715
+ await notify(notifier, "repairWarning", result.retryState.remainingLongIdCandidatesBefore);
716
+ repairWarningNotified = true;
717
+ }
556
718
  const currentError = await getInputIdRetryErrorDetails(currentResponse);
557
- let stopReason;
558
- if (result.retryState.remainingLongIdCandidatesAfter >= result.retryState.remainingLongIdCandidatesBefore) {
719
+ let stopReason = result.retryState.stopReason;
720
+ const madeProgress = result.retryState.remainingLongIdCandidatesAfter < result.retryState.remainingLongIdCandidatesBefore;
721
+ if (!stopReason && result.retryState.remainingLongIdCandidatesAfter >= result.retryState.remainingLongIdCandidatesBefore) {
559
722
  stopReason = "remaining-candidates-not-reduced";
560
723
  }
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
- }
724
+ if (!stopReason &&
725
+ currentError &&
726
+ result.retryState.remainingLongIdCandidatesAfter > 0 &&
727
+ result.retryState.previousServerReportedIndex === currentError.serverReportedIndex &&
728
+ result.retryState.previousReportedLength === currentError.reportedLength) {
729
+ stopReason = "same-server-item-persists";
730
+ }
731
+ if (!stopReason && currentError && result.retryState.remainingLongIdCandidatesAfter === 0) {
732
+ stopReason = "local-candidates-exhausted";
567
733
  }
568
- if (currentError || stopReason) {
734
+ if ((currentError || stopReason) && stopReason !== "evidence-insufficient") {
569
735
  debugLog("input-id retry progress", {
570
736
  attempt: attempts + 1,
571
737
  previousServerReportedIndex: result.retryState.previousServerReportedIndex,
@@ -578,11 +744,40 @@ export function createCopilotRetryingFetch(baseFetch, options) {
578
744
  stopReason,
579
745
  });
580
746
  }
581
- if (stopReason)
747
+ if (stopReason === "local-candidates-exhausted") {
748
+ logCleanupStopped("local-candidates-exhausted", {
749
+ attempt: attempts + 1,
750
+ previousServerReportedIndex: result.retryState.previousServerReportedIndex,
751
+ currentServerReportedIndex: currentError?.serverReportedIndex,
752
+ });
753
+ }
754
+ if (stopReason) {
755
+ await notify(notifier, "stopped", result.retryState.remainingLongIdCandidatesAfter);
756
+ finishedNotified = true;
582
757
  break;
758
+ }
759
+ if (result.retried && madeProgress && result.retryState.remainingLongIdCandidatesAfter > 0) {
760
+ await notify(notifier, "progress", result.retryState.remainingLongIdCandidatesAfter);
761
+ }
762
+ if (result.retried && result.retryState.remainingLongIdCandidatesAfter === 0 && currentResponse.ok) {
763
+ await notify(notifier, "completed", 0);
764
+ finishedNotified = true;
765
+ }
766
+ if (result.retried && result.retryState.remainingLongIdCandidatesAfter === 0 && !currentResponse.ok) {
767
+ await notify(notifier, "stopped", 0);
768
+ finishedNotified = true;
769
+ }
770
+ if (result.retried && madeProgress && result.retryState.remainingLongIdCandidatesAfter > 0) {
771
+ shouldContinueInputIdRepair = true;
772
+ }
583
773
  }
584
- if (!result.retried)
774
+ if (!result.retried) {
775
+ if (startedNotified && !finishedNotified) {
776
+ await notify(notifier, "stopped", countLongInputIdCandidates(currentPayload));
777
+ finishedNotified = true;
778
+ }
585
779
  break;
780
+ }
586
781
  attempts += 1;
587
782
  }
588
783
  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
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1 @@
1
1
  export { CopilotAccountSwitcher } from "./plugin.js";
2
- export { applyMenuAction } from "./plugin-actions.js";
3
- export { buildPluginHooks } from "./plugin-hooks.js";
4
- export { createOfficialFetchAdapter, loadOfficialCopilotConfig } from "./upstream/copilot-loader-adapter.js";
package/dist/index.js CHANGED
@@ -1,4 +1 @@
1
1
  export { CopilotAccountSwitcher } from "./plugin.js";
2
- export { applyMenuAction } from "./plugin-actions.js";
3
- export { buildPluginHooks } from "./plugin-hooks.js";
4
- export { createOfficialFetchAdapter, loadOfficialCopilotConfig } from "./upstream/copilot-loader-adapter.js";
@@ -0,0 +1,3 @@
1
+ export { applyMenuAction } from "./plugin-actions.js";
2
+ export { buildPluginHooks } from "./plugin-hooks.js";
3
+ export { createOfficialFetchAdapter, loadOfficialCopilotConfig } from "./upstream/copilot-loader-adapter.js";
@@ -0,0 +1,3 @@
1
+ export { applyMenuAction } from "./plugin-actions.js";
2
+ export { buildPluginHooks } from "./plugin-hooks.js";
3
+ export { createOfficialFetchAdapter, loadOfficialCopilotConfig } from "./upstream/copilot-loader-adapter.js";
@@ -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,9 +1,19 @@
1
1
  {
2
2
  "name": "opencode-copilot-account-switcher",
3
- "version": "0.2.8",
3
+ "version": "0.3.1",
4
4
  "description": "GitHub Copilot account switcher plugin for OpenCode",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ },
12
+ "./internal": {
13
+ "types": "./dist/internal.d.ts",
14
+ "default": "./dist/internal.js"
15
+ }
16
+ },
7
17
  "type": "module",
8
18
  "license": "MPL-2.0",
9
19
  "author": "jiwangyihao",