@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
@@ -35,6 +35,17 @@ export interface EventVerdict<Q> {
35
35
  readonly outputStarted?: boolean;
36
36
  readonly quota?: Q;
37
37
  readonly limit?: LimitSignal;
38
+ /**
39
+ * This is the last event of the response (completed or failed); the
40
+ * attempt ends here. On WebSocket this is the only way an attempt ends
41
+ * normally, because the socket stays open between responses.
42
+ */
43
+ readonly done?: boolean;
44
+ /**
45
+ * The response failed with this message. Ends the attempt with
46
+ * `error.reason` `failed`; implies `done`.
47
+ */
48
+ readonly error?: string;
38
49
  }
39
50
  /**
40
51
  * Header changes for one account. A string sets the header (replacing every
@@ -57,6 +68,65 @@ export interface ChooseAccountInput extends RequestScope {
57
68
  export interface AccountRequest extends RequestScope {
58
69
  readonly accountId: string;
59
70
  }
71
+ /**
72
+ * One physical send: created when an account is chosen for a request, and
73
+ * handed back on every hook call and event that belongs to that send, so a
74
+ * plugin can tie a response, a frame, a limit, a retry decision and the end
75
+ * of the send to the credential it chose for it.
76
+ *
77
+ * `A` is the plugin's own per-attempt value, returned by `accountHeaders`
78
+ * next to the headers (a served credential receipt, say); the installer
79
+ * only carries it.
80
+ */
81
+ export interface Attempt<A = unknown> extends AccountRequest {
82
+ /** Unique within one installation. */
83
+ readonly attemptId: string;
84
+ /**
85
+ * The transport that carried the send. `undefined` until `http.request` or
86
+ * `experimental.ws.handshake` has run for it: an account chosen in
87
+ * `model.request` is chosen before the host has picked the transport.
88
+ */
89
+ readonly transport: Transport | undefined;
90
+ /** What `accountHeaders` returned as `attempt`, if anything. */
91
+ readonly data: A | undefined;
92
+ }
93
+ /**
94
+ * Why an attempt ended without completing.
95
+ *
96
+ * - `failed`: the response stream errored, or the adapter's `inspectEvent`
97
+ * reported the response as failed;
98
+ * - `cancelled`: the host cancelled the HTTP response body (the user stopped
99
+ * the turn, say);
100
+ * - `abandoned`: the attempt was still open when a newer attempt of the same
101
+ * session and kind began, its session was forgotten, its record was
102
+ * dropped for space, or the installation was disposed. A WebSocket closed
103
+ * or cancelled mid-response ends this way, since the host has no hook for
104
+ * either.
105
+ */
106
+ export type AttemptEndReason = 'failed' | 'cancelled' | 'abandoned';
107
+ /** How an attempt ended, as `onAttemptEnd` reports it. */
108
+ export interface AttemptOutcome {
109
+ /** The HTTP status of the response, when an HTTP response was seen. */
110
+ readonly status?: number;
111
+ /** Output from this attempt had reached the user. */
112
+ readonly outputStarted: boolean;
113
+ /** The account-level refusal recorded for this attempt, if any. */
114
+ readonly limit?: LimitSignal;
115
+ /** Absent when the response completed, whatever its status. */
116
+ readonly error?: {
117
+ readonly reason: AttemptEndReason;
118
+ readonly message?: string;
119
+ };
120
+ }
121
+ /**
122
+ * `accountHeaders` may return the header edits alone, or the edits together
123
+ * with a per-attempt value that the installer hands back in
124
+ * `Attempt.data`.
125
+ */
126
+ export interface AccountHeadersResult<A = unknown> {
127
+ readonly headers: HeaderEdits;
128
+ readonly attempt?: A;
129
+ }
60
130
  /** A host-supplied error, as the retry hook reports it. */
