@cortexkit/common-auth 0.2.9 → 0.4.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.
Files changed (60) hide show
  1. package/dist/cachekeep/manager.d.ts +18 -6
  2. package/dist/cachekeep/manager.js +40 -10
  3. package/dist/claustrum/consumer.d.ts +13 -4
  4. package/dist/claustrum/consumer.js +11 -3
  5. package/dist/claustrum/custody.d.ts +47 -6
  6. package/dist/claustrum/custody.js +37 -7
  7. package/dist/claustrum/errors.d.ts +1 -1
  8. package/dist/claustrum/index.d.ts +3 -3
  9. package/dist/claustrum/index.js +2 -2
  10. package/dist/claustrum/interlock.d.ts +15 -17
  11. package/dist/claustrum/interlock.js +19 -26
  12. package/dist/claustrum/roster.d.ts +96 -6
  13. package/dist/claustrum/roster.js +209 -44
  14. package/dist/commands/builtins.d.ts +1 -1
  15. package/dist/commands/builtins.js +6 -1
  16. package/dist/commands/index.d.ts +2 -2
  17. package/dist/commands/index.js +1 -1
  18. package/dist/commands/menu.d.ts +8 -0
  19. package/dist/commands/menu.js +34 -13
  20. package/dist/commands/model.d.ts +9 -0
  21. package/dist/commands/seam.d.ts +40 -4
  22. package/dist/commands/seam.js +132 -19
  23. package/dist/dump/index.d.ts +94 -0
  24. package/dist/dump/index.js +236 -9
  25. package/dist/logger/engine.d.ts +52 -17
  26. package/dist/logger/engine.js +178 -135
  27. package/dist/logger/index.d.ts +2 -2
  28. package/dist/logger/index.js +1 -1
  29. package/dist/opencode2/index.d.ts +1 -1
  30. package/dist/opencode2/install.d.ts +23 -5
  31. package/dist/opencode2/install.js +335 -140
  32. package/dist/opencode2/types.d.ts +149 -9
  33. package/dist/quota/projection.d.ts +11 -4
  34. package/dist/quota/projection.js +11 -4
  35. package/dist/routing/admission.js +3 -1
  36. package/dist/routing/index.d.ts +2 -2
  37. package/dist/routing/index.js +1 -1
  38. package/dist/routing/sticky.d.ts +19 -6
  39. package/dist/routing/sticky.js +34 -23
  40. package/dist/rpc/notifications.d.ts +20 -0
  41. package/dist/rpc/notifications.js +21 -0
  42. package/dist/rpc/rpc-server.d.ts +9 -1
  43. package/dist/rpc/rpc-server.js +8 -1
  44. package/dist/sidebar-file/index.d.ts +1 -1
  45. package/dist/sidebar-file/sidebar-file.d.ts +50 -2
  46. package/dist/sidebar-file/sidebar-file.js +92 -21
  47. package/dist/store/attribution.js +11 -2
  48. package/dist/store/errors.d.ts +6 -3
  49. package/dist/store/identity.d.ts +13 -4
  50. package/dist/store/mutate.d.ts +23 -3
  51. package/dist/store/mutate.js +43 -26
  52. package/dist/store/pool.d.ts +8 -1
  53. package/dist/store/pool.js +7 -2
  54. package/dist/store/rows.d.ts +17 -4
  55. package/dist/store/rows.js +82 -33
  56. package/dist/store/schema.d.ts +57 -4
  57. package/dist/store/schema.js +100 -7
  58. package/dist/store/torn.d.ts +29 -0
  59. package/dist/store/torn.js +113 -0
  60. package/package.json +1 -1
@@ -10,6 +10,49 @@ const scopeOf = (draft) => ({
10
10
  agent: draft.agent,
11
11
  kind: draft.kind,
12
12
  });
