@code-yeongyu/senpi 2026.11.6-2 → 2026.11.9-2

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 (244) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/bundle/chunks/{bun-extension-importer-RCGPQTI2.js → bun-extension-importer-GQ6IKEXD.js} +2 -2
  3. package/dist/bundle/chunks/{chunk-RQ6CHXQ2.js → chunk-3TZQXZKH.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-QN2BHP3M.js → chunk-44BJPS5I.js} +1 -1
  5. package/dist/bundle/chunks/{chunk-WGRKM3N6.js → chunk-45MVD7BU.js} +1 -1
  6. package/dist/bundle/chunks/chunk-4JIBEZZE.js +106 -0
  7. package/dist/bundle/chunks/{chunk-CF5OGBC4.js → chunk-4NYNMAYF.js} +1 -1
  8. package/dist/bundle/chunks/{chunk-X5ZVYNOL.js → chunk-4UR6UFBH.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-LNPAT22L.js → chunk-527Q2UYM.js} +9 -8
  10. package/dist/bundle/chunks/{chunk-HLM46XEB.js → chunk-622VYTRI.js} +1 -1
  11. package/dist/bundle/chunks/{chunk-D5MRJWUK.js → chunk-7CFORZPE.js} +221 -229
  12. package/dist/bundle/chunks/{chunk-GKAYPJ4E.js → chunk-7PROV6BB.js} +1 -1
  13. package/dist/bundle/chunks/{chunk-4E7GDG4H.js → chunk-7VMEE62L.js} +1 -1
  14. package/dist/bundle/chunks/{chunk-C7OKUC7L.js → chunk-K3UHGDUQ.js} +1 -1
  15. package/dist/bundle/chunks/{chunk-7III44PV.js → chunk-LHSWAJJL.js} +1 -1
  16. package/dist/bundle/chunks/{chunk-47PPZKPO.js → chunk-LM6DOWQH.js} +4 -4
  17. package/dist/bundle/chunks/chunk-LPYBKMPT.js +2 -0
  18. package/dist/bundle/chunks/{chunk-2FDW2LQL.js → chunk-M7GF73FO.js} +2 -2
  19. package/dist/bundle/chunks/{chunk-ECW7VBMB.js → chunk-NDSWTAYJ.js} +1 -1
  20. package/dist/bundle/chunks/{chunk-GSKCWXXG.js → chunk-Q6NR6644.js} +1 -1
  21. package/dist/bundle/chunks/{chunk-TIPKAMSS.js → chunk-SGAFIMAU.js} +1 -1
  22. package/dist/bundle/chunks/{chunk-7PSBD5KS.js → chunk-STGOYIXC.js} +1 -1
  23. package/dist/bundle/chunks/{chunk-DSWDJ7HT.js → chunk-WQ4RBPMS.js} +1 -1
  24. package/dist/bundle/chunks/chunk-XZDCMDUH.js +2 -0
  25. package/dist/bundle/chunks/{chunk-GNSMNSL7.js → chunk-Y5L4UFMI.js} +2 -2
  26. package/dist/bundle/chunks/{cli-main-T74YJGIF.js → cli-main-XBEFQW5A.js} +1 -1
  27. package/dist/bundle/chunks/{dispatch-BAK2YZKD.js → dispatch-VDIYSXQN.js} +1 -1
  28. package/dist/bundle/chunks/{hand-entry-AYRUGE2A.js → hand-entry-QFEY3JMQ.js} +1 -1
  29. package/dist/bundle/chunks/{headless-host-ES74ZB6M.js → headless-host-YXUGONR3.js} +1 -1
  30. package/dist/bundle/chunks/{host-command-YRXZE4TV.js → host-command-VHX2ILLM.js} +1 -1
  31. package/dist/bundle/chunks/{host-session-command-N7IN7ZDV.js → host-session-command-P3MRW63Z.js} +1 -1
  32. package/dist/bundle/chunks/interactive-mode-YGL3QHU6.js +2 -0
  33. package/dist/bundle/chunks/main-5SD4DHMC.js +2 -0
  34. package/dist/bundle/chunks/{multi-session-host-XSUJDHFZ.js → multi-session-host-PGRHY3JP.js} +20 -17
  35. package/dist/bundle/chunks/{package-manager-cli-GLUNPQJU.js → package-manager-cli-SADLU7QX.js} +1 -1
  36. package/dist/bundle/chunks/{role-RRFFHZF5.js → role-4BUU46VW.js} +3 -3
  37. package/dist/bundle/chunks/{rotation-stream-BN2YK2MC.js → rotation-stream-TODBIHXJ.js} +1 -1
  38. package/dist/bundle/chunks/rpc-mode-27TC7W6K.js +2 -0
  39. package/dist/bundle/chunks/{run-attached-tui-W236SEOR.js → run-attached-tui-MXCC5IZR.js} +1 -1
  40. package/dist/bundle/chunks/{runtime-session-OBXYK4PI.js → runtime-session-VO3X4OE3.js} +1 -1
  41. package/dist/bundle/chunks/{schedule-command-B2EJTBOE.js → schedule-command-MX56D6UE.js} +1 -1
  42. package/dist/bundle/chunks/{session-control-endpoint-YIMLHGLG.js → session-control-endpoint-IL6ERD4U.js} +1 -1
  43. package/dist/bundle/chunks/{session-picker-E6ZONUJB.js → session-picker-6VRC25MJ.js} +1 -1
  44. package/dist/bundle/chunks/session-worker.js +114 -110
  45. package/dist/bundle/cli.js +2 -2
  46. package/dist/bundle/index.js +1 -1
  47. package/dist/bundle/rpc-entry.js +1 -1
  48. package/dist/bundle/runtime-manifest.json +1 -1
  49. package/dist/core/agent-session-runtime.d.ts +2 -0
  50. package/dist/core/agent-session-runtime.js +9 -2
  51. package/dist/core/agent-session-services.d.ts +2 -0
  52. package/dist/core/agent-session-services.js +1 -0
  53. package/dist/core/agent-session.d.ts +32 -3
  54. package/dist/core/agent-session.js +142 -29
  55. package/dist/core/credential-pool/rotation-slots.d.ts +3 -3
  56. package/dist/core/credential-pool/state-store.d.ts +1 -1
  57. package/dist/core/egress/classify.d.ts +8 -3
  58. package/dist/core/egress/classify.js +177 -158
  59. package/dist/core/egress/console-redactor.d.ts +7 -0
  60. package/dist/core/egress/console-redactor.js +74 -0
  61. package/dist/core/egress/credentials.d.ts +73 -0
  62. package/dist/core/egress/credentials.js +20 -0
  63. package/dist/core/egress/device-custody.d.ts +1 -1
  64. package/dist/core/egress/device-custody.js +18 -15
  65. package/dist/core/egress/device-proxy-call.d.ts +59 -0
  66. package/dist/core/egress/device-proxy-call.js +148 -0
  67. package/dist/core/egress/device-proxy-lost-calls.d.ts +27 -0
  68. package/dist/core/egress/device-proxy-lost-calls.js +28 -0
  69. package/dist/core/egress/device-proxy-ports.d.ts +49 -0
  70. package/dist/core/egress/device-proxy-ports.js +2 -0
  71. package/dist/core/egress/device-proxy-request.d.ts +34 -0
  72. package/dist/core/egress/device-proxy-request.js +69 -0
  73. package/dist/core/egress/device-proxy-waits.d.ts +40 -0
  74. package/dist/core/egress/device-proxy-waits.js +67 -0
  75. package/dist/core/egress/device-proxy.d.ts +3 -0
  76. package/dist/core/egress/device-proxy.js +214 -0
  77. package/dist/core/egress/endpoint.js +2 -1
  78. package/dist/core/egress/entry-verify.d.ts +13 -0
  79. package/dist/core/egress/entry-verify.js +33 -0
  80. package/dist/core/egress/hpke.d.ts +63 -0
  81. package/dist/core/egress/hpke.js +192 -0
  82. package/dist/core/egress/key-policy-template.d.ts +7 -1
  83. package/dist/core/egress/ledger-schema.d.ts +80 -0
  84. package/dist/core/egress/ledger-schema.js +117 -0
  85. package/dist/core/egress/ledger.d.ts +55 -0
  86. package/dist/core/egress/ledger.js +159 -0
  87. package/dist/core/egress/pinned-infra.d.ts +23 -2
  88. package/dist/core/egress/pinned-infra.js +9 -2
  89. package/dist/core/egress/placement-claim.d.ts +69 -0
  90. package/dist/core/egress/placement-claim.js +115 -0
  91. package/dist/core/egress/pool-state.d.ts +18 -0
  92. package/dist/core/egress/pool-state.js +5 -0
  93. package/dist/core/egress/redact.d.ts +10 -0
  94. package/dist/core/egress/redact.js +134 -0
  95. package/dist/core/egress/sealed-format.d.ts +101 -0
  96. package/dist/core/egress/sealed-format.js +174 -0
  97. package/dist/core/egress/sealed-source.d.ts +35 -0
  98. package/dist/core/egress/sealed-source.js +140 -0
  99. package/dist/core/egress/verified-keys.js +1 -1
  100. package/dist/core/egress/wire.d.ts +228 -0
  101. package/dist/core/egress/wire.js +49 -0
  102. package/dist/core/extensions/builtin/ask-user/escape-residue.d.ts +50 -0
  103. package/dist/core/extensions/builtin/ask-user/escape-residue.js +113 -0
  104. package/dist/core/extensions/builtin/ask-user/extension.js +7 -1
  105. package/dist/core/extensions/builtin/ask-user/resume.js +2 -1
  106. package/dist/core/extensions/builtin/ask-user/tool.d.ts +3 -0
  107. package/dist/core/extensions/builtin/ask-user/tool.js +8 -0
  108. package/dist/core/extensions/builtin/goal/index.js +46 -0
  109. package/dist/core/extensions/builtin/goal/stop-pause.d.ts +1 -0
  110. package/dist/core/extensions/builtin/goal/stop-pause.js +4 -2
  111. package/dist/core/extensions/builtin/monitor-state-event.d.ts +26 -0
  112. package/dist/core/extensions/builtin/monitor-state-event.js +2 -0
  113. package/dist/core/extensions/builtin/permission-system/index.js +10 -4
  114. package/dist/core/extensions/builtin/permission-system/preset-registry.d.ts +2 -0
  115. package/dist/core/extensions/builtin/permission-system/preset-registry.js +11 -0
  116. package/dist/core/extensions/builtin/permission-system/settings.d.ts +2 -0
  117. package/dist/core/extensions/builtin/permission-system/settings.js +10 -1
  118. package/dist/core/extensions/builtin/terminal/background-age.d.ts +25 -0
  119. package/dist/core/extensions/builtin/terminal/background-age.js +91 -0
  120. package/dist/core/extensions/builtin/terminal/extension-state.d.ts +1 -1
  121. package/dist/core/extensions/builtin/terminal/extension-state.js +21 -3
  122. package/dist/core/extensions/builtin/terminal/notify.d.ts +6 -0
  123. package/dist/core/extensions/builtin/terminal/notify.js +39 -5
  124. package/dist/core/extensions/builtin/terminal/session-bundle.d.ts +9 -2
  125. package/dist/core/extensions/builtin/terminal/session-bundle.js +36 -1
  126. package/dist/core/extensions/builtin/terminal/settings.d.ts +2 -0
  127. package/dist/core/extensions/builtin/terminal/settings.js +8 -0
  128. package/dist/core/extensions/builtin/terminal/tools/background-auto-reap.d.ts +15 -0
  129. package/dist/core/extensions/builtin/terminal/tools/background-auto-reap.js +87 -0
  130. package/dist/core/extensions/builtin/terminal/tools/bash.d.ts +3 -1
  131. package/dist/core/extensions/builtin/terminal/tools/bash.js +44 -15
  132. package/dist/core/extensions/builtin/terminal/tools/child-placement.d.ts +8 -0
  133. package/dist/core/extensions/builtin/terminal/tools/child-placement.js +43 -6
  134. package/dist/core/extensions/builtin/terminal/tools/context.d.ts +14 -2
  135. package/dist/core/extensions/bun-extension-importer.js +4 -3
  136. package/dist/core/extensions/bun-extension-load-turns.d.ts +4 -0
  137. package/dist/core/extensions/bun-extension-load-turns.js +45 -0
  138. package/dist/core/extensions/bun-extension-registry.d.ts +1 -0
  139. package/dist/core/extensions/bun-extension-registry.js +19 -1
  140. package/dist/core/extensions/loader.js +5 -0
  141. package/dist/core/extensions/session-control-types.d.ts +15 -0
  142. package/dist/core/extensions/session-control-types.js +11 -0
  143. package/dist/core/extensions/session-dialog-api.d.ts +5 -0
  144. package/dist/core/extensions/session-dialog-api.js +13 -0
  145. package/dist/core/extensions/types.d.ts +14 -1
  146. package/dist/core/external-admission.d.ts +16 -2
  147. package/dist/core/external-admission.js +22 -4
  148. package/dist/core/permission-decision.d.ts +22 -0
  149. package/dist/core/permission-decision.js +53 -0
  150. package/dist/core/permission-mode.d.ts +56 -0
  151. package/dist/core/permission-mode.js +133 -0
  152. package/dist/core/retry-fallback/controller-types.d.ts +6 -0
  153. package/dist/core/retry-fallback/controller.d.ts +9 -0
  154. package/dist/core/retry-fallback/controller.js +34 -3
  155. package/dist/core/retry-fallback/unusable.d.ts +25 -1
  156. package/dist/core/retry-fallback/unusable.js +87 -2
  157. package/dist/core/retry-fallback/usage-limit.d.ts +6 -1
  158. package/dist/core/retry-fallback/usage-limit.js +12 -4
  159. package/dist/core/sdk.d.ts +6 -1
  160. package/dist/core/sdk.js +3 -0
  161. package/dist/core/session-log.js +1 -1
  162. package/dist/core/stall-warning.d.ts +41 -0
  163. package/dist/core/stall-warning.js +72 -0
  164. package/dist/core/terminal-settings.d.ts +1 -0
  165. package/dist/core/turn-origin.d.ts +31 -0
  166. package/dist/core/turn-origin.js +64 -0
  167. package/dist/modes/interactive/components/remote-delivery-message.d.ts +2 -1
  168. package/dist/modes/interactive/components/remote-delivery-message.js +13 -5
  169. package/dist/modes/rpc/admit-external-message-command.d.ts +37 -0
  170. package/dist/modes/rpc/admit-external-message-command.js +61 -0
  171. package/dist/modes/rpc/admit-external-message-wire.d.ts +42 -0
  172. package/dist/modes/rpc/admit-external-message-wire.js +55 -0
  173. package/dist/modes/rpc/client-input-handler.d.ts +2 -0
  174. package/dist/modes/rpc/client-input-handler.js +2 -0
  175. package/dist/modes/rpc/connection-handler.js +30 -5
  176. package/dist/modes/rpc/connection-roles.d.ts +6 -0
  177. package/dist/modes/rpc/connection-roles.js +13 -0
  178. package/dist/modes/rpc/delegated-drive-origin.d.ts +9 -0
  179. package/dist/modes/rpc/delegated-drive-origin.js +16 -0
  180. package/dist/modes/rpc/effect-commands.d.ts +7 -1
  181. package/dist/modes/rpc/host-bare-endpoint-registration.d.ts +7 -0
  182. package/dist/modes/rpc/host-bare-endpoint-registration.js +35 -0
  183. package/dist/modes/rpc/host-capabilities.d.ts +2 -0
  184. package/dist/modes/rpc/host-capabilities.js +3 -0
  185. package/dist/modes/rpc/host-handover-parked.d.ts +11 -0
  186. package/dist/modes/rpc/host-handover-parked.js +17 -0
  187. package/dist/modes/rpc/host-handover-reopen.js +18 -6
  188. package/dist/modes/rpc/host-handover-resident-writer.d.ts +16 -0
  189. package/dist/modes/rpc/host-handover-resident-writer.js +57 -0
  190. package/dist/modes/rpc/host-idle-handover.js +1 -0
  191. package/dist/modes/rpc/host-warm.d.ts +16 -1
  192. package/dist/modes/rpc/host-warm.js +33 -6
  193. package/dist/modes/rpc/method-classes-schema-delivery.d.ts +32 -0
  194. package/dist/modes/rpc/method-classes-schema-delivery.js +22 -0
  195. package/dist/modes/rpc/method-classes-schema-session.d.ts +36 -0
  196. package/dist/modes/rpc/method-classes-schema-session.js +3 -0
  197. package/dist/modes/rpc/method-classes-schema.d.ts +36 -0
  198. package/dist/modes/rpc/method-classes.d.ts +2 -0
  199. package/dist/modes/rpc/method-classes.js +4 -0
  200. package/dist/modes/rpc/multi-session-host.js +6 -1
  201. package/dist/modes/rpc/permission-preset-command.d.ts +6 -0
  202. package/dist/modes/rpc/permission-preset-command.js +27 -0
  203. package/dist/modes/rpc/rpc-input-validation.js +4 -3
  204. package/dist/modes/rpc/rpc-mutation-response-types.d.ts +7 -0
  205. package/dist/modes/rpc/rpc-response-types.d.ts +2 -1
  206. package/dist/modes/rpc/rpc-session-command-types.d.ts +6 -1
  207. package/dist/modes/rpc/rpc-session-event-types.d.ts +5 -2
  208. package/dist/modes/rpc/rpc-session-state-types.d.ts +2 -1
  209. package/dist/modes/rpc/rpc-session-state.js +2 -0
  210. package/dist/modes/rpc/session-attachment-commands.js +3 -3
  211. package/dist/modes/rpc/session-event-writer-connections.js +1 -0
  212. package/dist/modes/rpc/session-event-writer.d.ts +1 -1
  213. package/dist/modes/rpc/session-registry-attach.d.ts +0 -9
  214. package/dist/modes/rpc/session-registry-attach.js +16 -3
  215. package/dist/modes/rpc/session-registry-types.d.ts +2 -0
  216. package/dist/modes/rpc/session-registry.d.ts +2 -0
  217. package/dist/modes/rpc/session-registry.js +16 -3
  218. package/dist/modes/rpc/session-router-close.js +43 -6
  219. package/dist/modes/rpc/session-router-context.js +2 -0
  220. package/dist/modes/rpc/session-router-occupancy.js +2 -11
  221. package/dist/modes/rpc/session-router-open-profile.js +4 -0
  222. package/dist/modes/rpc/session-router-open.d.ts +1 -1
  223. package/dist/modes/rpc/session-router-open.js +6 -17
  224. package/dist/modes/rpc/session-router-residency.d.ts +24 -0
  225. package/dist/modes/rpc/session-router-residency.js +90 -0
  226. package/dist/modes/rpc/session-router-routing.js +7 -1
  227. package/dist/modes/rpc/session-status.js +4 -1
  228. package/dist/modes/rpc/session-worker.js +4 -0
  229. package/dist/modes/rpc/tool-child/terminal-jobs.d.ts +1 -0
  230. package/dist/modes/rpc/tool-child/terminal-jobs.js +17 -1
  231. package/dist/modes/rpc/worker-session-registry.js +5 -2
  232. package/docs/engine-socket/fixtures/admission-routes/h-legacy.json +1 -1
  233. package/docs/engine-socket/fixtures/rpc-inbound-samples.json +29 -2
  234. package/docs/extensions.md +12 -0
  235. package/docs/rpc.md +129 -12
  236. package/docs/terminal-tools.md +25 -1
  237. package/native/socket-secret/prebuilds/win32-arm64/senpi_socket_secret.win32-arm64.node +0 -0
  238. package/native/socket-secret/prebuilds/win32-x64/senpi_socket_secret.win32-x64.node +0 -0
  239. package/package.json +6 -6
  240. package/dist/bundle/chunks/chunk-C7HEEBFH.js +0 -2
  241. package/dist/bundle/chunks/chunk-XCMII2VQ.js +0 -98
  242. package/dist/bundle/chunks/interactive-mode-B5SQDLH7.js +0 -2
  243. package/dist/bundle/chunks/main-UQFVFCNY.js +0 -2
  244. package/dist/bundle/chunks/rpc-mode-JYN3E7P7.js +0 -2
