opencode-copilot-account-switcher 0.2.7 → 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 MAX_INPUT_ID_REPAIR_ATTEMPTS = 3;
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";
@@ -70,6 +69,9 @@ function parseInputIdTooLongDetails(text) {
70
69
  function buildIdPreview(id) {
71
70
  return `${id.slice(0, 12)}...`;
72
71
  }
72
+ function buildMessagePreview(message) {
73
+ return message.slice(0, 160);
74
+ }
73
75
  function getPayloadCandidates(payload) {
74
76
  const input = payload.input;
75
77
  if (!Array.isArray(input))
@@ -89,33 +91,82 @@ function hasLongInputIds(payload) {
89
91
  return false;
90
92
  return input.some((item) => typeof item?.id === "string" && (item.id?.length ?? 0) > 64);
91
93
  }
92
- function getTargetedLongInputId(payload, reportedLength) {
94
+ function countLongInputIdCandidates(payload) {
95
+ const input = payload?.input;
96
+ if (!Array.isArray(input))
97
+ return 0;
98
+ return input.filter((item) => typeof item?.id === "string" && (item.id?.length ?? 0) > 64)
99
+ .length;
100
+ }
101
+ function collectLongInputIdCandidates(payload) {
93
102
  const input = payload.input;
94
103
  if (!Array.isArray(input))
95
- return undefined;
96
- const matches = input.filter((item) => typeof item?.id === "string" && (item.id?.length ?? 0) > 64);
97
- if (matches.length === 0)
98
- return undefined;
104
+ return [];
105
+ return input.flatMap((item, payloadIndex) => {
106
+ const id = item?.id;
107
+ if (typeof id !== "string" || id.length <= 64)
108
+ return [];
109
+ return [{ item: item, payloadIndex, idLength: id.length }];
110
+ });
111
+ }
112
+ function getTargetedLongInputIdSelection(payload, serverReportedIndex, reportedLength) {
113
+ const matches = collectLongInputIdCandidates(payload);
114
+ if (matches.length === 0) {
115
+ return {
116
+ strategy: "ambiguous",
117
+ candidates: [],
118
+ reportedLengthMatched: reportedLength === undefined,
119
+ };
120
+ }
99
121
  const lengthMatches = reportedLength
100
- ? matches.filter((item) => String(item.id ?? "").length === reportedLength)
122
+ ? matches.filter((item) => item.idLength === reportedLength)
101
123
  : matches;
102
- if (lengthMatches.length === 1)
103
- return lengthMatches[0];
104
- if (lengthMatches.length > 1)
105
- return lengthMatches[0];
106
- if (matches.length === 1)
107
- return matches[0];
108
- return matches.reduce((best, item) => {
109
- const bestLength = String(best.id ?? "").length;
110
- const itemLength = String(item.id ?? "").length;
111
- return itemLength > bestLength ? item : best;
112
- }, matches[0]);
113
- }
114
- function stripTargetedLongInputId(payload, reportedLength) {
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
+ }
147
+ const narrowedCandidates = lengthMatches.length > 0 ? lengthMatches : matches;
148
+ if (typeof serverReportedIndex === "number") {
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
+ }
158
+ }
159
+ return {
160
+ strategy: "ambiguous",
161
+ candidates: narrowedCandidates,
162
+ reportedLengthMatched: true,
163
+ };
164
+ }
165
+ function stripTargetedLongInputId(payload, serverReportedIndex, reportedLength) {
115
166
  const input = payload.input;
116
167
  if (!Array.isArray(input))
117
168
  return payload;
118
- const target = getTargetedLongInputId(payload, reportedLength);
169
+ const target = getTargetedLongInputIdSelection(payload, serverReportedIndex, reportedLength).candidate?.item;
119
170
  if (!target)
120
171
  return payload;
121
172
  let changed = false;
@@ -159,6 +210,21 @@ function buildRetryInit(init, payload) {
159
210
  body: JSON.stringify(payload),
160
211
  };
161
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
+ }
162
228
  function stripInternalSessionHeaderFromRequest(request) {
163
229
  if (!(request instanceof Request))
164
230
  return request;
@@ -177,13 +243,19 @@ function getHeader(request, init, name) {
177
243
  return request.headers.get(name) ?? undefined;
178
244
  return undefined;
179
245
  }
180
- function getTargetedInputId(payload, reportedLength) {
181
- const target = getTargetedLongInputId(payload, reportedLength);
246
+ function getTargetedInputId(payload, serverReportedIndex, reportedLength) {
247
+ const target = getTargetedLongInputIdSelection(payload, serverReportedIndex, reportedLength).candidate?.item;
182
248
  const id = target?.id;
183
249
  if (typeof id !== "string")
184
250
  return undefined;
185
251
  return id;
186
252
  }
253
+ function logCleanupStopped(reason, details) {
254
+ debugLog("input-id retry cleanup-stopped", {
255
+ reason,
256
+ ...(details ?? {}),
257
+ });
258
+ }
187
259
  function stripOpenAIItemId(part) {
188
260
  const metadata = part.metadata;
189
261
  if (!metadata || typeof metadata !== "object")
@@ -247,30 +319,72 @@ async function repairSessionPart(sessionID, failingId, ctx) {
247
319
  body: JSON.stringify(body),
248
320
  };
249
321
  if (ctx?.patchPart) {
250
- await ctx.patchPart({ url: url.href, init });
322
+ try {
323
+ await ctx.patchPart({ url: url.href, init });
324
+ debugLog("input-id retry session repair", {
325
+ partID: match.partID,
326
+ messageID: match.messageID,
327
+ sessionID,
328
+ });
329
+ return true;
330
+ }
331
+ catch (error) {
332
+ debugLog("input-id retry session repair failed", {
333
+ partID: match.partID,
334
+ messageID: match.messageID,
335
+ sessionID,
336
+ error: String(error instanceof Error ? error.message : error),
337
+ });
338
+ return false;
339
+ }
340
+ }
341
+ try {
342
+ const response = await fetch(url, init);
251
343
  debugLog("input-id retry session repair", {
252
344
  partID: match.partID,
253
345
  messageID: match.messageID,
254
346
  sessionID,
347
+ ok: response.ok,
255
348
  });
256
- return true;
349
+ if (!response.ok) {
350
+ debugLog("input-id retry session repair failed", {
351
+ partID: match.partID,
352
+ messageID: match.messageID,
353
+ sessionID,
354
+ status: response.status,
355
+ });
356
+ }
357
+ return response.ok;
358
+ }
359
+ catch (error) {
360
+ debugLog("input-id retry session repair failed", {
361
+ partID: match.partID,
362
+ messageID: match.messageID,
363
+ sessionID,
364
+ error: String(error instanceof Error ? error.message : error),
365
+ });
366
+ return false;
257
367
  }
258
- const response = await fetch(url, init);
259
- debugLog("input-id retry session repair", {
260
- partID: match.partID,
261
- messageID: match.messageID,
262
- sessionID,
263
- ok: response.ok,
264
- });
265
- return response.ok;
266
368
  }
267
- async function maybeRetryInputIdTooLong(request, init, response, baseFetch, ctx, sessionID) {
268
- if (response.status !== 400)
269
- return { response, retried: false, nextInit: init };
270
- const requestPayload = parseJsonBody(init);
369
+ async function maybeRetryInputIdTooLong(request, init, response, baseFetch, requestPayload, ctx, sessionID, startedNotified = false) {
370
+ if (response.status !== 400) {
371
+ return {
372
+ response,
373
+ retried: false,
374
+ nextInit: init,
375
+ nextPayload: requestPayload,
376
+ retryState: undefined,
377
+ };
378
+ }
271
379
  if (!requestPayload || !hasLongInputIds(requestPayload)) {
272
380
  debugLog("skip input-id retry: request has no long ids");
273
- return { response, retried: false, nextInit: init };
381
+ return {
382
+ response,
383
+ retried: false,
384
+ nextInit: init,
385
+ nextPayload: requestPayload,
386
+ retryState: undefined,
387
+ };
274
388
  }
275
389
  debugLog("input-id retry candidate", {
276
390
  status: response.status,
@@ -282,7 +396,13 @@ async function maybeRetryInputIdTooLong(request, init, response, baseFetch, ctx,
282
396
  .catch(() => "");
283
397
  if (!responseText) {
284
398
  debugLog("skip input-id retry: empty response body");
285
- return { response, retried: false, nextInit: init };
399
+ return {
400
+ response,
401
+ retried: false,
402
+ nextInit: init,
403
+ nextPayload: requestPayload,
404
+ retryState: undefined,
405
+ };
286
406
  }
287
407
  let parsed = parseInputIdTooLongDetails(responseText);
288
408
  let matched = parsed.matched;
@@ -307,36 +427,81 @@ async function maybeRetryInputIdTooLong(request, init, response, baseFetch, ctx,
307
427
  serverReportedIndex: parsed.serverReportedIndex,
308
428
  reportedLength: parsed.reportedLength,
309
429
  });
310
- if (!matched)
311
- return { response, retried: false, nextInit: init };
312
- if (parsed.serverReportedIndex === undefined) {
313
- debugLog("skip input-id retry: missing server input index", {
314
- reportedLength: parsed.reportedLength,
315
- });
316
- return { response, retried: false, nextInit: init };
430
+ if (!matched) {
431
+ return {
432
+ response,
433
+ retried: false,
434
+ nextInit: init,
435
+ nextPayload: requestPayload,
436
+ retryState: undefined,
437
+ };
317
438
  }
318
439
  const payloadCandidates = getPayloadCandidates(requestPayload);
440
+ const targetSelection = getTargetedLongInputIdSelection(requestPayload, parsed.serverReportedIndex, parsed.reportedLength);
319
441
  debugLog("input-id retry payload candidates", {
320
442
  serverReportedIndex: parsed.serverReportedIndex,
321
443
  candidates: payloadCandidates,
322
444
  });
323
- const failingId = getTargetedInputId(requestPayload, parsed.reportedLength);
324
- const targetedPayload = payloadCandidates.find((item) => item.idLength === parsed.reportedLength) ?? payloadCandidates[0];
325
- if (targetedPayload) {
326
- debugLog("input-id retry payload target", {
327
- targetedPayloadIndex: targetedPayload.payloadIndex,
328
- itemKind: targetedPayload.itemKind,
329
- idLength: targetedPayload.idLength,
330
- idPreview: targetedPayload.idPreview,
445
+ const failingId = getTargetedInputId(requestPayload, parsed.serverReportedIndex, parsed.reportedLength);
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,
331
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
+ };
332
481
  }
482
+ const notifiedStarted = startedNotified || remainingBefore > 0;
483
+ let repairFailed = false;
333
484
  if (sessionID && failingId) {
334
- await repairSessionPart(sessionID, failingId, ctx).catch(() => false);
485
+ repairFailed = !(await repairSessionPart(sessionID, failingId, ctx).catch(() => false));
335
486
  }
336
- const sanitized = stripTargetedLongInputId(requestPayload, parsed.reportedLength);
487
+ const sanitized = stripTargetedLongInputId(requestPayload, parsed.serverReportedIndex, parsed.reportedLength);
337
488
  if (sanitized === requestPayload) {
338
489
  debugLog("skip input-id retry: sanitize made no changes");
339
- return { response, retried: false, nextInit: init };
490
+ return {
491
+ response,
492
+ retried: false,
493
+ nextInit: init,
494
+ nextPayload: requestPayload,
495
+ retryState: {
496
+ previousServerReportedIndex: parsed.serverReportedIndex,
497
+ previousErrorMessagePreview: buildMessagePreview(responseText),
498
+ remainingLongIdCandidatesBefore: remainingBefore,
499
+ remainingLongIdCandidatesAfter: remainingBefore,
500
+ previousReportedLength: parsed.reportedLength,
501
+ notifiedStarted,
502
+ repairFailed,
503
+ },
504
+ };
340
505
  }
341
506
  debugLog("input-id retry triggered", {
342
507
  removedLongIds: true,
@@ -344,11 +509,20 @@ async function maybeRetryInputIdTooLong(request, init, response, baseFetch, ctx,
344
509
  });
345
510
  const nextInit = buildRetryInit(init, sanitized);
346
511
  const retried = await baseFetch(request, nextInit);
512
+ const retryState = {
513
+ previousServerReportedIndex: parsed.serverReportedIndex,
514
+ previousErrorMessagePreview: buildMessagePreview(responseText),
515
+ remainingLongIdCandidatesBefore: remainingBefore,
516
+ remainingLongIdCandidatesAfter: countLongInputIdCandidates(parseJsonBody(nextInit)),
517
+ previousReportedLength: parsed.reportedLength,
518
+ notifiedStarted,
519
+ repairFailed,
520
+ };
347
521
  debugLog("input-id retry response", {
348
522
  status: retried.status,
349
523
  contentType: retried.headers.get("content-type") ?? undefined,
350
524
  });
351
- return { response: retried, retried: true, nextInit };
525
+ return { response: retried, retried: true, nextInit, nextPayload: sanitized, retryState };
352
526
  }
353
527
  function toRetryableSystemError(error) {
354
528
  const base = error instanceof Error ? error : new Error(String(error));
@@ -370,6 +544,57 @@ function isCopilotUrl(request) {
370
544
  return false;
371
545
  }
372
546
  }
547
+ async function getInputIdRetryErrorDetails(response) {
548
+ if (response.status !== 400)
549
+ return undefined;
550
+ const responseText = await response
551
+ .clone()
552
+ .text()
553
+ .catch(() => "");
554
+ if (!responseText)
555
+ return undefined;
556
+ let parsed = parseInputIdTooLongDetails(responseText);
557
+ let matched = parsed.matched;
558
+ let message = responseText;
559
+ if (!matched) {
560
+ try {
561
+ const bodyPayload = JSON.parse(responseText);
562
+ const error = bodyPayload.error;
563
+ message = String(error?.message ?? "");
564
+ parsed = parseInputIdTooLongDetails(message);
565
+ matched = parsed.matched || isInputIdTooLongErrorBody(bodyPayload);
566
+ }
567
+ catch {
568
+ matched = false;
569
+ }
570
+ }
571
+ if (!matched)
572
+ return undefined;
573
+ return {
574
+ serverReportedIndex: parsed.serverReportedIndex,
575
+ reportedLength: parsed.reportedLength,
576
+ errorMessagePreview: buildMessagePreview(message),
577
+ };
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
+ }
373
598
  function withStreamDebugLogs(response, request) {
374
599
  if (!isDebugEnabled())
375
600
  return response;
@@ -418,7 +643,7 @@ export function isRetryableCopilotFetchError(error) {
418
643
  return RETRYABLE_MESSAGES.some((part) => message.includes(part));
419
644
  }
420
645
  export function createCopilotRetryingFetch(baseFetch, options) {
421
- void options;
646
+ const notifier = options?.notifier ?? noopNotifier;
422
647
  return async function retryingFetch(request, init) {
423
648
  const sessionID = getHeader(request, init, INTERNAL_SESSION_HEADER);
424
649
  const safeRequest = stripInternalSessionHeaderFromRequest(request);
@@ -434,6 +659,7 @@ export function createCopilotRetryingFetch(baseFetch, options) {
434
659
  url: safeRequest instanceof Request ? safeRequest.url : safeRequest instanceof URL ? safeRequest.href : String(safeRequest),
435
660
  isCopilot: isCopilotUrl(safeRequest),
436
661
  });
662
+ let currentPayload = await parseJsonRequestPayload(safeRequest, effectiveInit);
437
663
  try {
438
664
  const response = await baseFetch(safeRequest, effectiveInit);
439
665
  debugLog("fetch resolved", {
@@ -443,12 +669,90 @@ export function createCopilotRetryingFetch(baseFetch, options) {
443
669
  if (isCopilotUrl(safeRequest)) {
444
670
  let currentResponse = response;
445
671
  let currentInit = effectiveInit;
446
- for (let attempt = 0; attempt < MAX_INPUT_ID_REPAIR_ATTEMPTS; attempt += 1) {
447
- const result = await maybeRetryInputIdTooLong(safeRequest, currentInit, currentResponse, baseFetch, options, sessionID);
672
+ let attempts = 0;
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);
448
680
  currentResponse = result.response;
449
681
  currentInit = result.nextInit;
450
- if (!result.retried)
682
+ currentPayload = result.nextPayload;
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
+ }
692
+ const currentError = await getInputIdRetryErrorDetails(currentResponse);
693
+ let stopReason = result.retryState.stopReason;
694
+ const madeProgress = result.retryState.remainingLongIdCandidatesAfter < result.retryState.remainingLongIdCandidatesBefore;
695
+ if (!stopReason && result.retryState.remainingLongIdCandidatesAfter >= result.retryState.remainingLongIdCandidatesBefore) {
696
+ stopReason = "remaining-candidates-not-reduced";
697
+ }
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";
704
+ }
705
+ if (!stopReason && currentError && result.retryState.remainingLongIdCandidatesAfter === 0) {
706
+ stopReason = "local-candidates-exhausted";
707
+ }
708
+ if ((currentError || stopReason) && stopReason !== "evidence-insufficient") {
709
+ debugLog("input-id retry progress", {
710
+ attempt: attempts + 1,
711
+ previousServerReportedIndex: result.retryState.previousServerReportedIndex,
712
+ currentServerReportedIndex: currentError?.serverReportedIndex,
713
+ serverIndexChanged: result.retryState.previousServerReportedIndex !== currentError?.serverReportedIndex,
714
+ previousErrorMessagePreview: result.retryState.previousErrorMessagePreview,
715
+ currentErrorMessagePreview: currentError?.errorMessagePreview,
716
+ remainingLongIdCandidatesBefore: result.retryState.remainingLongIdCandidatesBefore,
717
+ remainingLongIdCandidatesAfter: result.retryState.remainingLongIdCandidatesAfter,
718
+ stopReason,
719
+ });
720
+ }
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;
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
+ }
747
+ }
748
+ if (!result.retried) {
749
+ if (startedNotified && !finishedNotified) {
750
+ await notify(notifier, "stopped", countLongInputIdCandidates(currentPayload));
751
+ finishedNotified = true;
752
+ }
451
753
  break;
754
+ }
755
+ attempts += 1;
452
756
  }
453
757
  return withStreamDebugLogs(currentResponse, safeRequest);
454
758
  }
@@ -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,6 +1,6 @@
1
1
  import type { Hooks } from "@opencode-ai/plugin";
2
2
  import { type StoreFile } from "./store.js";
3
- export declare const LOOP_SAFETY_POLICY = "Guided Loop Safety Policy\n- Continue working on any remaining non-blocked task before stopping to report or wait for more instructions.\n- If you are not fully blocked, do not stop just because you feel ready to pause; finish the work that can still be done safely.\n- MANDATORY: When the question tool is available and permitted in the current session, ALL user-facing reports MUST be delivered through the question tool.\n- MANDATORY: Treat progress updates, status reports, summaries, explanations, completion notes, and next-step choices as user-facing reports.\n- MANDATORY: In this workflow, the question tool is both the question channel and the report channel for user-visible communication.\n- MANDATORY: Do not reinterpret \"keep working unless blocked\" as permission to send direct assistant text. If you need to report, report through the question tool.\n- MANDATORY: Do not reinterpret \"don't ask unnecessary questions\" as permission to skip the question tool. Use fewer question batches, not a different reporting channel.\n- MANDATORY: If the user says a report was sent the wrong way, first check whether you used the wrong channel before analyzing verbosity, timing, or wording.\n- MANDATORY: When reflecting on mistakes, check report-channel violations before any secondary critique.\n- MANDATORY: Do not turn a pure report into a preference question, permission question, or route-selection question just to satisfy the question tool requirement.\n- MANDATORY: When no user decision is needed, use the question tool as a report container with the smallest sensible option set.\n- NO EXCEPTIONS: Do not switch to direct assistant text just because the report is long, detailed, complex, or contains multiple related items.\n- The question tool is considered available and permitted when it appears in the active tool list and the current session has not denied its use.\n- Direct assistant text is allowed only when the question tool is unavailable, denied, or absent from the current session.\n- If a report would otherwise be sent as direct text, convert it into one question batch or a short sequence of question batches instead.\n- IMPORTANT: A report-mode violation usually means the wrong tool was used, not that the report was too long.\n- IMPORTANT: Do not reinterpret a tool-usage violation as a verbosity problem.\n- Long reports are allowed. Use pagination or sequential question batches when needed; do not fall back to direct assistant text.\n- A single question tool call may contain multiple well-grouped questions. Prefer that over multiple small interruptions when it keeps the report clear.\n- Group related items into explicit batches such as current progress, key findings, decisions, and next-step choices.\n- Present the highest-priority information first and defer secondary details to later question batches when needed.\n- Even when no explicit decision is required, use brief question-tool status updates instead of direct assistant text whenever the tool is available.\n- Avoid unnecessary question frequency; combine small related updates when a single question call can cover them clearly.\n- MANDATORY: After any successful question-tool report, immediately choose between (a) continue unfinished non-blocked work, or (b) issue a question-tool wait-for-instruction message when no such work remains.\n- MANDATORY: Never send assistant text as a post-report fallback in either branch.\n- MANDATORY: If a post-report branch has no content, suppress assistant output and re-enter question-tool flow.\n- When no further action can be taken safely and no non-blocked work remains, use the question tool to ask for the next task or clarification instead of ending with direct assistant text.\n- When the user says \"stop\", do not send assistant text to acknowledge the stop. Use the question tool only if more user-visible communication is still required by policy.\n- Dispatching task or subagent work is expensive and should be avoided unless it materially improves the result.\n- Materially improves the result means clearly beneficial cases such as parallel analysis of independent areas; it does not include routine local searches, small file reads, or straightforward edits.\n- If task or subagent delegation is used, keep the number minimal and explain the reason briefly through the question tool when available.";
3
+ export declare const LOOP_SAFETY_POLICY = "Guided Loop Safety Policy\n- Continue working on any remaining non-blocked task before stopping to report or wait for more instructions.\n- If you are not fully blocked, do not stop just because you feel ready to pause; finish the work that can still be done safely.\n- MANDATORY: When the question tool is available and permitted in the current session, ALL user-facing reports MUST be delivered through the question tool.\n- MANDATORY: Treat progress updates, status reports, summaries, explanations, completion notes, and next-step choices as user-facing reports.\n- MANDATORY: In this workflow, the question tool is both the question channel and the report channel for user-visible communication.\n- MANDATORY: Do not reinterpret \"keep working unless blocked\" as permission to send direct assistant text. If you need to report, report through the question tool.\n- MANDATORY: Do not reinterpret \"don't ask unnecessary questions\" as permission to skip the question tool. Use fewer question batches, not a different reporting channel.\n- MANDATORY: If the user says a report was sent the wrong way, first check whether you used the wrong channel before analyzing verbosity, timing, or wording.\n- MANDATORY: When reflecting on report-delivery or tool-usage mistakes, first check report-channel violations before any secondary critique.\n- MANDATORY: Do not reinterpret those report-channel violations as a problem with the report policy itself.\n- MANDATORY: Do not turn a pure report into a preference question, permission question, or route-selection question just to satisfy the question tool requirement.\n- MANDATORY: When no user decision is needed, use the question tool as a report container with the smallest sensible option set.\n- NO EXCEPTIONS: Do not switch to direct assistant text just because the report is long, detailed, complex, or contains multiple related items.\n- The question tool is considered available and permitted when it appears in the active tool list and the current session has not denied its use.\n- Direct assistant text is allowed only when the question tool is unavailable, denied, or absent from the current session.\n- If a report would otherwise be sent as direct text, convert it into one question batch or a short sequence of question batches instead.\n- IMPORTANT: A report-mode violation usually means the wrong tool was used, not that the report was too long.\n- IMPORTANT: Do not reinterpret a tool-usage violation as a verbosity problem.\n- Long reports are allowed. Use pagination or sequential question batches when needed; do not fall back to direct assistant text.\n- A single question tool call may contain multiple well-grouped questions. Prefer that over multiple small interruptions when it keeps the report clear.\n- Group related items into explicit batches such as current progress, key findings, decisions, and next-step choices.\n- Present the highest-priority information first and defer secondary details to later question batches when needed.\n- Even when no explicit decision is required, use brief question-tool status updates instead of direct assistant text whenever the tool is available.\n- Avoid unnecessary question frequency; combine small related updates when a single question call can cover them clearly.\n- MANDATORY: After any successful question-tool report, immediately choose between (a) continue unfinished non-blocked work, or (b) issue a question-tool wait-for-instruction message when no such work remains.\n- MANDATORY: Never send assistant text as a post-report fallback in either branch.\n- MANDATORY: If a post-report branch has no content, suppress assistant output and re-enter question-tool flow.\n- MANDATORY: When idle or waiting for the next task, continue using the question tool to maintain user control of the session.\n- MANDATORY: Repeated waiting for the next task is not a reason to stop using the question tool.\n- When no further action can be taken safely and no non-blocked work remains, use the question tool to ask for the next task or clarification instead of ending with direct assistant text.\n- When the user says \"stop\", do not send assistant text to acknowledge the stop. Use the question tool only if more user-visible communication is still required by policy.\n- Dispatching task or subagent work is expensive and should be avoided unless it materially improves the result.\n- Materially improves the result means clearly beneficial cases such as parallel analysis of independent areas; it does not include routine local searches, small file reads, or straightforward edits.\n- If task or subagent delegation is used, keep the number minimal and explain the reason briefly through the question tool when available.";
4
4
  export type ExperimentalChatSystemTransformHook = (input: {
5
5
  sessionID: string;
6
6
  model: {
@@ -8,7 +8,8 @@ export const LOOP_SAFETY_POLICY = `Guided Loop Safety Policy
8
8
  - MANDATORY: Do not reinterpret "keep working unless blocked" as permission to send direct assistant text. If you need to report, report through the question tool.
9
9
  - MANDATORY: Do not reinterpret "don't ask unnecessary questions" as permission to skip the question tool. Use fewer question batches, not a different reporting channel.
10
10
  - MANDATORY: If the user says a report was sent the wrong way, first check whether you used the wrong channel before analyzing verbosity, timing, or wording.
11
- - MANDATORY: When reflecting on mistakes, check report-channel violations before any secondary critique.
11
+ - MANDATORY: When reflecting on report-delivery or tool-usage mistakes, first check report-channel violations before any secondary critique.
12
+ - MANDATORY: Do not reinterpret those report-channel violations as a problem with the report policy itself.
12
13
  - MANDATORY: Do not turn a pure report into a preference question, permission question, or route-selection question just to satisfy the question tool requirement.
13
14
  - MANDATORY: When no user decision is needed, use the question tool as a report container with the smallest sensible option set.
14
15
  - NO EXCEPTIONS: Do not switch to direct assistant text just because the report is long, detailed, complex, or contains multiple related items.
@@ -26,6 +27,8 @@ export const LOOP_SAFETY_POLICY = `Guided Loop Safety Policy
26
27
  - MANDATORY: After any successful question-tool report, immediately choose between (a) continue unfinished non-blocked work, or (b) issue a question-tool wait-for-instruction message when no such work remains.
27
28
  - MANDATORY: Never send assistant text as a post-report fallback in either branch.
28
29
  - MANDATORY: If a post-report branch has no content, suppress assistant output and re-enter question-tool flow.
30
+ - MANDATORY: When idle or waiting for the next task, continue using the question tool to maintain user control of the session.
31
+ - MANDATORY: Repeated waiting for the next task is not a reason to stop using the question tool.
29
32
  - When no further action can be taken safely and no non-blocked work remains, use the question tool to ask for the next task or clarification instead of ending with direct assistant text.
30
33
  - When the user says "stop", do not send assistant text to acknowledge the stop. Use the question tool only if more user-visible communication is still required by policy.
31
34
  - Dispatching task or subagent work is expensive and should be avoided unless it materially improves the result.
@@ -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.7",
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",