61
131
  export interface HostError {
62
132
  readonly type: string;
@@ -66,8 +136,9 @@ export interface HostError {
66
136
  /**
67
137
  * Everything provider-specific the installer needs. `Q` is the plugin's own
68
138
  * quota reading type; the installer only carries it to the `quota` event.
139
+ * `A` is the plugin's per-attempt value (see `Attempt`).
69
140
  */
70
- export interface OpenCode2AuthAdapter<Q = unknown> {
141
+ export interface OpenCode2AuthAdapter<Q = unknown, A = unknown> {
71
142
  /** Every hook is scoped to this provider; other providers are untouched. */
72
143
  readonly providerID: string;
73
144
  /**
@@ -81,14 +152,19 @@ export interface OpenCode2AuthAdapter<Q = unknown> {
81
152
  * `model.request`, `http.request` and `experimental.ws.handshake`. The last
82
153
  * two run after the host has applied its own credential, so these headers
83
154
  * win on the wire.
155
+ *
156
+ * Called once per attempt, when the account is chosen. Returning
157
+ * `{headers, attempt}` instead of the bare edits stores `attempt` as the
158
+ * attempt's `data`, handed back on everything that belongs to the attempt.
84
159
  */
85
- accountHeaders(input: AccountRequest): Promise<HeaderEdits> | HeaderEdits;
160
+ accountHeaders(input: AccountRequest): Promise<HeaderEdits | AccountHeadersResult<A>> | HeaderEdits | AccountHeadersResult<A>;
86
161
  /**
87
162
  * Optional request rewrite (URL, body) before the account headers are
88
163
  * applied. Return `undefined` to keep the request.
89
164
  */
90
165
  rewriteRequest?(input: AccountRequest & {
91
166
  readonly request: Request;
167
+ readonly attempt: Attempt<A>;
92
168
  }): Promise<Request | undefined> | Request | undefined;
93
169
  /**
94
170
  * Optional response rewrite (body stream, status). It receives the
@@ -98,13 +174,54 @@ export interface OpenCode2AuthAdapter<Q = unknown> {
98
174
  rewriteResponse?(input: AccountRequest & {
99
175
  readonly request: Request;
100
176
  readonly response: Response;
177
+ readonly attempt: Attempt<A>;
101
178
  }): Promise<Response | undefined> | Response | undefined;
102
179
  /** Optional WebSocket URL rewrite. Return `undefined` to keep the URL. */
103
180
  rewriteHandshakeURL?(input: AccountRequest & {
104
181
  readonly url: string;
182
+ readonly attempt: Attempt<A>;
105
183
  }): string | undefined;
184
+ /**
185
+ * Optional rewrite of each outgoing WebSocket frame of this provider,
186
+ * from `experimental.ws.send`. Return the frame to send, or `undefined` to
187
+ * send it unchanged.
188
+ *
189
+ * The rewrite must be a deterministic function of the frame (stable for
190
+ * the session): the host chains turns with `previous_response_id` by
191
+ * diffing its own request as it was before this hook ran, so the server
192
+ * only stays in step when every frame is rewritten the same way. Never
193
+ * alter, drop or reorder the `input` items the host put in the frame, and
194
+ * never touch `previous_response_id`: the host's next frame assumes the
195
+ * server holds exactly what it sent. Adding items to `input`, or adding or
196
+ * changing settings fields, keeps the host's order and continuation when
197
+ * done the same way for every frame: the added items become part of the
198
+ * server's history and the follow-up turn stays incremental.
199
+ *
200
+ * What inserting `input` items into every frame does not keep is the
201
+ * equivalence of the two ways a history reaches the server (settings
202
+ * fields carry no history, so they are not affected). Incrementally, each
203
+ * frame carries only
204
+ * the new items, so an item the rewrite inserts into every frame lands at
205
+ * every network boundary of the accumulated history. After a reconnect the
206
+ * host replays the whole history in one frame, and the same rewrite
207
+ * inserts that item once. The two server-side histories, and their cache
208
+ * prefixes, then differ. A rewrite that only adds or changes settings
209
+ * fields is unaffected. An adapter that inserts `input` items must show,
210
+ * for its own insertion, that the accumulated incremental history equals
211
+ * the rewritten full replay (including across tool loops), or accept the
212
+ * divergence and name what it costs (a cache miss and a different prompt
213
+ * after every reconnect).
214
+ *
215
+ * It runs for every frame, including one no attempt can be tied to
216
+ * (`attempt` is then `undefined`), so the rewrite never depends on
217
+ * attribution.
218
+ */
219
+ rewriteWebSocketFrame?(input: RequestScope & {
220
+ readonly attempt: Attempt<A> | undefined;
221
+ readonly frame: string;
222
+ }): Promise<string | undefined> | string | undefined;
106
223
  /** Quota carried in HTTP response headers. */
107
- quotaFromHeaders?(headers: Headers, status: number): Q | undefined;
224
+ quotaFromHeaders?(headers: Headers, status: number, attempt: Attempt<A>): Q | undefined;
108
225
  /**
109
226
  * Recognises an account-level refusal from an HTTP response before its
110
227
  * body is streamed. `body()` reads a copy, so the host still gets the body.
@@ -113,21 +230,35 @@ export interface OpenCode2AuthAdapter<Q = unknown> {
113
230
  readonly status: number;
114
231
  readonly headers: Headers;
115
232
  readonly body: () => Promise<string>;
233
+ readonly attempt: Attempt<A>;
116
234
  }): Promise<LimitSignal | undefined> | LimitSignal | undefined;
117
235
  /**
118
236
  * Inspects one server-sent event (`data` payload) or one WebSocket frame.
119
- * Detects output, quota and refusals inside the stream.
237
+ * Detects output, quota, refusals and the end of the response inside the
238
+ * stream.
120
239
  */
121
240
  inspectEvent?(input: {
122
241
  readonly transport: Transport;
123
242
  readonly data: string;
124
243
  readonly event?: string;
244
+ readonly attempt: Attempt<A>;
125
245
  }): EventVerdict<Q> | undefined;
126
246
  /**
127
247
  * Recognises an account-level refusal from the error the host hands the
128
- * retry hook, for refusals no other hook saw.
248
+ * retry hook, for refusals no other hook saw. `attempt` is the attempt
249
+ * the retry hook judged.
250
+ */
251
+ limitFromError?(error: HostError, attempt: Attempt<A>): LimitSignal | undefined;
252
+ /**
253
+ * Called once per attempt when it ends: an HTTP response body finished,
254
+ * errored or was cancelled; an HTTP response with an error status or no
255
+ * body arrived; an event's verdict said `done` or `error`; or the attempt
256
+ * was abandoned (see `AttemptEndReason`). Errors are logged and never
257
+ * reach the host. The retry hook waits for this call to settle before it
258
+ * decides, so a plugin that records a refusal here has it in place when
259
+ * `chooseAccount` runs again.
129
260
  */
130
- limitFromError?(error: HostError): LimitSignal | undefined;
261
+ onAttemptEnd?(attempt: Attempt<A>, outcome: AttemptOutcome): Promise<void> | void;
131
262
  }
132
263
  export interface OpenCode2AuthLogger {
133
264
  warn(message: string, data?: unknown): void;
@@ -150,18 +281,24 @@ export type RetryReason = 'reroute' | 'output-started' | 'no-account' | 'host-de
150
281
  * plugin may want to log as a host change.
151
282
  */
152
283
  export type SelectingHook = 'model.request' | 'http.request' | 'experimental.ws.handshake';
153
- export interface OpenCode2AuthEvents<Q> {
284
+ /**
285
+ * Every event that belongs to one attempt carries it as `handle` (the
286
+ * `retry` event already uses `attempt` for the host's retry count).
287
+ */
288
+ export interface OpenCode2AuthEvents<Q, A = unknown> {
154
289
  /** An account was picked for a model request. */
155
290
  readonly select: AccountRequest & {
156
291
  readonly hook: SelectingHook;
157
292
  readonly previousAccountId?: string;
158
293
  readonly rerouteFrom?: ChooseAccountInput['rerouteFrom'];
294
+ readonly handle: Attempt<A>;
159
295
  };
160
296
  /** A quota reading, attributed through this installer's own record. */
161
297
  readonly quota: AccountRequest & {
162
298
  readonly transport: Transport;
163
299
  readonly status?: number;
164
300
  readonly quota: Q;
301
+ readonly handle: Attempt<A>;
165
302
  };
166
303
  /**
167
304
  * An account-level refusal. The retry hook waits for every listener of
@@ -172,6 +309,7 @@ export interface OpenCode2AuthEvents<Q> {
172
309
  readonly via: Transport | 'error';
173
310
  readonly limit: LimitSignal;
174
311
  readonly outputStarted: boolean;
312
+ readonly handle: Attempt<A>;
175
313
  };
176
314
  /** The retry hook ran for this provider. */
177
315
  readonly retry: {
@@ -182,15 +320,17 @@ export interface OpenCode2AuthEvents<Q> {
182
320
  readonly reason: RetryReason;
183
321
  readonly hostDecision: SessionRetryDecision;
184
322
  readonly decision: SessionRetryDecision;
323
+ /** The attempt the decision was about, when it had an account. */
324
+ readonly handle?: Attempt<A>;
185
325
  };
186
326
  }
187
327
  export type OpenCode2AuthEventName = keyof OpenCode2AuthEvents<unknown>;
188
- export interface OpenCode2AuthInstallation<Q> {
328
+ export interface OpenCode2AuthInstallation<Q, A = unknown> {
189
329
  /**
190
330
  * Listens to an event. Listener errors are logged and never reach the
191
331
  * host. Returns a function that removes the listener.
192
332
  */
193
- on<E extends OpenCode2AuthEventName>(event: E, listener: (payload: OpenCode2AuthEvents<Q>[E]) => void | Promise<void>): () => void;
333
+ on<E extends OpenCode2AuthEventName>(event: E, listener: (payload: OpenCode2AuthEvents<Q, A>[E]) => void | Promise<void>): () => void;
194
334
  /** The account last chosen for a session and request kind. */
195
335
  accountFor(sessionID: string, kind: RequestKind): string | undefined;
196
336
  /** Drops every record of a session. Session deletion does this itself. */
@@ -33,10 +33,17 @@ export interface ProjectedQuota {
33
33
  }
34
34
  /**
35
35
  * Resolves `map` for a request in `scope` (`all` or a model family). A family
36
- * request sees only its own family's keys and the `all` keys. Per label, a
37
- * family reading shadows the `all` entry; a family tombstone or absence
38
- * record says only that no family-specific limit exists, so it does not hide
39
- * an `all` entry for the same label and is used only when there is none.
36
+ * request sees only its own family's keys and the `all` keys.
37
+ *
38
+ * The label decides how a family limit relates to a general one. Under the
39
+ * same label, the family reading replaces the `all` reading: use this only
40
+ * when the provider's family figure is a more specific view of the same cap.
41
+ * Under different labels both limits are projected and every consumer judges
42
+ * both, so a family cap that applies on top of a general cap must be stored
43
+ * under its own label; otherwise the general cap is hidden from a family
44
+ * request. A family tombstone or absence record says only that no
45
+ * family-specific limit exists, so it does not hide an `all` entry for the
46
+ * same label and is used only when there is none.
40
47
  */
41
48
  export declare function projectQuota(map: QuotaMap | undefined, scope?: string): ProjectedQuota;
42
49
  export interface ExhaustionReset {
@@ -61,10 +61,17 @@ function projectBudget(budget) {
61
61
  }
62
62
  /**
63
63
  * Resolves `map` for a request in `scope` (`all` or a model family). A family
64
- * request sees only its own family's keys and the `all` keys. Per label, a
65
- * family reading shadows the `all` entry; a family tombstone or absence
66
- * record says only that no family-specific limit exists, so it does not hide
67
- * an `all` entry for the same label and is used only when there is none.
64
+ * request sees only its own family's keys and the `all` keys.
65
+ *
66
+ * The label decides how a family limit relates to a general one. Under the
67
+ * same label, the family reading replaces the `all` reading: use this only
68
+ * when the provider's family figure is a more specific view of the same cap.
69
+ * Under different labels both limits are projected and every consumer judges
70
+ * both, so a family cap that applies on top of a general cap must be stored
71
+ * under its own label; otherwise the general cap is hidden from a family
72
+ * request. A family tombstone or absence record says only that no
73
+ * family-specific limit exists, so it does not hide an `all` entry for the
74
+ * same label and is used only when there is none.
68
75
  */
69
76
  export function projectQuota(map, scope = ALL_SCOPE) {
70
77
  const byLabel = new Map();
@@ -6,7 +6,9 @@
6
6
  //
7
7
  // A marked or backed-off row is excluded before the gates. Stage 1 then
8
8
  // judges each remaining row alone, in gate order:
9
- // 1. an API-key row is admitted without consulting quota;
9
+ // 1. an API-key row is admitted without consulting quota (a provider that
10
+ // offers paid rows only after its OAuth rows are spent leaves them out
11
+ // of `rows` until then);
10
12
  // 2. an OAuth row whose projection resolves no entry for the scope needs a
11
13
  // first reading: refused, pull requested;
12
14
  // 3. a required label with no entry is unknown: refused, pull requested;
@@ -4,5 +4,5 @@ export type { OrderedAttempt, OrderedPlacement, OrderedRoute, OrderedRouteInput,
4
4
  export { DEFAULT_FORMER_MAIN_ID, nextOrderedAttempt, orderForPlacement, resolveRoutingMode, routeOrdered, } from './ordered.js';
5
5
  export type { StickyPin } from './pins.js';
6
6
  export { isPinValid, pendingBytesForPins } from './pins.js';
7
- export type { PinAction, StickyBreakDecision, StickyRoute, StickyRouteInput, StickySelection, StickySelectionCandidate, StickySelectionInput, } from './sticky.js';
8
- export { decideStickyBreak, MIN_RESET_HOURS, MIN_WEIGHT, QUOTA_STALENESS_MS, routeSticky, STICKY_WINDOW_SLOTS, selectStickyCandidate, snapshotCheckedAt, sustainableWindowWeight, } from './sticky.js';
7
+ export type { PinAction, StickyBreakDecision, StickyRoute, StickyRouteInput, StickySelection, StickySelectionCandidate, StickySelectionInput, StickyStatusClass, StickyStatusClassifier, } from './sticky.js';
8
+ export { decideStickyBreak, defaultStickyStatusClass, MIN_RESET_HOURS, MIN_WEIGHT, QUOTA_STALENESS_MS, routeSticky, selectStickyCandidate, snapshotCheckedAt, sustainableWindowWeight, } from './sticky.js';
@@ -1,4 +1,4 @@
1
1
  export { admit, exclusionFor } from './admission.js';
2
2
  export { DEFAULT_FORMER_MAIN_ID, nextOrderedAttempt, orderForPlacement, resolveRoutingMode, routeOrdered, } from './ordered.js';
3
3
  export { isPinValid, pendingBytesForPins } from './pins.js';
4
- export { decideStickyBreak, MIN_RESET_HOURS, MIN_WEIGHT, QUOTA_STALENESS_MS, routeSticky, STICKY_WINDOW_SLOTS, selectStickyCandidate, snapshotCheckedAt, sustainableWindowWeight, } from './sticky.js';
4
+ export { decideStickyBreak, defaultStickyStatusClass, MIN_RESET_HOURS, MIN_WEIGHT, QUOTA_STALENESS_MS, routeSticky, selectStickyCandidate, snapshotCheckedAt, sustainableWindowWeight, } from './sticky.js';
@@ -4,12 +4,6 @@ import { type StickyPin } from './pins.js';
4
4
  export declare const QUOTA_STALENESS_MS: number;
5
5
  export declare const MIN_RESET_HOURS: number;
6
6
  export declare const MIN_WEIGHT = 0.000001;
7
- /**
8
- * How many window readings the selection primitives judge, as openai-auth's
9
- * primary and secondary slots did. The projection's first readings in its
10
- * order fill the slots; any further window is judged by admission only.
11
- */
12
- export declare const STICKY_WINDOW_SLOTS = 2;
13
7
  /** The projection's time, else the caller's cache-entry time. */
14
8
  export declare function snapshotCheckedAt(quota: ProjectedQuota | null | undefined, entryCheckedAt?: number): number | undefined;
15
9
  export type StickyBreakDecision = {
@@ -22,6 +16,23 @@ export type StickyBreakDecision = {
22
16
  window?: WindowRef;
23
17
  resetsAt?: string;
24
18
  };
19
+ /**
20
+ * How the adapter reads a failed response's HTTP status for the break
21
+ * decision. `permanent` moves the session off its row before any quota is
22
+ * consulted; `transient` and `healthy` keep it unless the quota says the row
23
+ * is spent. A missing status means the request failed without a response.
24
+ */
25
+ export type StickyStatusClass = 'permanent' | 'transient' | 'healthy';
26
+ export type StickyStatusClassifier = (status: number | undefined) => StickyStatusClass;
27
+ /**
28
+ * The status rule used when the adapter supplies none, carried from
29
+ * openai-auth: 401 and 403 are permanent; no response, 0, a non-finite
30
+ * status, 429 and 5xx are transient; anything else leaves the row healthy.
31
+ * A provider whose 403 can mean an organisation or model policy rather than
32
+ * a dead account supplies its own classifier and can fall back to this one
33
+ * for the statuses it does not single out.
34
+ */
35
+ export declare function defaultStickyStatusClass(status: number | undefined): StickyStatusClass;
25
36
  /** Classifies whether a pinned session should leave its row after a failure. */
26
37
  export declare function decideStickyBreak(input: {
27
38
  quota: ProjectedQuota | null | undefined;
@@ -29,6 +40,8 @@ export declare function decideStickyBreak(input: {
29
40
  status?: number;
30
41
  now: number;
31
42
  killswitchPasses?: boolean;
43
+ /** Defaults to `defaultStickyStatusClass`. */
44
+ classifyStatus?: StickyStatusClassifier;
32
45
  }): StickyBreakDecision;
33
46
  export declare function sustainableWindowWeight(window: {
34
47
  remainingPercent: number;
@@ -7,12 +7,6 @@ import { isPinValid } from './pins.js';
7
7
  export const QUOTA_STALENESS_MS = 15 * 60_000;
8
8
  export const MIN_RESET_HOURS = 1 / 60;
9
9
  export const MIN_WEIGHT = 1e-6;
10
- /**
11
- * How many window readings the selection primitives judge, as openai-auth's
12
- * primary and secondary slots did. The projection's first readings in its
13
- * order fill the slots; any further window is judged by admission only.
14
- */
15
- export const STICKY_WINDOW_SLOTS = 2;
16
10
  /** The projection's time, else the caller's cache-entry time. */
17
11
  export function snapshotCheckedAt(quota, entryCheckedAt) {
18
12
  for (const checkedAt of [quota?.checkedAt, entryCheckedAt]) {
@@ -22,10 +16,33 @@ export function snapshotCheckedAt(quota, entryCheckedAt) {
22
16
  }
23
17
  return undefined;
24
18
  }
25
- function slotReadings(quota) {
26
- // Longest known window first, unknown lengths last, as openai-auth sorts.
27
- // Tombstones and absence records carry no capacity figure, so they occupy
28
- // no slot.
19
+ /**
20
+ * The status rule used when the adapter supplies none, carried from
21
+ * openai-auth: 401 and 403 are permanent; no response, 0, a non-finite
22
+ * status, 429 and 5xx are transient; anything else leaves the row healthy.
23
+ * A provider whose 403 can mean an organisation or model policy rather than
24
+ * a dead account supplies its own classifier and can fall back to this one
25
+ * for the statuses it does not single out.
26
+ */
27
+ export function defaultStickyStatusClass(status) {
28
+ if (status === 401 || status === 403)
29
+ return 'permanent';
30
+ if (status === undefined ||
31
+ status === 0 ||
32
+ !Number.isFinite(status) ||
33
+ (status >= 500 && status <= 599) ||
34
+ status === 429) {
35
+ return 'transient';
36
+ }
37
+ return 'healthy';
38
+ }
39
+ function windowReadings(quota) {
40
+ // Every reading is an independent constraint, so all of them are judged:
41
+ // a third window (for example a short window beside two weekly ones) can
42
+ // be the one that is nearly spent. Longest known window first, unknown
43
+ // lengths last, as openai-auth sorts, so a break decision with several
44
+ // spent windows names the longest. Tombstones and absence records carry no
45
+ // capacity figure and are left out.
29
46
  return quota.limits
30
47
  .filter((limit) => limit.kind === 'reading')
31
48
  .sort((left, right) => {
@@ -37,12 +54,12 @@ function slotReadings(quota) {
37
54
  return (right.windowMinutes ?? 0) - (left.windowMinutes ?? 0);
38
55
  }
39
56
  return 0;
40
- })
41
- .slice(0, STICKY_WINDOW_SLOTS);
57
+ });
42
58
  }
43
59
  /** Classifies whether a pinned session should leave its row after a failure. */
44
60
  export function decideStickyBreak(input) {
45
- if (input.status === 401 || input.status === 403) {
61
+ const statusClass = (input.classifyStatus ?? defaultStickyStatusClass)(input.status);
62
+ if (statusClass === 'permanent') {
46
63
  return { action: 'migrate', reason: 'permanent' };
47
64
  }
48
65
  if (!input.quota)
@@ -58,7 +75,7 @@ export function decideStickyBreak(input) {
58
75
  if (input.killswitchPasses === false) {
59
76
  return { action: 'migrate', reason: 'killswitch' };
60
77
  }
61
- for (const limit of slotReadings(input.quota)) {
78
+ for (const limit of windowReadings(input.quota)) {
62
79
  const remaining = limit.remainingPercent;
63
80
  if (typeof remaining === 'number' &&
64
81
  Number.isFinite(remaining) &&
@@ -83,14 +100,7 @@ export function decideStickyBreak(input) {
83
100
  resetsAt: budgetReset.resetsAt,
84
101
  };
85
102
  }
86
- if (input.status === undefined ||
87
- input.status === 0 ||
88
- !Number.isFinite(input.status) ||
89
- (input.status >= 500 && input.status <= 599) ||
90
- input.status === 429) {
91
- return { action: 'retain', reason: 'transient' };
92
- }
93
- return { action: 'retain', reason: 'healthy' };
103
+ return { action: 'retain', reason: statusClass };
94
104
  }
95
105
  export function sustainableWindowWeight(window, reservePercent, now) {
96
106
  const spendable = Math.max(0, window.remainingPercent - reservePercent);
@@ -124,7 +134,8 @@ function candidateWeight(candidate, now) {
124
134
  }
125
135
  // Missing reserve data must leave a window usable rather than silently
126
136
  // excluding its account.
127
- const weights = slotReadings(candidate.quota).map((limit) => sustainableWindowWeight({
137
+ // The account is as constrained as its tightest window, whichever it is.
138
+ const weights = windowReadings(candidate.quota).map((limit) => sustainableWindowWeight({
128
139
  remainingPercent: limit.remainingPercent ?? Number.NaN,
129
140
  ...(limit.resetsAt === undefined ? {} : { resetsAt: limit.resetsAt }),
130
141
  }, candidate.reservePercent[limit.label] ?? 0, now));
@@ -22,7 +22,27 @@ export interface NotificationScope {
22
22
  rpcRoot: string;
23
23
  directoryPrefix: string;
24
24
  registrationSessionId: string;
25
+ /**
26
+ * Strict session isolation. When true, `pushNotification` and
27
+ * `drainNotifications` refuse an absent or empty session id (throwing
28
+ * `RpcSessionRequiredError`) and a drain returns only that session's own
29
+ * notifications: no notification reaches every session. A strict scope is
30
+ * a separate queue from the same scope without the flag, so a lenient
31
+ * caller cannot push into it or drain it.
32
+ *
33
+ * Off by default: without it, a drain with no session returns every
34
+ * session's notifications and a push with no session reaches every
35
+ * session, which plugins that drain from one process-wide TUI rely on.
36
+ */
37
+ requireSession?: boolean;
25
38
  }
39
+ /** A strict notification scope was used without a session id. */
40
+ export declare class RpcSessionRequiredError extends Error {
41
+ readonly operation: 'push' | 'drain';
42
+ constructor(operation: 'push' | 'drain');
43
+ }
44
+ /** True for a session id a strict scope accepts: a non-empty string. */
45
+ export declare function isSessionId(value: unknown): value is string;
26
46
  export declare function pushNotification(scope: NotificationScope, payload: OpenDialogPayload, sessionId?: string): void;
27
47
  export declare function drainNotifications(scope: NotificationScope, lastReceivedId?: number, sessionId?: string): RpcNotification[];
28
48
  export declare function isTuiConnected(scope: NotificationScope, sessionId: string): boolean;
@@ -1,9 +1,23 @@
1
+ /** A strict notification scope was used without a session id. */
2
+ export class RpcSessionRequiredError extends Error {
3
+ operation;
4
+ constructor(operation) {
5
+ super(`a ${operation} on a strict notification scope needs a session id`);
6
+ this.name = 'RpcSessionRequiredError';
7
+ this.operation = operation;
8
+ }
9
+ }
10
+ /** True for a session id a strict scope accepts: a non-empty string. */
11
+ export function isSessionId(value) {
12
+ return typeof value === 'string' && value.length > 0;
13
+ }
1
14
  const queues = new Map();
2
15
  function state(scope) {
3
16
  const key = JSON.stringify([
4
17
  scope.rpcRoot,
5
18
  scope.directoryPrefix,
6
19
  scope.registrationSessionId,
20
+ ...(scope.requireSession === true ? ['strict'] : []),
7
21
  ]);
8
22
  let value = queues.get(key);
9
23
  if (!value) {
@@ -15,6 +29,8 @@ function state(scope) {
15
29
  const QUEUE_CAP = 100;
16
30
  const TUI_CONNECTED_WINDOW_MS = 3_000;
17
31
  export function pushNotification(scope, payload, sessionId) {
32
+ if (scope.requireSession === true && !isSessionId(sessionId))
33
+ throw new RpcSessionRequiredError('push');
18
34
  const value = state(scope);
19
35
  value.queue.push({
20
36
  id: value.nextId++,
@@ -26,6 +42,11 @@ export function pushNotification(scope, payload, sessionId) {
26
42
  value.queue = value.queue.slice(-QUEUE_CAP);
27
43
  }
28
44
  export function drainNotifications(scope, lastReceivedId = 0, sessionId) {
45
+ // A strict queue holds only session-scoped notifications (its push refuses
46
+ // the rest), so the ordinary match below already gives a strict drain
47
+ // nothing but its own session's notifications.
48
+ if (scope.requireSession === true && !isSessionId(sessionId))
49
+ throw new RpcSessionRequiredError('drain');
29
50
  const value = state(scope);
30
51
  const now = Date.now();
31
52
  if (sessionId !== undefined)
@@ -1,5 +1,5 @@
1
1
  import type { RpcLogChannel } from './index.js';
2
- import type { ApplyRequest, ApplyResult, RpcNotification } from './notifications.js';
2
+ import { type ApplyRequest, type ApplyResult, type RpcNotification } from './notifications.js';
3
3
  export interface RpcServerHandle {
4
4
  port: number;
5
5
  token: string;
@@ -13,6 +13,14 @@ export interface RpcServerOptions {
13
13
  sweepRoot?: string;
14
14
  drain: (lastReceivedId: number, sessionId?: string) => RpcNotification[];
15
15
  apply: (request: ApplyRequest) => Promise<ApplyResult>;
16
+ /**
17
+ * Refuse a `pending-notifications` drain whose `sessionId` is absent, not a
18
+ * string or empty, with 400 and without calling `drain`. Pair it with a
19
+ * strict notification scope (`requireSession`) so neither the wire nor
20
+ * the queue can hand one session's notifications to another. Off by
21
+ * default, when a session-less drain is passed to `drain` as undefined.
22
+ */
23
+ requireSession?: boolean;
16
24
  timeoutMs?: number;
17
25
  receiptTimeoutMs?: number;
18
26
  }
@@ -2,6 +2,7 @@ import { randomBytes, timingSafeEqual } from 'node:crypto';
2
2
  import { readFile, unlink } from 'node:fs/promises';
3
3
  import { createServer, } from 'node:http';
4
4
  import { join } from 'node:path';
5
+ import { isSessionId, } from './notifications.js';
5
6
  import { sweepRpcState, writePortFile } from './port-file.js';
6
7
  function readBody(req) {
7
8
  return new Promise((resolve, reject) => {
@@ -64,6 +65,8 @@ export async function startRpcServer(options) {
64
65
  const body = await readBody(req);
65
66
  const params = JSON.parse(body || '{}');
66
67
  if (method === 'pending-notifications') {
68
+ if (options.requireSession === true && !isSessionId(params.sessionId))
69
+ return json(400, { error: 'session required' });
67
70
  const sessionId = typeof params.sessionId === 'string' ? params.sessionId : undefined;
68
71
  if (sessionId === undefined && !warnedMissingNotificationSession) {
69
72
  warnedMissingNotificationSession = true;
@@ -81,9 +84,13 @@ export async function startRpcServer(options) {
81
84
  return json(404, { error: 'unknown method' });
82
85
  }
83
86
  catch (error) {
84
- json(500, {
87
+ // A handler's exception can quote a request or a credential, so its
88
+ // text goes to the plugin's log channel only; the wire gets a fixed code.
89
+ log.warn('rpc request failed', {
90
+ pid: process.pid,
85
91
  error: error instanceof Error ? error.message : String(error),
86
92
  });
93
+ json(500, { error: 'internal error' });
87
94
  }
88
95
  }
89
96
  const port = await new Promise((resolve, reject) => {
@@ -1,2 +1,2 @@
1
- export type { SidebarFile, SidebarFileHooks, SidebarFileOptions, } from './sidebar-file.js';
1
+ export type { SidebarFile, SidebarFileHooks, SidebarFileOptions, SidebarRepair, SidebarWriteOptions, SidebarWriteResult, } from './sidebar-file.js';
2
2
  export { createSidebarFile } from './sidebar-file.js';