@@ -1,4 +1,5 @@
1
1
  import { isSessionControlDeliveryDetails, SESSION_CONTROL_DELIVERY_TYPE, } from "./extensions/session-control-types.js";
2
+ import { markInputTurnOrigin } from "./turn-origin.js";
2
3
  export class ExternalAdmission {
3
4
  constructor(port) {
4
5
  this.turnEpochValue = 0;
@@ -81,7 +82,7 @@ export class ExternalAdmission {
81
82
  ? { can_admit: true, ...base }
82
83
  : { can_admit: false, hold_reason: editor.hold_reason, ...base };
83
84
  }
84
- admit(input) {
85
+ admit(input, stamp = {}) {
85
86
  if (this.closedReason !== undefined)
86
87
  throw new Error(this.closedReason);
87
88
  const turn_epoch = this.turnEpochValue;
@@ -95,7 +96,7 @@ export class ExternalAdmission {
95
96
  if (input.expected_turn_id !== undefined && input.expected_turn_id !== turn_epoch) {
96
97
  return { kind: "turn_conflict", turn_epoch };
97
98
  }
98
- const message = deliveryMessage(input);
99
+ const message = deliveryMessage(input, stamp);
99
100
  if (!this.isBusy() && input.whenIdle !== "queue") {
100
101
  this.pending.set(id, "start");
101
102
  // A start that settles without writing the entry left it queued for later (the runtime's
@@ -188,8 +189,8 @@ export class ExternalAdmission {
188
189
  return false;
189
190
  }
190
191
  }
191
- function deliveryMessage(input) {
192
- return {
192
+ function deliveryMessage(input, stamp) {
193
+ const message = {
193
194
  role: "custom",
194
195
  customType: SESSION_CONTROL_DELIVERY_TYPE,
195
196
  content: input.text,
@@ -200,10 +201,27 @@ function deliveryMessage(input) {
200
201
  deliverAs: input.deliverAs,
201
202
  ...(input.sender === undefined ? {} : { sender: input.sender }),
202
203
  ...(input.display_text === undefined ? {} : { display_text: input.display_text }),
204
+ ...(stamp.remoteOrigin === undefined ? {} : { remote_origin: stamp.remoteOrigin }),
205
+ ...(stamp.chainCount === undefined ? {} : { chain_count: stamp.chainCount }),
203
206
  },
204
207
  timestamp: Date.now(),
205
208
  };
209
+ // The mark rides the message into the run, where the turn that consumes it takes its origin (turn-origin.ts).
210
+ if (stamp.turnMark !== undefined)
211
+ markInputTurnOrigin(message, stamp.turnMark);
212
+ return message;
206
213
  }
214
+ /** Every delivery id the transcript holds an entry for: what answers for the process-lifetime ledger after a restart. */
215
+ export const writtenDeliveryIds = {
216
+ initial: () => new Set(),
217
+ add: (ids, entry) => {
218
+ if (entry.type === "custom_message" &&
219
+ entry.customType === SESSION_CONTROL_DELIVERY_TYPE &&
220
+ isSessionControlDeliveryDetails(entry.details))
221
+ ids.add(entry.details.delivery_id);
222
+ return ids;
223
+ },
224
+ };
207
225
  export function deliveryIdOf(message) {
208
226
  if (message.role !== "custom" || message.customType !== SESSION_CONTROL_DELIVERY_TYPE)
209
227
  return undefined;
@@ -0,0 +1,22 @@
1
+ import type { SessionManager } from "./session-manager.ts";
2
+ type DecisionSessionManager = Pick<SessionManager, "getEntries" | "getHeader" | "getSessionId">;
3
+ export interface DecisionSession {
4
+ readonly sessionManager: DecisionSessionManager;
5
+ readonly namedPreset: () => string | undefined;
6
+ readonly launchFlagNamed: () => boolean;
7
+ readonly recordedPreset: () => string | undefined;
8
+ }
9
+ /**
10
+ * Binds the remote approval-mode rule to one live session, evaluated at EACH gated decision:
11
+ * 1. a preset a client named for the session wins (no override);
12
+ * 2. delegated-origin work asks, or uses the mode a client recorded in the session file when the operator named none.
13
+ * It is decided before any `spawnedBy` inheritance, because `spawnedBy` is a client-supplied claim: naming a
14
+ * parent, real or made up, can never take a remote device's turn off this rule;
15
+ * 3. a child (`spawnedBy`) follows its live parent's mode for the parent's current turn; a child whose parent is not
16
+ * live in this process asks, unless the operator named a preset for it with the launch flag;
17
+ * 4. anything else keeps the host's own default (no override).
18
+ */
19
+ export declare function bindDecisionPreset(session: DecisionSession): void;
20
+ export declare function releaseDecisionPreset(sessionManager: DecisionSessionManager): void;
21
+ export {};
22
+ //# sourceMappingURL=permission-decision.d.ts.map
@@ -0,0 +1,53 @@
1
+ import { parsePermissionPresetName } from "./extensions/builtin/permission-system/cli.js";
2
+ import { registerDecisionPresetOverride } from "./extensions/builtin/permission-system/preset-registry.js";
3
+ import { decidingTurnIsDelegated } from "./turn-origin.js";
4
+ const liveSessions = new Map();
5
+ /**
6
+ * Binds the remote approval-mode rule to one live session, evaluated at EACH gated decision:
7
+ * 1. a preset a client named for the session wins (no override);
8
+ * 2. delegated-origin work asks, or uses the mode a client recorded in the session file when the operator named none.
9
+ * It is decided before any `spawnedBy` inheritance, because `spawnedBy` is a client-supplied claim: naming a
10
+ * parent, real or made up, can never take a remote device's turn off this rule;
11
+ * 3. a child (`spawnedBy`) follows its live parent's mode for the parent's current turn; a child whose parent is not
12
+ * live in this process asks, unless the operator named a preset for it with the launch flag;
13
+ * 4. anything else keeps the host's own default (no override).
14
+ */
15
+ export function bindDecisionPreset(session) {
16
+ liveSessions.set(session.sessionManager.getSessionId(), session);
17
+ registerDecisionPresetOverride(session.sessionManager, () => decisionPreset(session, new Set()));
18
+ }
19
+ export function releaseDecisionPreset(sessionManager) {
20
+ const id = sessionManager.getSessionId();
21
+ if (liveSessions.get(id)?.sessionManager === sessionManager)
22
+ liveSessions.delete(id);
23
+ }
24
+ function decisionPreset(session, visited) {
25
+ if (session.namedPreset() !== undefined)
26
+ return undefined;
27
+ if (decidingTurnIsDelegated(session.sessionManager))
28
+ return delegatedPreset(session);
29
+ const parentId = session.sessionManager.getHeader()?.spawnedBy?.parentSessionId;
30
+ if (parentId === undefined)
31
+ return undefined;
32
+ const parent = liveSessions.get(parentId);
33
+ if (parent === undefined)
34
+ return session.launchFlagNamed() ? undefined : "ask";
35
+ // A `spawnedBy` loop (only a crafted session file can make one) has no parent to follow: it asks.
36
+ if (visited.has(parent) || parent === session)
37
+ return "ask";
38
+ visited.add(session);
39
+ return effectivePresetOf(parent, visited);
40
+ }
41
+ function effectivePresetOf(session, visited) {
42
+ const named = session.namedPreset();
43
+ if (named !== undefined)
44
+ return parsePermissionPresetName(named);
45
+ return decisionPreset(session, visited);
46
+ }
47
+ function delegatedPreset(session) {
48
+ if (session.launchFlagNamed())
49
+ return "ask";
50
+ const recorded = session.recordedPreset();
51
+ return (recorded === undefined ? undefined : parsePermissionPresetName(recorded)) ?? "ask";
52
+ }
53
+ //# sourceMappingURL=permission-decision.js.map
@@ -0,0 +1,56 @@
1
+ import type { PermissionPresetName } from "./extensions/builtin/permission-system/types.ts";
2
+ import type { SessionManager } from "./session-manager.ts";
3
+ import type { SettingsManager } from "./settings-manager.ts";
4
+ /** `client`: a client named a valid preset for this session. `host_default`: the host's own default applies. */
5
+ export type PermissionPresetSource = "client" | "host_default";
6
+ /** `invalid`: the named preset (or the host's settings) cannot be parsed; every gated tool call is refused. */
7
+ export type PermissionPresetStatus = "applied" | "invalid";
8
+ /** The approval mode a session enforces, as `get_state`, the open reply and `set_permission_preset` report it. */
9
+ export interface PermissionMode {
10
+ readonly permissionPreset: string;
11
+ readonly permissionPresetSource: PermissionPresetSource;
12
+ readonly permissionPresetStatus: PermissionPresetStatus;
13
+ /** What the next gated decision uses; `null` while the setup is invalid (every gated call is refused). */
14
+ readonly effectivePermissionPreset: PermissionPresetName | null;
15
+ }
16
+ /** The mode fields `set_permission_preset` and the open reply confirm a requested preset by. */
17
+ export type PermissionModeReply = Pick<PermissionMode, "permissionPreset" | "permissionPresetSource" | "permissionPresetStatus">;
18
+ type ModeSession = Parameters<typeof permissionModeOf>[0] & {
19
+ readonly sessionManager: Pick<SessionManager, "getEntries" | "appendCustomEntry" | "getHeader" | "getSessionId">;
20
+ readonly extensionRunner: {
21
+ emitBusEvent(channel: string, data: unknown): void;
22
+ };
23
+ };
24
+ export declare function markHostDefaultPresetFlag(session: object, profile: {
25
+ readonly permissionPresetSource?: "host_default";
26
+ } | undefined): void;
27
+ export declare function permissionModeOf(session: {
28
+ readonly sessionManager: object;
29
+ readonly settingsManager: SettingsManager;
30
+ readonly extensionRunner: {
31
+ getFlagValues(): ReadonlyMap<string, boolean | string>;
32
+ };
33
+ }): PermissionMode;
34
+ /** The preset a launch profile names as a client's choice; an embedder's host default is not one. */
35
+ export declare function clientNamedPreset(profile: {
36
+ readonly permissionPreset?: string;
37
+ readonly permissionPresetSource?: "host_default";
38
+ } | undefined): string | undefined;
39
+ /** A profile whose preset a client just named: the host-default marker no longer applies. */
40
+ export declare function withClientPreset<P extends {
41
+ readonly permissionPreset?: string;
42
+ readonly permissionPresetSource?: "host_default";
43
+ }>(profile: P, preset: string): Readonly<P>;
44
+ export declare function permissionModeReply(mode: PermissionMode): PermissionModeReply;
45
+ export declare function openReplyPermissionMode(state: Partial<PermissionMode>): Partial<PermissionModeReply>;
46
+ /**
47
+ * Binds the preset a client named to the live session: reported as `client`, persisted to its file, and, for a change
48
+ * to a running session (`announce`), published on the session's `permission_preset_changed` bus channel.
49
+ */
50
+ /** Binds the per-decision approval-mode rule to a session, wherever it was created (runtime, SDK, in-process child). */
51
+ export declare function bindPermissionDecision(session: ModeSession): void;
52
+ export declare function applyClientPermissionMode(session: ModeSession, preset: string | undefined, options: {
53
+ readonly announce: boolean;
54
+ }): void;
55
+ export {};
56
+ //# sourceMappingURL=permission-mode.d.ts.map
@@ -0,0 +1,133 @@
1
+ import { parsePermissionPresetName } from "./extensions/builtin/permission-system/cli.js";
2
+ import { readPermissionPreset } from "./extensions/builtin/permission-system/preset-registry.js";
3
+ import { resolvePermissionPreset } from "./extensions/builtin/permission-system/settings.js";
4
+ import { bindDecisionPreset } from "./permission-decision.js";
5
+ const PERMISSION_PRESET_FLAG = "permission-preset";
6
+ const PERMISSION_PRESET_CHANGED_CHANNEL = "permission_preset_changed";
7
+ const PERMISSION_MODE_ENTRY = "permission_mode";
8
+ const clientPresets = new WeakMap();
9
+ // Sessions whose `permission-preset` flag holds the host's own default (an embedder's host option), not an operator flag.
10
+ const hostDefaultFlagSessions = new WeakSet();
11
+ export function markHostDefaultPresetFlag(session, profile) {
12
+ if (profile?.permissionPresetSource === "host_default")
13
+ hostDefaultFlagSessions.add(session);
14
+ else
15
+ hostDefaultFlagSessions.delete(session);
16
+ }
17
+ function markClientPermissionPreset(session, preset) {
18
+ if (preset === undefined)
19
+ clientPresets.delete(session);
20
+ else
21
+ clientPresets.set(session, preset);
22
+ }
23
+ export function permissionModeOf(session) {
24
+ const named = clientPresets.get(session);
25
+ const namedPreset = named === undefined ? undefined : parsePermissionPresetName(named);
26
+ if (namedPreset !== undefined)
27
+ return clientMode(namedPreset, readPermissionPreset(session.sessionManager));
28
+ const hostFlag = named === undefined ? session.extensionRunner.getFlagValues().get(PERMISSION_PRESET_FLAG) : named;
29
+ const hostPreset = typeof hostFlag === "string" ? parsePermissionPresetName(hostFlag) : undefined;
30
+ if (typeof hostFlag === "string" && hostPreset === undefined)
31
+ return invalidMode(hostFlag);
32
+ let preset;
33
+ try {
34
+ preset = resolvePermissionPreset(session.settingsManager, hostPreset);
35
+ }
36
+ catch {
37
+ return invalidMode(typeof hostFlag === "string" ? hostFlag : "");
38
+ }
39
+ return {
40
+ permissionPreset: preset,
41
+ permissionPresetSource: "host_default",
42
+ permissionPresetStatus: "applied",
43
+ effectivePermissionPreset: readPermissionPreset(session.sessionManager) ?? preset,
44
+ };
45
+ }
46
+ // The live permission system is the authority on what it enforces; the named preset stands in only while no
47
+ // permission system has registered for the session yet (it loads with the session's extensions).
48
+ function clientMode(preset, enforced) {
49
+ return {
50
+ permissionPreset: preset,
51
+ permissionPresetSource: "client",
52
+ permissionPresetStatus: "applied",
53
+ effectivePermissionPreset: enforced ?? preset,
54
+ };
55
+ }
56
+ // An invalid preset never counts as one a client set: rule 1 of the delegated-default design must never select an
57
+ // unusable preset, so the source stays the host's while every gated call is refused (#2617).
58
+ function invalidMode(preset) {
59
+ return {
60
+ permissionPreset: preset,
61
+ permissionPresetSource: "host_default",
62
+ permissionPresetStatus: "invalid",
63
+ effectivePermissionPreset: null,
64
+ };
65
+ }
66
+ /** The preset a launch profile names as a client's choice; an embedder's host default is not one. */
67
+ export function clientNamedPreset(profile) {
68
+ return profile?.permissionPresetSource === "host_default" ? undefined : profile?.permissionPreset;
69
+ }
70
+ /** A profile whose preset a client just named: the host-default marker no longer applies. */
71
+ export function withClientPreset(profile, preset) {
72
+ const { permissionPresetSource: _hostDefault, ...rest } = profile;
73
+ return Object.freeze({ ...rest, permissionPreset: preset });
74
+ }
75
+ export function permissionModeReply(mode) {
76
+ return {
77
+ permissionPreset: mode.permissionPreset,
78
+ permissionPresetSource: mode.permissionPresetSource,
79
+ permissionPresetStatus: mode.permissionPresetStatus,
80
+ };
81
+ }
82
+ function persistedClientPreset(sessionManager) {
83
+ const entries = sessionManager.getEntries();
84
+ for (let index = entries.length - 1; index >= 0; index--) {
85
+ const entry = entries[index];
86
+ if (entry?.type !== "custom" || entry.customType !== PERMISSION_MODE_ENTRY)
87
+ continue;
88
+ const data = entry.data;
89
+ if (typeof data !== "object" || data === null || !("preset" in data))
90
+ return undefined;
91
+ return typeof data.preset === "string" && parsePermissionPresetName(data.preset) ? data.preset : undefined;
92
+ }
93
+ return undefined;
94
+ }
95
+ export function openReplyPermissionMode(state) {
96
+ const { permissionPreset, permissionPresetSource, permissionPresetStatus } = state;
97
+ if (permissionPreset === undefined || permissionPresetSource === undefined || permissionPresetStatus === undefined)
98
+ return {};
99
+ return { permissionPreset, permissionPresetSource, permissionPresetStatus };
100
+ }
101
+ /**
102
+ * Binds the preset a client named to the live session: reported as `client`, persisted to its file, and, for a change
103
+ * to a running session (`announce`), published on the session's `permission_preset_changed` bus channel.
104
+ */
105
+ /** Binds the per-decision approval-mode rule to a session, wherever it was created (runtime, SDK, in-process child). */
106
+ export function bindPermissionDecision(session) {
107
+ bindDecisionPreset({
108
+ sessionManager: session.sessionManager,
109
+ namedPreset: () => clientPresets.get(session),
110
+ launchFlagNamed: () => !hostDefaultFlagSessions.has(session) &&
111
+ typeof session.extensionRunner.getFlagValues().get(PERMISSION_PRESET_FLAG) === "string",
112
+ recordedPreset: () => persistedClientPreset(session.sessionManager),
113
+ });
114
+ }
115
+ export function applyClientPermissionMode(session, preset, options) {
116
+ markClientPermissionPreset(session, preset);
117
+ persistClientPreset(session.sessionManager, preset);
118
+ bindPermissionDecision(session);
119
+ if (options.announce)
120
+ session.extensionRunner.emitBusEvent(PERMISSION_PRESET_CHANGED_CHANNEL, permissionModeOf(session));
121
+ }
122
+ /**
123
+ * Records a valid client-named preset as a `permission_mode` custom entry (bookkeeping only: nothing restores it
124
+ * on a later open). Writes only on a change; an invalid preset is never recorded.
125
+ */
126
+ function persistClientPreset(sessionManager, preset) {
127
+ if (preset === undefined || parsePermissionPresetName(preset) === undefined)
128
+ return;
129
+ if (persistedClientPreset(sessionManager) === preset)
130
+ return;
131
+ sessionManager.appendCustomEntry(PERMISSION_MODE_ENTRY, { preset, source: "client" }, { startupBookkeeping: true });
132
+ }
133
+ //# sourceMappingURL=permission-mode.js.map
@@ -65,6 +65,12 @@ export interface RetryFallbackControllerDeps {
65
65
  from: string;
66
66
  to: string;
67
67
  cause?: FallbackRevertCause;
68
+ } | {
69
+ type: "usage_limit_scope_probe";
70
+ provider: string;
71
+ heldFamily: string;
72
+ probeFamily: string;
73
+ outcome: "served" | "widened";
68
74
  }): void;
69
75
  getCurrentSelector(): {
70
76
  model: Model<Api>;
@@ -19,11 +19,19 @@ export declare class RetryFallbackController {
19
19
  constructor(deps: RetryFallbackControllerDeps);
20
20
  get activeState(): Readonly<ActiveFallbackState> | undefined;
21
21
  get exhaustedChainKey(): string | undefined;
22
+ /** The weekly-limit family hold on `model`, while it lasts (senpi#5596). */
23
+ heldFamily(model: Model<Api>): {
24
+ readonly family: string;
25
+ readonly errorMessage: string;
26
+ } | undefined;
27
+ /** The families a no-family limit was confirmed on before it held the whole provider (senpi#5596). */
28
+ widenedFamiliesOf(provider: string): readonly string[] | undefined;
22
29
  get turnRefusals(): readonly FallbackRungRefusal[];
23
30
  isProviderSpent(provider: string): boolean;
24
31
  accountError(provider: string): string | undefined;
25
32
  accountResetAt(provider: string): number | undefined;
26
33
  releaseGuessedAccountHold(provider: string): boolean;
34
+ releaseAccountHold(provider: string): boolean;
27
35
  resetTurn(): void;
28
36
  clear(): void;
29
37
  private canonicalChains;
@@ -77,6 +85,7 @@ export declare class RetryFallbackController {
77
85
  noteProviderAccountsChanged(provider: string): void;
78
86
  /** A response this session got from `selector` succeeded: the entry serves it again (senpi#3467). */
79
87
  noteServed(selector: string): void;
88
+ private emitScopeProbe;
80
89
  /**
81
90
  * Records a failure that makes the entry unusable for every session - billing
82
91
  * or quota exhaustion, or a transient failure that spent its budget or failed a
@@ -32,6 +32,14 @@ export class RetryFallbackController {
32
32
  get exhaustedChainKey() {
33
33
  return this.lastExhaustedChainKey;
34
34
  }
35
+ /** The weekly-limit family hold on `model`, while it lasts (senpi#5596). */
36
+ heldFamily(model) {
37
+ return this.unusable.heldFamily(model);
38
+ }
39
+ /** The families a no-family limit was confirmed on before it held the whole provider (senpi#5596). */
40
+ widenedFamiliesOf(provider) {
41
+ return this.unusable.widenedFamiliesOf(provider);
42
+ }
35
43
  get turnRefusals() {
36
44
  return [...this.refusals];
37
45
  }
@@ -47,6 +55,9 @@ export class RetryFallbackController {
47
55
  releaseGuessedAccountHold(provider) {
48
56
  return this.unusable.releaseGuessedAccountHold(provider);
49
57
  }
58
+ releaseAccountHold(provider) {
59
+ return this.unusable.releaseAccountHold(provider);
60
+ }
50
61
  resetTurn() {
51
62
  this.triedSelectors.clear();
52
63
  this.refusedProviders.clear();
@@ -209,6 +220,18 @@ export class RetryFallbackController {
209
220
  noteServed(selector) {
210
221
  this.ownFailures.delete(selector);
211
222
  this.probes.accept(selector);
223
+ const slash = selector.indexOf("/");
224
+ if (slash > 0)
225
+ this.emitScopeProbe(this.unusable.noteServed({
226
+ provider: selector.slice(0, slash),
227
+ id: selector.slice(slash + 1).split(":")[0] ?? "",
228
+ }));
229
+ }
230
+ emitScopeProbe(probe) {
231
+ if (probe === undefined)
232
+ return;
233
+ this.deps.logger.info("usage_limit_scope_probe", { ...probe });
234
+ this.deps.emit({ type: "usage_limit_scope_probe", ...probe });
212
235
  }
213
236
  /**
214
237
  * Records a failure that makes the entry unusable for every session - billing
@@ -216,7 +239,7 @@ export class RetryFallbackController {
216
239
  * half-open probe - independent of whether a fallback candidate remains.
217
240
  */
218
241
  noteHealthFailure(model, thinkingLevel, failure) {
219
- this.unusable.note(model, failure);
242
+ this.emitScopeProbe(this.unusable.note(model, failure));
220
243
  if (!this.managedChainKey({ model, thinkingLevel }))
221
244
  return;
222
245
  // A pure network fault is the transport's, not the model's: it never opens the shared circuit (senpi#3306).
@@ -275,9 +298,17 @@ export class RetryFallbackController {
275
298
  // noteHealthFailure) so the final chain entry opens too.
276
299
  if (failureObserved && current && reason === "transient")
277
300
  this.noteHealthFailure(current.model, current.thinkingLevel, failure);
278
- const limit = usageLimitScope(failure.errorMessage);
279
301
  if (failureObserved && current)
280
- this.unusable.note(current.model, failure);
302
+ this.emitScopeProbe(this.unusable.note(current.model, failure));
303
+ // A weekly limit that named no family held only the failing family (senpi#5596): report it as that model's
304
+ // limit, so the switch never claims the account's other models were skipped.
305
+ const scopeOfText = usageLimitScope(failure.errorMessage);
306
+ const limit = scopeOfText === "account" &&
307
+ current &&
308
+ !this.unusable.isProviderSpent(current.model.provider) &&
309
+ this.unusable.heldFamily(current.model)
310
+ ? "model"
311
+ : scopeOfText;
281
312
  // A model-wide park (transient, billing) can rule out the current model's other rungs, so the
282
313
  // walk re-scans after noting it; the first scan logs only when no re-scan follows.
283
314
  const modelWidePark = !networkBlip && failureObserved && (reason === "transient" || reason === "billing");
@@ -1,6 +1,13 @@
1
1
  import type { Api, Model } from "@earendil-works/pi-ai";
2
2
  import type { CircuitFailure } from "./controller-types.ts";
3
3
  import type { SelectorCooldowns } from "./cooldown.ts";
4
+ /** A once-per-provider check of a limit that named no family (senpi#5596). */
5
+ export type UsageLimitScopeProbe = {
6
+ readonly provider: string;
7
+ readonly heldFamily: string;
8
+ readonly probeFamily: string;
9
+ readonly outcome: "served" | "widened";
10
+ };
4
11
  /**
5
12
  * Chain entries this session has seen refuse to serve for a reason retrying cannot
6
13
  * fix within the cooldown: an account-wide usage limit or billing failure (every
@@ -11,8 +18,21 @@ import type { SelectorCooldowns } from "./cooldown.ts";
11
18
  export declare class UnusableEntries {
12
19
  private readonly cooldowns;
13
20
  private readonly accountErrors;
21
+ /** Providers whose limit named no family: the family it hit, awaiting one request on another family. */
22
+ private readonly pendingProbes;
23
+ private readonly widenedFamilies;
24
+ private readonly familyErrors;
14
25
  constructor(cooldowns: SelectorCooldowns);
15
- note(model: Model<Api>, failure: CircuitFailure): void;
26
+ note(model: Model<Api>, failure: CircuitFailure): UsageLimitScopeProbe | undefined;
27
+ /** A response served on `model`: a pending family probe on another family resolves as served. */
28
+ noteServed(model: Pick<Model<Api>, "provider" | "id">): UsageLimitScopeProbe | undefined;
29
+ /** The weekly-limit family hold on `model`, while it lasts (senpi#5596). */
30
+ heldFamily(model: Model<Api>): {
31
+ readonly family: string;
32
+ readonly errorMessage: string;
33
+ } | undefined;
34
+ /** The two families a no-family limit was confirmed on before it widened to the account. */
35
+ widenedFamiliesOf(provider: string): readonly string[] | undefined;
16
36
  isProviderSpent(provider: string): boolean;
17
37
  accountError(provider: string): string | undefined;
18
38
  /** Wall-clock time the provider said its account limit resets; undefined when the hold is a guessed default. */
@@ -22,9 +42,13 @@ export declare class UnusableEntries {
22
42
  * (senpi#5568). A hold with a reset the provider stated is kept. Returns whether a hold was dropped.
23
43
  */
24
44
  releaseGuessedAccountHold(provider: string): boolean;
45
+ /** Drops the provider's account hold, whatever its length; returns whether one was held (senpi#5590). */
46
+ releaseAccountHold(provider: string): boolean;
25
47
  isUnusable(model: Model<Api>): boolean;
26
48
  clear(model: Model<Api>): void;
27
49
  /** The provider's accounts changed (a login, an added or re-authorized account): every hold it earned is stale. */
28
50
  clearProvider(provider: string, models: readonly Model<Api>[]): void;
51
+ /** A family hold and its pending probe go wherever the provider's own holds go (senpi#5596). */
52
+ private clearFamilyHolds;
29
53
  }
30
54
  //# sourceMappingURL=unusable.d.ts.map
@@ -1,6 +1,15 @@
1
1
  import { formatSelector } from "./chains.js";
2
2
  import { isHealthExhaustionFailure } from "./circuit-probes.js";
3
+ import { rateLimitModelFamily } from "../credential-pool/model-scope.js";
4
+ import { isBillingErrorMessage } from "./billing.js";
3
5
  import { usageLimitScope } from "./usage-limit.js";
6
+ // A weekly window binds the account (seven_day) or one family (seven_day_opus/...); the text alone cannot tell.
7
+ const WEEKLY_LIMIT = /\bweekly\b[^.]*\blimit\b/i;
8
+ const MODEL_FAMILY = /(?:^|[^a-z])(opus|sonnet|haiku|fable|mythos)(?:[^a-z]|$)/i;
9
+ /** The model family a model id belongs to, when it names one. */
10
+ function modelFamilyOf(modelId) {
11
+ return MODEL_FAMILY.exec(modelId)?.[1]?.toLowerCase();
12
+ }
4
13
  /**
5
14
  * Chain entries this session has seen refuse to serve for a reason retrying cannot
6
15
  * fix within the cooldown: an account-wide usage limit or billing failure (every
@@ -11,10 +20,36 @@ import { usageLimitScope } from "./usage-limit.js";
11
20
  export class UnusableEntries {
12
21
  constructor(cooldowns) {
13
22
  this.accountErrors = new Map();
23
+ /** Providers whose limit named no family: the family it hit, awaiting one request on another family. */
24
+ this.pendingProbes = new Map();
25
+ this.widenedFamilies = new Map();
26
+ this.familyErrors = new Map();
14
27
  this.cooldowns = cooldowns;
15
28
  }
16
29
  note(model, failure) {
17
30
  const scope = usageLimitScope(failure.errorMessage);
31
+ const family = modelFamilyOf(model.id);
32
+ // A subscription limit that names no family may bind only this family: hold it and let one request on
33
+ // another family decide, instead of marking every model of the account spent (senpi#5596).
34
+ if (scope === "account" &&
35
+ family !== undefined &&
36
+ failure.errorMessage !== undefined &&
37
+ WEEKLY_LIMIT.test(failure.errorMessage) &&
38
+ !isBillingErrorMessage(failure.errorMessage) &&
39
+ rateLimitModelFamily(failure.errorMessage) === undefined) {
40
+ const held = this.pendingProbes.get(model.provider);
41
+ if (held === undefined || held === family) {
42
+ this.pendingProbes.set(model.provider, family);
43
+ this.cooldowns.note(familyKey(model.provider, family), failure);
44
+ this.familyErrors.set(familyKey(model.provider, family), failure.errorMessage);
45
+ return undefined;
46
+ }
47
+ this.pendingProbes.delete(model.provider);
48
+ this.widenedFamilies.set(model.provider, [held, family]);
49
+ this.cooldowns.note(accountKey(model.provider), failure);
50
+ this.accountErrors.set(model.provider, failure.errorMessage);
51
+ return { provider: model.provider, heldFamily: held, probeFamily: family, outcome: "widened" };
52
+ }
18
53
  if (scope === "account") {
19
54
  this.cooldowns.note(accountKey(model.provider), failure);
20
55
  if (failure.errorMessage)
@@ -23,6 +58,29 @@ export class UnusableEntries {
23
58
  else if (scope === "model" || isHealthExhaustionFailure(failure.errorMessage)) {
24
59
  this.cooldowns.note(entryKey(model), failure);
25
60
  }
61
+ return undefined;
62
+ }
63
+ /** A response served on `model`: a pending family probe on another family resolves as served. */
64
+ noteServed(model) {
65
+ const held = this.pendingProbes.get(model.provider);
66
+ const family = modelFamilyOf(model.id);
67
+ if (held === undefined || family === undefined || family === held)
68
+ return undefined;
69
+ this.pendingProbes.delete(model.provider);
70
+ return { provider: model.provider, heldFamily: held, probeFamily: family, outcome: "served" };
71
+ }
72
+ /** The weekly-limit family hold on `model`, while it lasts (senpi#5596). */
73
+ heldFamily(model) {
74
+ const family = modelFamilyOf(model.id);
75
+ if (family === undefined)
76
+ return undefined;
77
+ const key = familyKey(model.provider, family);
78
+ const errorMessage = this.familyErrors.get(key);
79
+ return errorMessage !== undefined && this.cooldowns.isSuppressed(key) ? { family, errorMessage } : undefined;
80
+ }
81
+ /** The two families a no-family limit was confirmed on before it widened to the account. */
82
+ widenedFamiliesOf(provider) {
83
+ return this.isProviderSpent(provider) ? this.widenedFamilies.get(provider) : undefined;
26
84
  }
27
85
  isProviderSpent(provider) {
28
86
  return this.cooldowns.isSuppressed(accountKey(provider));
@@ -39,20 +97,31 @@ export class UnusableEntries {
39
97
  * (senpi#5568). A hold with a reset the provider stated is kept. Returns whether a hold was dropped.
40
98
  */
41
99
  releaseGuessedAccountHold(provider) {
42
- if (!this.isProviderSpent(provider) || this.accountResetAt(provider) !== undefined)
100
+ if (this.accountResetAt(provider) !== undefined)
101
+ return false;
102
+ return this.releaseAccountHold(provider);
103
+ }
104
+ /** Drops the provider's account hold, whatever its length; returns whether one was held (senpi#5590). */
105
+ releaseAccountHold(provider) {
106
+ if (!this.isProviderSpent(provider))
43
107
  return false;
44
108
  this.cooldowns.clear(accountKey(provider));
45
109
  this.accountErrors.delete(provider);
110
+ this.clearFamilyHolds(provider);
46
111
  return true;
47
112
  }
48
113
  isUnusable(model) {
49
- return this.isProviderSpent(model.provider) || this.cooldowns.isSuppressed(entryKey(model));
114
+ const family = modelFamilyOf(model.id);
115
+ return (this.isProviderSpent(model.provider) ||
116
+ this.cooldowns.isSuppressed(entryKey(model)) ||
117
+ (family !== undefined && this.cooldowns.isSuppressed(familyKey(model.provider, family))));
50
118
  }
51
119
  clear(model) {
52
120
  // A deliberate model selection allows the user to probe after reauth/reset.
53
121
  this.cooldowns.clear(entryKey(model));
54
122
  this.cooldowns.clear(accountKey(model.provider));
55
123
  this.accountErrors.delete(model.provider);
124
+ this.clearFamilyHolds(model.provider);
56
125
  }
57
126
  /** The provider's accounts changed (a login, an added or re-authorized account): every hold it earned is stale. */
58
127
  clearProvider(provider, models) {
@@ -62,6 +131,19 @@ export class UnusableEntries {
62
131
  if (model.provider === provider)
63
132
  this.cooldowns.clear(entryKey(model));
64
133
  }
134
+ this.clearFamilyHolds(provider);
135
+ }
136
+ /** A family hold and its pending probe go wherever the provider's own holds go (senpi#5596). */
137
+ clearFamilyHolds(provider) {
138
+ const prefix = familyKey(provider, "");
139
+ for (const key of [...this.familyErrors.keys()]) {
140
+ if (!key.startsWith(prefix))
141
+ continue;
142
+ this.cooldowns.clear(key);
143
+ this.familyErrors.delete(key);
144
+ }
145
+ this.pendingProbes.delete(provider);
146
+ this.widenedFamilies.delete(provider);
65
147
  }
66
148
  }
67
149
  function accountKey(provider) {
@@ -70,6 +152,9 @@ function accountKey(provider) {
70
152
  // than the last surfaced account error. Explicit /model selection permits a probe.
71
153
  return `account:${provider}`;
72
154
  }
155
+ function familyKey(provider, family) {
156
+ return `family:${provider}:${family}`;
157
+ }
73
158
  function entryKey(model) {
74
159
  return `exhausted:${formatSelector(model)}`;
75
160
  }
@@ -10,8 +10,13 @@ import type { SessionEntry } from "../session-manager.ts";
10
10
  */
11
11
  export type UsageLimitScope = "model" | "account";
12
12
  export declare class AccountUsageLimitError extends Error {
13
- constructor(provider: string, raw?: string, resetsAt?: number);
13
+ /**
14
+ * `families`: a no-family weekly limit confirmed on these families before it held the provider (senpi#5596).
15
+ * `accountsOut`: a pooled provider's accounts, each with why it cannot serve (senpi#5590).
16
+ */
17
+ constructor(provider: string, raw?: string, resetsAt?: number, families?: readonly string[], accountsOut?: string);
14
18
  }
19
+ export declare function formatResetTime(epochMs: number): string;
15
20
  export declare function usageLimitScope(failure: string | undefined | Pick<AssistantMessage, "errorMessage" | "errorCause">): UsageLimitScope | undefined;
16
21
  /** Project advice without mutating the diagnostic-bearing engine message. */
17
22
  export declare function describeUsageLimitMessage(message: AssistantMessage): AssistantMessage;