13
+ // A header value is a string or null, so an object under `headers` can only
14
+ // be the `{headers, attempt}` form.
15
+ function isHeadersResult(value) {
16
+ const headers = value.headers;
17
+ return typeof headers === 'object' && headers !== null;
18
+ }
19
+ /**
20
+ * Forwards a response body and reports how it ended: read to the end
21
+ * (`undefined`), errored, or cancelled by whoever was reading it.
22
+ */
23
+ function trackBody(source, onEnd) {
24
+ const reader = source.getReader();
25
+ return new ReadableStream({
26
+ async pull(controller) {
27
+ let chunk;
28
+ try {
29
+ chunk = await reader.read();
30
+ }
31
+ catch (error) {
32
+ onEnd({
33
+ reason: 'failed',
34
+ message: error instanceof Error ? error.message : String(error),
35
+ });
36
+ controller.error(error);
37
+ return;
38
+ }
39
+ if (chunk.done) {
40
+ onEnd();
41
+ controller.close();
42
+ }
43
+ else {
44
+ controller.enqueue(chunk.value);
45
+ }
46
+ },
47
+ async cancel(reason) {
48
+ onEnd({
49
+ reason: 'cancelled',
50
+ ...(reason === undefined ? {} : { message: String(reason) }),
51
+ });
52
+ await reader.cancel(reason);
53
+ },
54
+ });
55
+ }
13
56
  /** Applies edits to a plain header record, replacing every spelling of each name. */
14
57
  export function applyHeaderEdits(target, edits) {
15
58
  for (const [name, value] of Object.entries(edits)) {
@@ -34,15 +77,33 @@ function applyHeaderEditsTo(target, edits) {
34
77
  * Installs multi-account auth on OpenCode 2's own provider drivers. Every
35
78
  * hook is scoped to `adapter.providerID`:
36
79
  *
37
- * - `model.request` picks the account for the request's `sessionID:kind` and
38
- * sets its headers;
80
+ * - `model.request` picks the account for the request's `sessionID:kind`,
81
+ * which starts a new attempt, and sets its headers;
39
82
  * - `http.request` and `experimental.ws.handshake` set them again, because
40
83
  * the host applies its own credential after `model.request`;
41
- * - `http.response` and `experimental.ws.receive` read quota, refusals and
42
- * whether output has started, attributed through the record above;
84
+ * - `experimental.ws.send` (only when the adapter rewrites frames) rewrites
85
+ * each outgoing frame;
86
+ * - `http.response` and `experimental.ws.receive` read quota, refusals,
87
+ * whether output has started and when the response ended, attributed to
88
+ * an attempt by the rules below;
43
89
  * - `retry` asks the host to retry at once when an account was refused
44
90
  * before any output, so `model.request` runs again and can pick another
45
91
  * account, and refuses to retry once output has started.
92
+ *
93
+ * Attribution. An HTTP response belongs to the attempt whose `http.request`
94
+ * produced its request object, and to nothing else. The host hands
95
+ * `http.response` the request object the `http.request` hooks left, so a
96
+ * different object means a later hook replaced it; nothing then proves which
97
+ * send the response answers (an earlier attempt's send may still be in
98
+ * flight, and only the newest attempt of each session and kind is kept), and
99
+ * its feedback is dropped rather than guessed by recency. No marker can ride
100
+ * on the request instead: the host builds the wire request from that same
101
+ * object, so a marker would be sent to the provider. A WebSocket frame
102
+ * belongs to the newest attempt of its session and kind only while that
103
+ * attempt went out over WebSocket and has not ended: the host runs one
104
+ * exchange at a time per session socket, so frames between one handshake
105
+ * and the next belong to the earlier attempt. Anything else is attributed to
106
+ * no attempt and reaches no adapter callback or listener.
46
107
  */
47
108
  export async function installOpenCode2Auth(ctx, adapter, options = {}) {
48
109
  const { providerID } = adapter;
@@ -51,6 +112,7 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
51
112
  const forbidden = (options.hostCredentials ?? [placeholderSecret(providerID)]).filter((value) => value !== '');
52
113
  const records = new Map();
53
114
  const byRequest = new WeakMap();
115
+ let warnedUnprovenResponse = false;
54
116
  const listeners = new Map();
55
117
  let seq = 0;
56
118
  const warn = (message, data) => {
@@ -76,15 +138,40 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
76
138
  }
77
139
  }));
78
140
  };
141
+ const deliverEnd = async (attempt, outcome) => {
142
+ try {
143
+ await adapter.onAttemptEnd?.(attempt, outcome);
144
+ }
145
+ catch (error) {
146
+ warn('opencode2 auth onAttemptEnd threw; the request is unaffected', {
147
+ attemptId: attempt.attemptId,
148
+ error: describe(error),
149
+ });
150
+ }
151
+ };
152
+ /** Ends an attempt once; later calls for the same attempt do nothing. */
153
+ const finish = (rec, error) => {
154
+ if (rec.ended || !rec.attempt)
155
+ return;
156
+ const outcome = {
157
+ ...(rec.status === undefined ? {} : { status: rec.status }),
158
+ outputStarted: rec.outputStarted,
159
+ ...(rec.limit ? { limit: rec.limit.signal } : {}),
160
+ ...(error ? { error } : {}),
161
+ };
162
+ rec.ended = deliverEnd(rec.attempt, outcome);
163
+ };
164
+ const abandon = (rec, message) => finish(rec, { reason: 'abandoned', message });
79
165
  const remember = (rec) => {
80
166
  const key = keyOf(rec.scope.sessionID, rec.scope.kind);
81
167
  records.delete(key);
82
168
  records.set(key, rec);
83
169
  while (records.size > maxRecords) {
84
- const oldest = records.keys().next().value;
170
+ const oldest = records.entries().next().value;
85
171
  if (oldest === undefined)
86
172
  break;
87
- records.delete(oldest);
173
+ abandon(oldest[1], 'dropped to keep the record bound');
174
+ records.delete(oldest[0]);
88
175
  }
89
176
  };
90
177
  const accountOf = (rec) => rec.accountId === undefined
@@ -109,8 +196,10 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
109
196
  }
110
197
  }
111
198
  };
112
- const select = async (scope, hook) => {
199
+ const select = async (scope, hook, transport) => {
113
200
  const prior = records.get(keyOf(scope.sessionID, scope.kind));
201
+ if (prior)
202
+ abandon(prior, 'a newer attempt of its session and kind began');
114
203
  const input = {
115
204
  ...scope,
116
205
  ...(prior?.accountId === undefined
@@ -121,21 +210,41 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
121
210
  : { rerouteFrom: prior.rerouteFrom }),
122
211
  };
123
212
  const accountId = await adapter.chooseAccount(input);
124
- const headers = accountId === undefined
125
- ? {}
126
- : await adapter.accountHeaders({ ...scope, accountId });
213
+ let headers = {};
214
+ let data;
215
+ if (accountId !== undefined) {
216
+ const result = await adapter.accountHeaders({ ...scope, accountId });
217
+ if (isHeadersResult(result)) {
218
+ headers = result.headers;
219
+ data = result.attempt;
220
+ }
221
+ else {
222
+ headers = result;
223
+ }
224
+ }
225
+ const id = ++seq;
127
226
  const rec = {
128
227
  scope,
129
228
  accountId,
130
229
  headers,
131
- seq: ++seq,
230
+ seq: id,
231
+ attempt: accountId === undefined
232
+ ? undefined
233
+ : {
234
+ ...scope,
235
+ accountId,
236
+ attemptId: `attempt-${id}`,
237
+ transport,
238
+ data,
239
+ },
240
+ ...(transport === undefined ? {} : { transport }),
132
241
  outputStarted: false,
133
242
  };
134
243
  remember(rec);
135
- if (accountId !== undefined) {
244
+ if (rec.attempt) {
136
245
  void emit('select', {
137
246
  ...scope,
138
- accountId,
247
+ accountId: rec.attempt.accountId,
139
248
  hook,
140
249
  ...(input.previousAccountId === undefined
141
250
  ? {}
@@ -143,17 +252,40 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
143
252
  ...(input.rerouteFrom === undefined
144
253
  ? {}
145
254
  : { rerouteFrom: input.rerouteFrom }),
255
+ handle: rec.attempt,
146
256
  });
147
257
  }
148
258
  return rec;
149
259
  };
150
- // The transport hooks normally follow `model.request`; choosing here too
151
- // keeps a request that skipped it from going out under the host credential.
152
- const ensure = async (scope, hook) => records.get(keyOf(scope.sessionID, scope.kind)) ??
153
- (await select(scope, hook));
260
+ // The transport hooks normally follow `model.request` and carry the
261
+ // attempt it started. Choosing here too keeps a request that skipped it
262
+ // from going out under the host credential, and a second send without a
263
+ // `model.request` in between from reusing the first send's attempt.
264
+ const bind = async (scope, hook, transport) => {
265
+ const rec = records.get(keyOf(scope.sessionID, scope.kind));
266
+ if (!rec || rec.transport !== undefined || rec.ended)
267
+ return select(scope, hook, transport);
268
+ rec.transport = transport;
269
+ if (rec.attempt)
270
+ rec.attempt.transport = transport;
271
+ return rec;
272
+ };
273
+ // A transport hook that throws stops the send, so its attempt is over.
274
+ const failing = async (rec, run) => {
275
+ try {
276
+ return await run();
277
+ }
278
+ catch (error) {
279
+ finish(rec, { reason: 'failed', message: describe(error) });
280
+ throw error;
281
+ }
282
+ };
283
+ const liveOn = (rec, transport) => rec?.attempt !== undefined && rec.transport === transport && !rec.ended
284
+ ? rec
285
+ : undefined;
154
286
  const noteLimit = (rec, signal, via) => {
155
287
  const account = accountOf(rec);
156
- if (!account || rec.limit)
288
+ if (!account || !rec.attempt || rec.limit)
157
289
  return;
158
290
  rec.limit = {
159
291
  signal,
@@ -162,22 +294,29 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
162
294
  via,
163
295
  limit: signal,
164
296
  outputStarted: rec.outputStarted,
297
+ handle: rec.attempt,
165
298
  }),
166
299
  };
167
300
  };
168
301
  const noteQuota = (rec, transport, quota, status) => {
169
302
  const account = accountOf(rec);
170
- if (!account)
303
+ if (!account || !rec.attempt)
171
304
  return;
172
305
  void emit('quota', {
173
306
  ...account,
174
307
  transport,
175
308
  ...(status === undefined ? {} : { status }),
176
309
  quota,
310
+ handle: rec.attempt,
177
311
  });
178
312
  };
179
313
  const inspect = (rec, transport, data, event) => {
180
- const verdict = adapter.inspectEvent?.(event === undefined ? { transport, data } : { transport, data, event });
314
+ const attempt = rec.attempt;
315
+ if (!attempt)
316
+ return;
317
+ const verdict = adapter.inspectEvent?.(event === undefined
318
+ ? { transport, data, attempt }
319
+ : { transport, data, event, attempt });
181
320
  if (!verdict)
182
321
  return;
183
322
  if (verdict.quota !== undefined)
@@ -186,6 +325,10 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
186
325
  rec.outputStarted = true;
187
326
  if (verdict.limit)
188
327
  noteLimit(rec, verdict.limit, transport);
328
+ if (verdict.error !== undefined)
329
+ finish(rec, { reason: 'failed', message: verdict.error });
330
+ else if (verdict.done)
331
+ finish(rec);
189
332
  };
190
333
  const pickForRetry = (sessionID) => {
191
334
  let refused;
@@ -204,22 +347,28 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
204
347
  return refused ?? primary ?? latest;
205
348
  };
206
349
  const scoped = { providerID };
207
- const registrations = [
208
- await ctx.session.hook('model.request', async (draft) => {
209
- const rec = await select(scopeOf(draft), 'model.request');
210
- if (rec.accountId === undefined)
211
- throw noAccount(rec.scope);
212
- applyHeaderEdits(draft.headers, rec.headers);
213
- }, scoped),
214
- await ctx.session.hook('http.request', async (draft) => {
215
- const rec = await ensure(scopeOf(draft), 'http.request');
216
- const account = accountOf(rec);
217
- if (!account)
218
- throw noAccount(rec.scope);
350
+ const registrations = [];
351
+ registrations.push(await ctx.session.hook('model.request', async (draft) => {
352
+ const rec = await select(scopeOf(draft), 'model.request');
353
+ if (rec.accountId === undefined)
354
+ throw noAccount(rec.scope);
355
+ applyHeaderEdits(draft.headers, rec.headers);
356
+ }, scoped));
357
+ registrations.push(await ctx.session.hook('http.request', async (draft) => {
358
+ const rec = await bind(scopeOf(draft), 'http.request', 'http');
359
+ const account = accountOf(rec);
360
+ const attempt = rec.attempt;
361
+ if (!account || !attempt)
362
+ throw noAccount(rec.scope);
363
+ await failing(rec, async () => {
219
364
  let request = draft.request;
220
365
  if (adapter.rewriteRequest) {
221
366
  request =
222
- (await adapter.rewriteRequest({ ...account, request })) ?? request;
367
+ (await adapter.rewriteRequest({
368
+ ...account,
369
+ request,
370
+ attempt,
371
+ })) ?? request;
223
372
  }
224
373
  const headers = new Headers(request.headers);
225
374
  applyHeaderEditsTo(headers, rec.headers);
@@ -227,130 +376,174 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
227
376
  const final = new Request(request, { headers });
228
377
  byRequest.set(final, rec);
229
378
  draft.request = final;
230
- }, scoped),
231
- await ctx.session.hook('http.response', async (draft) => {
232
- const rec = byRequest.get(draft.request) ??
233
- records.get(keyOf(draft.sessionID, draft.kind));
234
- const account = rec && accountOf(rec);
235
- if (!rec || !account)
236
- return;
237
- const original = draft.response;
238
- const quota = adapter.quotaFromHeaders?.(original.headers, original.status);
239
- if (quota !== undefined)
240
- noteQuota(rec, 'http', quota, original.status);
241
- if (!original.ok && adapter.limitFromResponse) {
242
- const signal = await adapter.limitFromResponse({
243
- status: original.status,
244
- headers: original.headers,
245
- body: () => original.clone().text(),
246
- });
247
- if (signal)
248
- noteLimit(rec, signal, 'http');
379
+ });
380
+ }, scoped));
381
+ registrations.push(await ctx.session.hook('http.response', async (draft) => {
382
+ const rec = byRequest.get(draft.request);
383
+ if (!rec) {
384
+ if (!warnedUnprovenResponse) {
385
+ warnedUnprovenResponse = true;
386
+ warn('opencode2 auth dropped an http.response whose request it did not produce', { sessionID: draft.sessionID, kind: draft.kind });
249
387
  }
250
- let response = original;
251
- if (original.body && adapter.inspectEvent) {
252
- const watch = watchServerSentEvents((event) => inspect(rec, 'http', event.data, event.event), (error) => warn('opencode2 auth event inspection threw', {
388
+ return;
389
+ }
390
+ const account = accountOf(rec);
391
+ const attempt = rec?.attempt;
392
+ if (!rec || !account || !attempt)
393
+ return;
394
+ const original = draft.response;
395
+ rec.status = original.status;
396
+ const quota = adapter.quotaFromHeaders?.(original.headers, original.status, attempt);
397
+ if (quota !== undefined)
398
+ noteQuota(rec, 'http', quota, original.status);
399
+ if (!original.ok && adapter.limitFromResponse) {
400
+ const signal = await adapter.limitFromResponse({
401
+ status: original.status,
402
+ headers: original.headers,
403
+ body: () => original.clone().text(),
404
+ attempt,
405
+ });
406
+ if (signal)
407
+ noteLimit(rec, signal, 'http');
408
+ }
409
+ // An error response carries no output, so its status is the outcome;
410
+ // ending here keeps the end from depending on whether the host reads
411
+ // the error body to the end.
412
+ if (!original.ok || !original.body)
413
+ finish(rec);
414
+ let response = original;
415
+ if (original.body && (adapter.inspectEvent || adapter.onAttemptEnd)) {
416
+ let body = original.body;
417
+ if (adapter.inspectEvent) {
418
+ body = body.pipeThrough(watchServerSentEvents((event) => inspect(rec, 'http', event.data, event.event), (error) => warn('opencode2 auth event inspection threw', {
253
419
  error: describe(error),
254
- }));
255
- response = new Response(original.body.pipeThrough(watch), {
256
- status: original.status,
257
- statusText: original.statusText,
258
- headers: original.headers,
259
- });
260
- }
261
- if (adapter.rewriteResponse) {
262
- response =
263
- (await adapter.rewriteResponse({
264
- ...account,
265
- request: draft.request,
266
- response,
267
- })) ?? response;
420
+ })));
268
421
  }
269
- if (response !== original)
270
- draft.response = response;
271
- }, scoped),
272
- await ctx.session.hook('experimental.ws.handshake', async (draft) => {
273
- const rec = await ensure(scopeOf(draft), 'experimental.ws.handshake');
274
- const account = accountOf(rec);
275
- if (!account)
276
- throw noAccount(rec.scope);
422
+ response = new Response(trackBody(body, (error) => finish(rec, error)), {
423
+ status: original.status,
424
+ statusText: original.statusText,
425
+ headers: original.headers,
426
+ });
427
+ }
428
+ if (adapter.rewriteResponse) {
429
+ response =
430
+ (await adapter.rewriteResponse({
431
+ ...account,
432
+ request: draft.request,
433
+ response,
434
+ attempt,
435
+ })) ?? response;
436
+ }
437
+ if (response !== original)
438
+ draft.response = response;
439
+ }, scoped));
440
+ registrations.push(await ctx.session.hook('experimental.ws.handshake', async (draft) => {
441
+ const rec = await bind(scopeOf(draft), 'experimental.ws.handshake', 'ws');
442
+ const account = accountOf(rec);
443
+ const attempt = rec.attempt;
444
+ if (!account || !attempt)
445
+ throw noAccount(rec.scope);
446
+ await failing(rec, async () => {
277
447
  applyHeaderEdits(draft.headers, rec.headers);
278
448
  const url = adapter.rewriteHandshakeURL?.({
279
449
  ...account,
280
450
  url: draft.url,
451
+ attempt,
281
452
  });
282
453
  if (url !== undefined)
283
454
  draft.url = url;
284
455
  guard(rec.scope, Object.entries(draft.headers));
285
- }, scoped),
286
- await ctx.session.hook('experimental.ws.receive', (draft) => {
287
- const rec = records.get(keyOf(draft.sessionID, draft.kind));
288
- if (!rec || rec.accountId === undefined)
289
- return;
290
- try {
291
- inspect(rec, 'ws', draft.frame);
292
- }
293
- catch (error) {
294
- warn('opencode2 auth frame inspection threw', {
295
- error: describe(error),
296
- });
297
- }
298
- }, scoped),
299
- await ctx.session.hook('retry', async (draft) => {
300
- const rec = pickForRetry(draft.sessionID);
301
- if (!rec)
302
- return;
303
- const hostDecision = draft.decision;
304
- let decision = hostDecision;
305
- let reason;
306
- if (rec.accountId === undefined) {
307
- decision = { retry: false };
308
- reason = 'no-account';
456
+ });
457
+ }, scoped));
458
+ const rewriteFrame = adapter.rewriteWebSocketFrame?.bind(adapter);
459
+ if (rewriteFrame) {
460
+ registrations.push(await ctx.session.hook('experimental.ws.send', async (draft) => {
461
+ const scope = scopeOf(draft);
462
+ const rec = liveOn(records.get(keyOf(scope.sessionID, scope.kind)), 'ws');
463
+ const frame = await rewriteFrame({
464
+ ...scope,
465
+ attempt: rec?.attempt,
466
+ frame: draft.frame,
467
+ });
468
+ if (frame !== undefined)
469
+ draft.frame = frame;
470
+ }, scoped));
471
+ }
472
+ registrations.push(await ctx.session.hook('experimental.ws.receive', (draft) => {
473
+ const rec = liveOn(records.get(keyOf(draft.sessionID, draft.kind)), 'ws');
474
+ if (!rec)
475
+ return;
476
+ try {
477
+ inspect(rec, 'ws', draft.frame);
478
+ }
479
+ catch (error) {
480
+ warn('opencode2 auth frame inspection threw', {
481
+ error: describe(error),
482
+ });
483
+ }
484
+ }, scoped));
485
+ registrations.push(await ctx.session.hook('retry', async (draft) => {
486
+ const rec = pickForRetry(draft.sessionID);
487
+ if (!rec)
488
+ return;
489
+ const hostDecision = draft.decision;
490
+ let decision = hostDecision;
491
+ let reason;
492
+ const attempt = rec.attempt;
493
+ if (rec.accountId === undefined || !attempt) {
494
+ decision = { retry: false };
495
+ reason = 'no-account';
496
+ }
497
+ else if (rec.outputStarted) {
498
+ // The user has already seen part of this answer; a retry would
499
+ // send it again.
500
+ decision = { retry: false };
501
+ reason = 'output-started';
502
+ }
503
+ else {
504
+ if (!rec.limit) {
505
+ const signal = adapter.limitFromError?.(draft.error, attempt);
506
+ if (signal)
507
+ noteLimit(rec, signal, 'error');
309
508
  }
310
- else if (rec.outputStarted) {
311
- // The user has already seen part of this answer; a retry would
312
- // send it again.
313
- decision = { retry: false };
314
- reason = 'output-started';
509
+ // The next `chooseAccount` should see whatever the plugin learnt
510
+ // from how this attempt ended.
511
+ if (rec.ended)
512
+ await rec.ended;
513
+ if (rec.limit) {
514
+ await rec.limit.delivered;
515
+ rec.rerouteFrom = {
516
+ accountId: rec.accountId,
517
+ limit: rec.limit.signal,
518
+ };
519
+ // No delay: the next attempt goes to another account, and the
520
+ // host would otherwise wait out the refused account's backoff,
521
+ // or not retry at all for errors it deems final.
522
+ decision = { retry: true, delay: 0 };
523
+ reason = 'reroute';
315
524
  }
316
525
  else {
317
- if (!rec.limit) {
318
- const signal = adapter.limitFromError?.(draft.error);
319
- if (signal)
320
- noteLimit(rec, signal, 'error');
321
- }
322
- if (rec.limit) {
323
- await rec.limit.delivered;
324
- rec.rerouteFrom = {
325
- accountId: rec.accountId,
326
- limit: rec.limit.signal,
327
- };
328
- // No delay: the next attempt goes to another account, and the
329
- // host would otherwise wait out the refused account's backoff,
330
- // or not retry at all for errors it deems final.
331
- decision = { retry: true, delay: 0 };
332
- reason = 'reroute';
333
- }
334
- else {
335
- reason = 'host-decides';
336
- }
526
+ reason = 'host-decides';
337
527
  }
338
- draft.decision = decision;
339
- await emit('retry', {
340
- sessionID: draft.sessionID,
341
- ...(rec.accountId === undefined ? {} : { accountId: rec.accountId }),
342
- kind: rec.scope.kind,
343
- attempt: draft.attempt,
344
- reason,
345
- hostDecision,
346
- decision,
347
- });
348
- }, scoped),
349
- ];
528
+ }
529
+ draft.decision = decision;
530
+ await emit('retry', {
531
+ sessionID: draft.sessionID,
532
+ ...(rec.accountId === undefined ? {} : { accountId: rec.accountId }),
533
+ kind: rec.scope.kind,
534
+ attempt: draft.attempt,
535
+ reason,
536
+ hostDecision,
537
+ decision,
538
+ ...(attempt === undefined ? {} : { handle: attempt }),
539
+ });
540
+ }, scoped));
350
541
  const forgetSession = (sessionID) => {
351
542
  for (const [key, rec] of records) {
352
- if (rec.scope.sessionID === sessionID)
353
- records.delete(key);
543
+ if (rec.scope.sessionID !== sessionID)
544
+ continue;
545
+ abandon(rec, 'its session was forgotten');
546
+ records.delete(key);
354
547
  }
355
548
  };
356
549
  const abort = new AbortController();
@@ -398,6 +591,8 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
398
591
  return;
399
592
  disposed = true;
400
593
  abort.abort();
594
+ for (const rec of records.values())
595
+ abandon(rec, 'the installation was disposed');
401
596
  records.clear();
402
597
  listeners.clear();
403
598
  await Promise.all(registrations.map(async (registration) => {