@code-yeongyu/senpi 2026.11.10-1 → 2026.11.10-3

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 (209) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/bundle/chunks/{args-77GYOPEO.js → args-HFYB6URH.js} +1 -1
  3. package/dist/bundle/chunks/{azure-openai-responses-YFZ734T3.js → azure-openai-responses-MNN2TINI.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-ZUQCYIRY.js → chunk-2ODGHBUD.js} +1 -1
  5. package/dist/bundle/chunks/{chunk-YPAQXRP3.js → chunk-47T6YMI2.js} +2 -2
  6. package/dist/bundle/chunks/{chunk-I5ASHSLZ.js → chunk-4JMGWVRH.js} +1 -1
  7. package/dist/bundle/chunks/{chunk-MSDBBOB2.js → chunk-5H43O4MA.js} +1 -1
  8. package/dist/bundle/chunks/{chunk-3IYWH6RO.js → chunk-6Q7Y2G5A.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-X4XWAB3J.js → chunk-AC6MDRAE.js} +81 -81
  10. package/dist/bundle/chunks/{chunk-33MZJTQ3.js → chunk-B4WD5XHP.js} +1 -1
  11. package/dist/bundle/chunks/{chunk-V5FSENW6.js → chunk-COKX65TE.js} +1 -1
  12. package/dist/bundle/chunks/{chunk-LHEP3ACI.js → chunk-DEQLHN2J.js} +2 -2
  13. package/dist/bundle/chunks/{chunk-EJB5E2JK.js → chunk-DHAAWL2H.js} +1 -1
  14. package/dist/bundle/chunks/{chunk-2EJ4BGBM.js → chunk-DHORIIVQ.js} +1 -1
  15. package/dist/bundle/chunks/{chunk-GPXZTSEX.js → chunk-DIHIEJFV.js} +3 -3
  16. package/dist/bundle/chunks/{chunk-NH7SYIGN.js → chunk-DLPV4B3R.js} +1 -1
  17. package/dist/bundle/chunks/{chunk-RCHIMH7N.js → chunk-DQY26AX3.js} +1 -1
  18. package/dist/bundle/chunks/{chunk-Y7Z7XPUF.js → chunk-DZQA5LVN.js} +1 -1
  19. package/dist/bundle/chunks/{chunk-2CNSCMYC.js → chunk-E5O4SP5I.js} +1 -1
  20. package/dist/bundle/chunks/{chunk-3Q5ZENSD.js → chunk-E6ZBTSC6.js} +1 -1
  21. package/dist/bundle/chunks/{chunk-HI5JSLG5.js → chunk-H3VJVKUK.js} +4 -4
  22. package/dist/bundle/chunks/{chunk-L6NRDUWJ.js → chunk-HZDKQOWB.js} +1 -1
  23. package/dist/bundle/chunks/chunk-IREUIMFE.js +11 -0
  24. package/dist/bundle/chunks/{chunk-FAFIGBUD.js → chunk-JSD6O3RJ.js} +2 -2
  25. package/dist/bundle/chunks/{chunk-BELNOPAI.js → chunk-L3KVP7VJ.js} +3 -3
  26. package/dist/bundle/chunks/{chunk-7MN7YNO5.js → chunk-L7DGTA3L.js} +1 -1
  27. package/dist/bundle/chunks/{chunk-J2NGJKXL.js → chunk-LXINMDMX.js} +1 -1
  28. package/dist/bundle/chunks/{chunk-H33A33M4.js → chunk-M2VJVJMK.js} +1 -1
  29. package/dist/bundle/chunks/{chunk-ARUV3US4.js → chunk-NN7NESW2.js} +1 -1
  30. package/dist/bundle/chunks/{chunk-2U6HXWXS.js → chunk-NNQ34ZDW.js} +1 -1
  31. package/dist/bundle/chunks/chunk-NQKCJQHB.js +2 -0
  32. package/dist/bundle/chunks/{chunk-TGW4R7SI.js → chunk-O5WJVIEU.js} +1 -1
  33. package/dist/bundle/chunks/{chunk-O3FG6HML.js → chunk-PJI5GT7U.js} +1 -1
  34. package/dist/bundle/chunks/{chunk-BPNSOZ2C.js → chunk-QUVSIW2R.js} +1 -1
  35. package/dist/bundle/chunks/{chunk-WXYMAUFX.js → chunk-QX7WD3IM.js} +1 -1
  36. package/dist/bundle/chunks/{chunk-Y4ONXQMR.js → chunk-RGHVPUUX.js} +1 -1
  37. package/dist/bundle/chunks/{chunk-RYPM5BZQ.js → chunk-S7NAJQB3.js} +1 -1
  38. package/dist/bundle/chunks/{chunk-LT353NSF.js → chunk-T4CUD7RF.js} +1 -1
  39. package/dist/bundle/chunks/{chunk-J7FW7MED.js → chunk-TCZORL3V.js} +1 -1
  40. package/dist/bundle/chunks/{chunk-KU72IKV5.js → chunk-VGSWOLIY.js} +1 -1
  41. package/dist/bundle/chunks/{chunk-DBQBJTMF.js → chunk-VUY4BZDI.js} +1 -1
  42. package/dist/bundle/chunks/chunk-XI6THO4F.js +2 -0
  43. package/dist/bundle/chunks/{chunk-THYEZBUG.js → chunk-Z2C5N2LM.js} +2 -2
  44. package/dist/bundle/chunks/{cli-main-VG3C2EXI.js → cli-main-ZTT3N4DO.js} +1 -1
  45. package/dist/bundle/chunks/{credential-accounts-LVVPKFEY.js → credential-accounts-5KHVGSCY.js} +1 -1
  46. package/dist/bundle/chunks/{dispatch-EP35KKJ3.js → dispatch-YUYZQXRP.js} +1 -1
  47. package/dist/bundle/chunks/{hand-entry-3LQKUTYS.js → hand-entry-UV5XPMBF.js} +3 -3
  48. package/dist/bundle/chunks/{headless-host-HKGNJGYT.js → headless-host-53UMJMM6.js} +1 -1
  49. package/dist/bundle/chunks/{health-write-FBF74QJ7.js → health-write-JLSF5EVV.js} +1 -1
  50. package/dist/bundle/chunks/{help-fast-path-5SCPY3OH.js → help-fast-path-552QLMWT.js} +1 -1
  51. package/dist/bundle/chunks/{host-command-CCASS7W4.js → host-command-BZN7WYYV.js} +1 -1
  52. package/dist/bundle/chunks/{host-lifecycle-WWC7HXQ6.js → host-lifecycle-TNHASSFJ.js} +1 -1
  53. package/dist/bundle/chunks/host-lifecycle.js +1 -1
  54. package/dist/bundle/chunks/{host-session-command-YJB2FLKU.js → host-session-command-O5WYHZXH.js} +1 -1
  55. package/dist/bundle/chunks/{host-socket-request-DMYXC56U.js → host-socket-request-QN4Q2P5E.js} +1 -1
  56. package/dist/bundle/chunks/{interactive-mode-CUN3WMUF.js → interactive-mode-OCU4NDPC.js} +1 -1
  57. package/dist/bundle/chunks/{login-health-EVXVLHB2.js → login-health-TOSQT5RG.js} +1 -1
  58. package/dist/bundle/chunks/{main-P7XRC2OI.js → main-CNDP6HCF.js} +1 -1
  59. package/dist/bundle/chunks/{multi-session-host-244X3P6L.js → multi-session-host-ODGJAGJP.js} +11 -8
  60. package/dist/bundle/chunks/openai-codex-responses-VRGOAFEG.js +10 -0
  61. package/dist/bundle/chunks/{openai-responses-NIVBWE2H.js → openai-responses-I7PXRWCM.js} +2 -2
  62. package/dist/bundle/chunks/{package-manager-cli-UVWJOQA3.js → package-manager-cli-HSPUG4RJ.js} +1 -1
  63. package/dist/bundle/chunks/{role-HQUN4XXK.js → role-AEOLJRTP.js} +1 -1
  64. package/dist/bundle/chunks/{rotation-stream-F3BEMXBE.js → rotation-stream-E3FJFHXJ.js} +1 -1
  65. package/dist/bundle/chunks/rpc-mode-KOZPXU3U.js +2 -0
  66. package/dist/bundle/chunks/{rpc-monitor-source-C6VQ3BC4.js → rpc-monitor-source-PGTDSGWP.js} +1 -1
  67. package/dist/bundle/chunks/{run-attached-tui-R3RB5P7G.js → run-attached-tui-4AENRP7P.js} +1 -1
  68. package/dist/bundle/chunks/{schedule-command-5FG2OKSR.js → schedule-command-SG44FE23.js} +1 -1
  69. package/dist/bundle/chunks/session-control-endpoint-5A24JYPE.js +3 -0
  70. package/dist/bundle/chunks/{session-picker-2FZSNFS7.js → session-picker-73YRS3GE.js} +1 -1
  71. package/dist/bundle/chunks/{session-slot-CZGQQCOC.js → session-slot-RH7JLO3F.js} +1 -1
  72. package/dist/bundle/chunks/session-worker.js +199 -196
  73. package/dist/bundle/chunks/{state-store-S7QAESIF.js → state-store-IECVTYXC.js} +1 -1
  74. package/dist/bundle/cli.js +2 -2
  75. package/dist/bundle/host-supervisor.js +1 -1
  76. package/dist/bundle/index.js +1 -1
  77. package/dist/bundle/rpc-entry.js +1 -1
  78. package/dist/bundle/runtime-manifest.json +1 -1
  79. package/dist/core/agent-session-runtime.d.ts +2 -0
  80. package/dist/core/agent-session-runtime.js +5 -0
  81. package/dist/core/agent-session.d.ts +13 -3
  82. package/dist/core/agent-session.js +89 -18
  83. package/dist/core/assistant-error-redaction.d.ts +9 -0
  84. package/dist/core/assistant-error-redaction.js +27 -0
  85. package/dist/core/config-reference-redaction.js +90 -10
  86. package/dist/core/context-files.d.ts +10 -0
  87. package/dist/core/context-files.js +58 -12
  88. package/dist/core/egress/wire.d.ts +13 -0
  89. package/dist/core/extensions/builtin/config-reload/change-groups.js +6 -2
  90. package/dist/core/extensions/builtin/mcp/auth/catalog-identity.js +32 -8
  91. package/dist/core/extensions/builtin/mcp/auth/oauth-grant.d.ts +43 -0
  92. package/dist/core/extensions/builtin/mcp/auth/oauth-grant.js +196 -0
  93. package/dist/core/extensions/builtin/mcp/auth/oauth-provider.d.ts +4 -17
  94. package/dist/core/extensions/builtin/mcp/auth/oauth-provider.js +64 -75
  95. package/dist/core/extensions/builtin/mcp/auth/oauth-refresh.d.ts +1 -1
  96. package/dist/core/extensions/builtin/mcp/auth/oauth-refresh.js +25 -4
  97. package/dist/core/extensions/builtin/mcp/auth/oauth.js +10 -7
  98. package/dist/core/extensions/builtin/mcp/auth/token-store.d.ts +18 -0
  99. package/dist/core/extensions/builtin/mcp/auth/token-store.js +3 -0
  100. package/dist/core/extensions/builtin/mcp/catalog-cache.js +7 -6
  101. package/dist/core/extensions/builtin/mcp/catalog.js +3 -2
  102. package/dist/core/extensions/builtin/mcp/invocation.js +6 -0
  103. package/dist/core/extensions/builtin/mcp/service-register.js +16 -2
  104. package/dist/core/extensions/builtin/mcp/service-tools-changed.js +61 -54
  105. package/dist/core/extensions/builtin/mcp/startup-race.d.ts +2 -0
  106. package/dist/core/extensions/builtin/mcp/startup-race.js +25 -7
  107. package/dist/core/extensions/builtin/prompt-preset/claude-fable-5-2.js +6 -6
  108. package/dist/core/extensions/loader.js +24 -4
  109. package/dist/core/extensions/runner.d.ts +9 -1
  110. package/dist/core/extensions/runner.js +9 -4
  111. package/dist/core/extensions/types.d.ts +17 -3
  112. package/dist/core/hand/hook-advertise.d.ts +41 -0
  113. package/dist/core/hand/hook-advertise.js +98 -0
  114. package/dist/core/hand/hook-contract.d.ts +66 -0
  115. package/dist/core/hand/hook-contract.js +138 -0
  116. package/dist/core/open-timing.d.ts +2 -0
  117. package/dist/core/open-timing.js +7 -1
  118. package/dist/core/resource-loader.js +2 -0
  119. package/dist/core/retry-fallback/log.d.ts +7 -1
  120. package/dist/core/retry-fallback/log.js +82 -5
  121. package/dist/core/retry-fallback/model-config-error.js +2 -1
  122. package/dist/core/session-manager.d.ts +2 -0
  123. package/dist/core/session-manager.js +15 -1
  124. package/dist/core/settings-manager.d.ts +6 -0
  125. package/dist/core/settings-manager.js +8 -0
  126. package/dist/core/turn-origin.d.ts +19 -13
  127. package/dist/core/turn-origin.js +34 -30
  128. package/dist/core/ultrafast-lanes.d.ts +1 -0
  129. package/dist/core/ultrafast-lanes.js +4 -2
  130. package/dist/modes/interactive/components/footer.d.ts +23 -5
  131. package/dist/modes/interactive/components/footer.js +45 -16
  132. package/dist/modes/interactive/interactive-mode.js +9 -1
  133. package/dist/modes/interactive/session-control-rpc-commands.d.ts +1 -1
  134. package/dist/modes/interactive/session-control-rpc-commands.js +5 -0
  135. package/dist/modes/rpc/admit-external-message-command.d.ts +2 -2
  136. package/dist/modes/rpc/admit-external-message-command.js +2 -10
  137. package/dist/modes/rpc/compact-admission-validation.js +4 -1
  138. package/dist/modes/rpc/connection-handler.d.ts +2 -0
  139. package/dist/modes/rpc/connection-handler.js +20 -8
  140. package/dist/modes/rpc/connection-roles.d.ts +2 -0
  141. package/dist/modes/rpc/connection-roles.js +4 -0
  142. package/dist/modes/rpc/custom-capability.d.ts +4 -0
  143. package/dist/modes/rpc/custom-capability.js +4 -0
  144. package/dist/modes/rpc/delegated-drive-origin.d.ts +6 -6
  145. package/dist/modes/rpc/delegated-drive-origin.js +7 -8
  146. package/dist/modes/rpc/delegation-declaration.d.ts +2 -0
  147. package/dist/modes/rpc/delegation-declaration.js +5 -1
  148. package/dist/modes/rpc/effect-commands.d.ts +21 -0
  149. package/dist/modes/rpc/host-capabilities.d.ts +1 -0
  150. package/dist/modes/rpc/host-capabilities.js +5 -1
  151. package/dist/modes/rpc/host-observe-request.d.ts +2 -0
  152. package/dist/modes/rpc/host-observe-request.js +6 -1
  153. package/dist/modes/rpc/host-park-ledger.d.ts +19 -0
  154. package/dist/modes/rpc/host-park-ledger.js +110 -0
  155. package/dist/modes/rpc/lifecycle-delivery.js +4 -1
  156. package/dist/modes/rpc/loopless-tool-handlers.d.ts +6 -0
  157. package/dist/modes/rpc/loopless-tool-handlers.js +14 -1
  158. package/dist/modes/rpc/media-placeholders.js +1 -0
  159. package/dist/modes/rpc/method-classes-schema-host.d.ts +42 -1
  160. package/dist/modes/rpc/method-classes-schema-host.js +24 -21
  161. package/dist/modes/rpc/method-classes-schema.d.ts +42 -1
  162. package/dist/modes/rpc/method-classes-schema.js +5 -2
  163. package/dist/modes/rpc/method-classes.d.ts +2 -0
  164. package/dist/modes/rpc/method-classes.js +3 -0
  165. package/dist/modes/rpc/multi-session-host.js +1 -1
  166. package/dist/modes/rpc/rpc-host-command-types.d.ts +14 -1
  167. package/dist/modes/rpc/rpc-host-response-types.d.ts +21 -0
  168. package/dist/modes/rpc/session-attachment-gate.js +2 -0
  169. package/dist/modes/rpc/session-binding.d.ts +1 -0
  170. package/dist/modes/rpc/session-binding.js +6 -1
  171. package/dist/modes/rpc/session-connection-context.d.ts +2 -0
  172. package/dist/modes/rpc/session-connection-context.js +4 -0
  173. package/dist/modes/rpc/session-registry-attach.d.ts +2 -0
  174. package/dist/modes/rpc/session-registry-attach.js +15 -11
  175. package/dist/modes/rpc/session-registry-claims.js +2 -0
  176. package/dist/modes/rpc/session-registry-open.js +13 -7
  177. package/dist/modes/rpc/session-registry-opening.js +2 -1
  178. package/dist/modes/rpc/session-registry-types.d.ts +4 -0
  179. package/dist/modes/rpc/session-registry.d.ts +3 -0
  180. package/dist/modes/rpc/session-registry.js +27 -3
  181. package/dist/modes/rpc/session-router-close.js +1 -0
  182. package/dist/modes/rpc/session-router-context.d.ts +4 -2
  183. package/dist/modes/rpc/session-router-context.js +3 -0
  184. package/dist/modes/rpc/session-router-host-reads.js +1 -0
  185. package/dist/modes/rpc/session-router-occupancy.js +10 -1
  186. package/dist/modes/rpc/session-router-open-result.d.ts +12 -0
  187. package/dist/modes/rpc/session-router-open-result.js +63 -0
  188. package/dist/modes/rpc/session-router-open.d.ts +3 -0
  189. package/dist/modes/rpc/session-router-open.js +15 -63
  190. package/dist/modes/rpc/session-router-park.d.ts +15 -0
  191. package/dist/modes/rpc/session-router-park.js +176 -0
  192. package/dist/modes/rpc/session-router-routing.d.ts +1 -1
  193. package/dist/modes/rpc/session-router-routing.js +7 -0
  194. package/dist/modes/rpc/session-status-source.js +2 -2
  195. package/docs/engine-socket/fixtures/admission-routes/h-legacy.json +1 -1
  196. package/docs/engine-socket/fixtures/rpc-inbound-samples.json +22 -2
  197. package/docs/extensions.md +10 -0
  198. package/docs/rpc.md +50 -0
  199. package/docs/settings.md +1 -0
  200. package/docs/usage.md +1 -1
  201. package/native/socket-secret/prebuilds/win32-arm64/senpi_socket_secret.win32-arm64.node +0 -0
  202. package/native/socket-secret/prebuilds/win32-x64/senpi_socket_secret.win32-x64.node +0 -0
  203. package/package.json +6 -6
  204. package/dist/bundle/chunks/chunk-436DNN4Z.js +0 -2
  205. package/dist/bundle/chunks/chunk-DLAY7CIU.js +0 -2
  206. package/dist/bundle/chunks/chunk-XH2MWTXA.js +0 -11
  207. package/dist/bundle/chunks/openai-codex-responses-ABB2J43U.js +0 -10
  208. package/dist/bundle/chunks/rpc-mode-G4I3HHPB.js +0 -2
  209. package/dist/bundle/chunks/session-control-endpoint-XWCFLJYC.js +0 -3
@@ -3,7 +3,8 @@ import { isModernMcpClient } from "./modern/request.js";
3
3
  import { hasSupportedModernToolSchemas } from "./modern/tool-schema.js";
4
4
  import { collectAllPages } from "./expose/pagination.js";
5
5
  export async function collectToolCatalog(server, connection, config, options = {}) {
6
- const result = await collectAllPages((cursor) => listMcpTools(connection.client, cursor === undefined ? {} : { cursor }, { timeout: config.requestTimeoutMs }));
6
+ const client = connection.client;
7
+ const result = await collectAllPages((cursor) => listMcpTools(client, cursor === undefined ? {} : { cursor }, { timeout: config.requestTimeoutMs }));
7
8
  return result.items.map((tool) => ({
8
9
  requestOrigin: options.requestOrigin,
9
10
  annotations: tool.annotations,
@@ -17,7 +18,7 @@ export async function collectToolCatalog(server, connection, config, options = {
17
18
  requestTimeoutMs: config.requestTimeoutMs,
18
19
  schema: tool.inputSchema,
19
20
  outputSchema: tool.outputSchema,
20
- modern: isModernMcpClient(connection.client),
21
+ modern: isModernMcpClient(client),
21
22
  server,
22
23
  tool: tool.name,
23
24
  }));
@@ -61,6 +61,12 @@ export function createMcpInvocationResolver(scope) {
61
61
  try {
62
62
  await Promise.race([ready, interrupted]);
63
63
  }
64
+ catch (error) {
65
+ readySignal.throwIfAborted();
66
+ if (!scope.isCurrent())
67
+ return refuse("unavailable", "MCP session or server configuration was replaced.");
68
+ throw error;
69
+ }
64
70
  finally {
65
71
  clearTimeout(timeout);
66
72
  readySignal.removeEventListener("abort", abort);
@@ -3,7 +3,7 @@ import { computeMcpExposurePolicy } from "./expose/policy.js";
3
3
  import { registerDirectMcpTools } from "./expose/session.js";
4
4
  import { ensureMcpToolCallConnection } from "./health.js";
5
5
  import { createMcpInvocationResolver } from "./invocation.js";
6
- import { connectAndRefreshMcpCatalog } from "./startup-race.js";
6
+ import { connectAndRefreshMcpCatalog, prepareMcpAuthentication } from "./startup-race.js";
7
7
  export async function registerMcpServiceDirectTools(pi, config, entries, toolSearchService, options = {}) {
8
8
  return await registerDirectMcpTools(pi, config, [...entries].map((entry) => {
9
9
  const serverConfig = config.servers[entry.name]?.config;
@@ -18,7 +18,21 @@ export async function registerMcpServiceDirectTools(pi, config, entries, toolSea
18
18
  servesSession: options.servesSession,
19
19
  isCurrent: () => entry.isCurrent?.() !== false && options.isCurrent?.(entry) !== false,
20
20
  ready: async () => {
21
- await ensureMcpToolCallConnection(entry.connection, () => entry.authPlan?.refresh?.ensureFresh().then(() => undefined) ?? Promise.resolve());
21
+ if (!(await prepareMcpAuthentication(entry)))
22
+ return;
23
+ if (entry.isCurrent?.() === false || options.isCurrent?.(entry) === false)
24
+ return;
25
+ if (entry.credentialsCurrent?.() === false) {
26
+ await entry.onCredentialsChanged?.();
27
+ return;
28
+ }
29
+ await ensureMcpToolCallConnection(entry.connection);
30
+ if (entry.isCurrent?.() === false || options.isCurrent?.(entry) === false)
31
+ return;
32
+ if (entry.credentialsCurrent?.() === false) {
33
+ await entry.onCredentialsChanged?.();
34
+ return;
35
+ }
22
36
  await connectAndRefreshMcpCatalog(entry, serverConfig);
23
37
  await entry.startupCatalogClaim?.settled();
24
38
  if (entry.isCurrent?.() === false || options.isCurrent?.(entry) === false)
@@ -54,71 +54,78 @@ export async function refreshMcpToolsOnListChanged(entry, targets, config, conne
54
54
  // The startup connect that still owns registration registers its refreshed catalog itself.
55
55
  if (connectOnly && entry.startupCatalogClaim?.ownsRegistration() === true)
56
56
  return;
57
- const registeredCatalog = entry.cachedCatalog;
58
- if (entry.connection instanceof SharedMcpLease) {
59
- const refreshed = await entry.connection.catalog();
60
- if (!ownsCatalog())
61
- return;
62
- entry.cachedCatalog = refreshed;
63
- }
64
- const catalog = await collectToolCatalog(entry.name, entry.connection, server.config, {
65
- agentDir: entry.agentDir,
66
- outputGuard: config.settings.outputGuard,
67
- });
68
- if (!ownsCatalog())
69
- return;
70
- const newNames = mapMcpCatalogNames(catalog).map(({ name }) => name);
71
- // Before the first refresh, the registered names are the catalog the startup pass registered.
72
- const knownNames = entry.knownToolNames ??
73
- (registeredCatalog === undefined
74
- ? newNames
75
- : mapMcpCatalogNames(cachedToolsToCatalogEntries(entry.name, registeredCatalog, entry.connection, server.config.requestTimeoutMs, async () => { })).map(({ name }) => name));
76
- const diff = diffMcpToolNames(knownNames, newNames);
77
- const identity = mcpRegistrationIdentity(catalog, entry.cachedCatalog);
78
- const initial = targets();
79
- const stale = connectOnly ? initial.filter((target) => target.registeredIdentity() !== identity) : initial;
80
- if (stale.length > 0) {
81
- // Registration reads entry.cachedCatalog. A shared lease refreshed it above; nothing else
82
- // refreshes a non-shared connection's catalog after its startup connect (#2188).
83
- if (!(entry.connection instanceof SharedMcpLease)) {
84
- const refreshed = await collectServerCatalogForCache(entry.connection, server.config, entry.generationHash ?? entry.configHash, entry.credentialIdentity);
57
+ try {
58
+ const registeredCatalog = entry.cachedCatalog;
59
+ if (entry.connection instanceof SharedMcpLease) {
60
+ const refreshed = await entry.connection.catalog();
85
61
  if (!ownsCatalog())
86
62
  return;
87
63
  entry.cachedCatalog = refreshed;
88
- await writeMcpCachedServer(entry.agentDir, entry.name, refreshed, ownsCatalog);
89
64
  }
65
+ const catalog = await collectToolCatalog(entry.name, entry.connection, server.config, {
66
+ agentDir: entry.agentDir,
67
+ outputGuard: config.settings.outputGuard,
68
+ });
90
69
  if (!ownsCatalog())
91
70
  return;
92
- // Tombstone removed tools BEFORE re-registration so the subsequent
93
- // setActiveTools (which excludes them) leaves the tombstones inactive.
94
- const seen = new Set(initial.map((target) => target.pi));
95
- const failures = [];
96
- let pending = stale;
97
- while (pending.length > 0) {
98
- for (const target of pending) {
71
+ const newNames = mapMcpCatalogNames(catalog).map(({ name }) => name);
72
+ // Before the first refresh, the registered names are the catalog the startup pass registered.
73
+ const knownNames = entry.knownToolNames ??
74
+ (registeredCatalog === undefined
75
+ ? newNames
76
+ : mapMcpCatalogNames(cachedToolsToCatalogEntries(entry.name, registeredCatalog, entry.connection, server.config.requestTimeoutMs, async () => { })).map(({ name }) => name));
77
+ const diff = diffMcpToolNames(knownNames, newNames);
78
+ const identity = mcpRegistrationIdentity(catalog, entry.cachedCatalog);
79
+ const initial = targets();
80
+ const stale = connectOnly ? initial.filter((target) => target.registeredIdentity() !== identity) : initial;
81
+ if (stale.length > 0) {
82
+ // Registration reads entry.cachedCatalog. A shared lease refreshed it above; nothing else
83
+ // refreshes a non-shared connection's catalog after its startup connect (#2188).
84
+ if (!(entry.connection instanceof SharedMcpLease)) {
85
+ const refreshed = await collectServerCatalogForCache(entry.connection, server.config, entry.generationHash ?? entry.configHash, entry.credentialIdentity);
99
86
  if (!ownsCatalog())
100
87
  return;
101
- try {
102
- for (const removed of diff.removed)
103
- target.pi.registerTool(buildMcpTombstoneDefinition(removed, entry.name));
104
- await target.register();
105
- }
106
- catch (error) {
107
- failures.push(error);
88
+ entry.cachedCatalog = refreshed;
89
+ await writeMcpCachedServer(entry.agentDir, entry.name, refreshed, ownsCatalog);
90
+ }
91
+ if (!ownsCatalog())
92
+ return;
93
+ // Tombstone removed tools BEFORE re-registration so the subsequent
94
+ // setActiveTools (which excludes them) leaves the tombstones inactive.
95
+ const seen = new Set(initial.map((target) => target.pi));
96
+ const failures = [];
97
+ let pending = stale;
98
+ while (pending.length > 0) {
99
+ for (const target of pending) {
100
+ if (!ownsCatalog())
101
+ return;
102
+ try {
103
+ for (const removed of diff.removed)
104
+ target.pi.registerTool(buildMcpTombstoneDefinition(removed, entry.name));
105
+ await target.register();
106
+ }
107
+ catch (error) {
108
+ failures.push(error);
109
+ }
108
110
  }
111
+ // A session that attached during this refresh registered the catalog cached before it.
112
+ pending = targets().filter((target) => !seen.has(target.pi));
113
+ for (const target of pending)
114
+ seen.add(target.pi);
115
+ }
116
+ if (failures.length > 0) {
117
+ entry.knownToolNames = newNames;
118
+ entry.lastListChangedDelta = formatMcpListChangedDelta(diff);
119
+ throw new AggregateError(failures, `MCP ${entry.name} tool refresh failed in ${failures.length} of ${seen.size} session(s)`);
109
120
  }
110
- // A session that attached during this refresh registered the catalog cached before it.
111
- pending = targets().filter((target) => !seen.has(target.pi));
112
- for (const target of pending)
113
- seen.add(target.pi);
114
- }
115
- if (failures.length > 0) {
116
- entry.knownToolNames = newNames;
117
- entry.lastListChangedDelta = formatMcpListChangedDelta(diff);
118
- throw new AggregateError(failures, `MCP ${entry.name} tool refresh failed in ${failures.length} of ${seen.size} session(s)`);
119
121
  }
122
+ entry.knownToolNames = newNames;
123
+ entry.lastListChangedDelta = formatMcpListChangedDelta(diff);
124
+ }
125
+ catch (error) {
126
+ if (!ownsCatalog())
127
+ return;
128
+ throw error;
120
129
  }
121
- entry.knownToolNames = newNames;
122
- entry.lastListChangedDelta = formatMcpListChangedDelta(diff);
123
130
  }
124
131
  //# sourceMappingURL=service-tools-changed.js.map
@@ -43,6 +43,8 @@ export declare class McpDeferredAttach {
43
43
  }
44
44
  export declare function raceMcpStartupConnect(options: RaceMcpStartupConnectOptions): Promise<void>;
45
45
  export declare function connectAndRefreshMcpCatalog(entry: McpConnectionEntry, serverConfig: ResolvedMcpServer["config"]): Promise<void>;
46
+ /** Reconcile the grant before health recovery without attempting a transport connection. */
47
+ export declare function prepareMcpAuthentication(entry: McpConnectionEntry): Promise<boolean>;
46
48
  export declare function ignoreStartupNeedsAuth(entry: McpConnectionEntry, connect: Promise<void>): Promise<void>;
47
49
  export declare function waitForMcpStartupRace(connect: Promise<void>, deadlineMs?: number): Promise<McpStartupRaceResult>;
48
50
  export declare function shouldRaceMcpStartup(lifecycle: "lazy" | "eager" | "keep-alive"): boolean;
@@ -91,15 +91,16 @@ export function connectAndRefreshMcpCatalog(entry, serverConfig) {
91
91
  void refresh.then(finish, finish);
92
92
  return refresh;
93
93
  }
94
- async function refreshConnectedMcpCatalog(entry, serverConfig) {
95
- if (serverConfig === undefined || entry.isCurrent?.() === false)
96
- return;
94
+ /** Reconcile the grant before health recovery without attempting a transport connection. */
95
+ export async function prepareMcpAuthentication(entry) {
96
+ if (entry.isCurrent?.() === false)
97
+ return false;
97
98
  try {
98
99
  await entry.authPlan?.refresh?.ensureFresh();
99
100
  }
100
101
  catch (error) {
101
- if (entry.connection.state === "disabled")
102
- return;
102
+ if (entry.isCurrent?.() === false || entry.connection.state === "disabled")
103
+ return false;
103
104
  const authError = markMcpConnectionNeedsAuth(entry.connection, error);
104
105
  if (authError !== undefined) {
105
106
  entry.logger.warn(authError.message);
@@ -107,10 +108,17 @@ async function refreshConnectedMcpCatalog(entry, serverConfig) {
107
108
  }
108
109
  throw error;
109
110
  }
111
+ if (entry.isCurrent?.() === false)
112
+ return false;
110
113
  if (entry.credentialsCurrent?.() === false) {
111
114
  await entry.onCredentialsChanged?.();
112
- return;
115
+ return false;
113
116
  }
117
+ return true;
118
+ }
119
+ async function refreshConnectedMcpCatalog(entry, serverConfig) {
120
+ if (serverConfig === undefined || !(await prepareMcpAuthentication(entry)))
121
+ return;
114
122
  await connectMcpServer(entry.connection, entry.logger);
115
123
  if (entry.connection.state !== "connected" || entry.isCurrent?.() === false)
116
124
  return;
@@ -120,7 +128,15 @@ async function refreshConnectedMcpCatalog(entry, serverConfig) {
120
128
  entry.connection.state === "connected" &&
121
129
  entry.connection.generation === generation;
122
130
  if (entry.connection instanceof SharedMcpLease) {
123
- const catalog = await entry.connection.catalog();
131
+ let catalog;
132
+ try {
133
+ catalog = await entry.connection.catalog();
134
+ }
135
+ catch (error) {
136
+ if (!ownsCatalog())
137
+ return;
138
+ throw error;
139
+ }
124
140
  if (!ownsCatalog()) {
125
141
  if (entry.credentialsCurrent?.() === false)
126
142
  await entry.onCredentialsChanged?.();
@@ -150,6 +166,8 @@ async function refreshConnectedMcpCatalog(entry, serverConfig) {
150
166
  await ensureMcpResourceSubscriptions(entry.connection.client, catalog.resources ?? []);
151
167
  }
152
168
  catch (error) {
169
+ if (!ownsCatalog())
170
+ return;
153
171
  entry.logger.warn("Failed to refresh MCP catalog cache", {
154
172
  error: error instanceof Error ? error.message : String(error),
155
173
  });
@@ -16,10 +16,10 @@
16
16
  // retries after a busy tool from nearly all tries to four in ten. "Blind
17
17
  // retries" becomes the exact action it meant, repeating a failure that has not
18
18
  // been diagnosed, so it no longer collides with "wait and retry".
19
- // - Scope, structure ("Say who owns the code"): with no ownership statement 5.2
20
- // repeats existing code instead of reusing it. The user owns the code here, so
21
- // the guide's ownership sentence stays out (it invites unrequested changes);
22
- // the smallest-change rule says to build on the code that already does the job.
19
+ // - Scope, structure ("Say who owns the code"): the guide's ownership sentence
20
+ // opens the structure paragraph and replaces the smallest-change sentence
21
+ // (owner order). Without it, 5.2 acts as a guest: parallel code, duplicates
22
+ // left in place, and nearby bugs only mentioned.
23
23
  // - Working the Task, delegation ("Set expectations for subagents"): the 5.1
24
24
  // sentence was a standing invitation to delegate, which the guide says to
25
25
  // review first. It now states the cost before the invitation, what a brief
@@ -29,7 +29,7 @@
29
29
  // mannered-prose sentence. 5.2 reports run dense, so the rule asks for plain
30
30
  // words, mostly short one-idea sentences, one word per thing, and no names
31
31
  // made up during the work, for a reader switching in from something else.
32
- // Deliberately absent: the ownership sentence; "answer directly" or think-less
32
+ // Deliberately absent: "answer directly" or think-less
33
33
  // lines (the guide: no effect at medium effort and above, lower the effort
34
34
  // instead); a token budget; the "treat that answer as done" lines; hard
35
35
  // sentence-length limits; any instruction to write reasoning in the reply (a
@@ -77,7 +77,7 @@ The request sets the scope, and the scope is the deliverable: deliver all of it
77
77
 
78
78
  Errors, timeouts, locked files, empty results, and failing tools are ordinary obstacles, not blockers: diagnose them, then work through them with the access you have (wait and retry a call that is safe to repeat, fix the request, use another tool or source) without asking first. A file marked not to be touched, withheld access, or a safety guardrail is a deliberate blocker: leave it alone and look for another safe way to finish. If part of the task stays blocked, finish every other part and say exactly what you left out and why.
79
79
 
80
- Smallest correct change wins: build on the code that already does the job instead of adding a parallel copy, with no refactors beside a focused fix, no helpers or abstractions for hypothetical needs, and no defensive checks inside trusted code; validate only at system boundaries. A pre-existing bug or performance concern you notice is a follow-up for your summary, not a change in this diff. Scratch checks verify and get discarded; commit tests only where the task asks for them or the repository already keeps tests for that kind of change, sized like the neighboring test files. Prefer a surgical edit over rewriting a file when the result would be identical.
80
+ This codebase is yours: what it's for and how it's built are your call, and you'll keep building on it. Add no helpers or abstractions for hypothetical needs and no defensive checks inside trusted code; validate only at system boundaries. A pre-existing bug or performance concern you notice is a follow-up for your summary, not a change in this diff. Scratch checks verify and get discarded; commit tests only where the task asks for them or the repository already keeps tests for that kind of change, sized like the neighboring test files. Prefer a surgical edit over rewriting a file when the result would be identical.
81
81
 
82
82
  ## Working the Task
83
83
 
@@ -68,6 +68,19 @@ function assertExtensionSide(extension, runs, registration) {
68
68
  return;
69
69
  throw new Error(`Extension "${extension.path}" declared runs: ${JSON.stringify(runs)} for ${registration}; runs must be "brain" or "hand".`);
70
70
  }
71
+ /** The events whose handlers may declare `{ providers }` (CK21). */
72
+ const PROVIDER_HOOK_EVENTS = [
73
+ "before_provider_request",
74
+ "provider_stream_event",
75
+ "after_provider_response",
76
+ ];
77
+ function declaredProviders(extension, providers, registration) {
78
+ if (Array.isArray(providers) &&
79
+ providers.length > 0 &&
80
+ providers.every((provider) => typeof provider === "string" && provider !== ""))
81
+ return Object.freeze([...providers]);
82
+ throw new Error(`Extension "${extension.path}" declared providers: ${JSON.stringify(providers)} for ${registration}; providers must be a non-empty array of provider ids.`);
83
+ }
71
84
  /** Modules available to extensions via virtualModules (for compiled binaries) */
72
85
  const VIRTUAL_MODULES = {
73
86
  typebox: _bundledTypebox,
@@ -355,11 +368,14 @@ function createExtensionAPI(extension, runtime, cwd, eventBus, session) {
355
368
  on(event, handler, options) {
356
369
  assertActive();
357
370
  const runs = options?.runs;
358
- if (runs !== undefined) {
371
+ if (runs !== undefined)
359
372
  assertExtensionSide(extension, runs, `"${event}"`);
360
- if (session.side !== undefined && runs !== session.side)
361
- return () => { };
362
- }
373
+ // Checked on every side, also for a handler this side does not register: a malformed declaration always throws.
374
+ const providers = PROVIDER_HOOK_EVENTS.includes(event) && options?.providers !== undefined
375
+ ? declaredProviders(extension, options.providers, `"${event}"`)
376
+ : undefined;
377
+ if (runs !== undefined && session.side !== undefined && runs !== session.side)
378
+ return () => { };
363
379
  const registeredHandler = ownedBy(extension, (...args) => {
364
380
  // A child event can run inside its parent's task-tool async scope.
365
381
  // Bind the host context here, preserving registered-handler metadata.
@@ -376,6 +392,10 @@ function createExtensionAPI(extension, runtime, cwd, eventBus, session) {
376
392
  extension.handlerSides ??= new WeakMap();
377
393
  extension.handlerSides.set(registeredHandler, runs);
378
394
  }
395
+ if (providers !== undefined) {
396
+ extension.handlerProviders ??= new WeakMap();
397
+ extension.handlerProviders.set(registeredHandler, providers);
398
+ }
379
399
  if (event === "before_agent_start" && options?.previewSafe === true) {
380
400
  extension.previewSafeHandlers ??= new WeakSet();
381
401
  extension.previewSafeHandlers.add(registeredHandler);
@@ -129,6 +129,14 @@ export type ShutdownHandler = () => void;
129
129
  export declare function emitSessionShutdownEvent(extensionRunner: ExtensionRunner, event: SessionShutdownEvent, options?: {
130
130
  readonly processExit?: boolean;
131
131
  }): Promise<boolean>;
132
+ /**
133
+ * A hand's dispatch of a hosted loop's request hooks (CK21): only the extensions `only` accepts, with the effective
134
+ * model the loop sent and no headers (they never leave the loop's side).
135
+ */
136
+ export interface HandRequestHookDispatch {
137
+ readonly only: (extensionPath: string) => boolean;
138
+ readonly model?: Model<Api>;
139
+ }
132
140
  export declare function emitProjectTrustEvent(extensionsResult: LoadExtensionsResult, event: ProjectTrustEvent, ctx: ProjectTrustContext): Promise<{
133
141
  result?: ProjectTrustEventResult;
134
142
  errors: ExtensionError[];
@@ -422,7 +430,7 @@ export declare class ExtensionRunner {
422
430
  emitBeforeProviderRequest(payload: unknown, excludeExtensionPath?: string, request?: {
423
431
  model: Model<Api>;
424
432
  headers: ProviderHeaders;
425
- }): Promise<unknown>;
433
+ }, hand?: HandRequestHookDispatch): Promise<unknown>;
426
434
  emitBeforeProviderHeaders(headers: ProviderHeaders): Promise<ProviderHeaders>;
427
435
  /** Use the loader's provenance, including bundled modules and generated global-default shims. */
428
436
  getThirdPartySessionStartPaths(): string[];
@@ -2111,11 +2111,15 @@ export class ExtensionRunner {
2111
2111
  transformHeaders: async (headers) => await this.emitBeforeProviderHeaders(headers),
2112
2112
  };
2113
2113
  }
2114
- async emitBeforeProviderRequest(payload, excludeExtensionPath, request) {
2114
+ async emitBeforeProviderRequest(payload, excludeExtensionPath, request, hand) {
2115
2115
  let currentPayload = payload;
2116
- const requestHandlers = this.dropsLoopEvent("before_provider_request")
2117
- ? []
2118
- : snapshotEventHandlers(this.extensions, "before_provider_request");
2116
+ // A hand running a hosted loop's request hooks (CK21) names whose handlers run; it has no loop of its own, so the
2117
+ // sessionless drop does not apply to that explicit dispatch. Every other caller keeps the dispatch it had.
2118
+ const requestHandlers = hand !== undefined
2119
+ ? snapshotEventHandlers(this.extensions, "before_provider_request").filter(({ ext }) => hand.only(ext.path))
2120
+ : this.dropsLoopEvent("before_provider_request")
2121
+ ? []
2122
+ : snapshotEventHandlers(this.extensions, "before_provider_request");
2119
2123
  for (const { ext, handlers } of requestHandlers) {
2120
2124
  if (ext.path === excludeExtensionPath)
2121
2125
  continue;
@@ -2125,6 +2129,7 @@ export class ExtensionRunner {
2125
2129
  type: "before_provider_request",
2126
2130
  payload: currentPayload,
2127
2131
  ...(request ? { model: request.model, headers: request.headers } : {}),
2132
+ ...(hand?.model === undefined ? {} : { model: hand.model }),
2128
2133
  };
2129
2134
  const handlerResult = await handler(event, this.createContext(ext.path));
2130
2135
  if (handlerResult !== undefined) {
@@ -1473,6 +1473,18 @@ export interface HandlerRegistrationOptions {
1473
1473
  */
1474
1474
  runs?: ExtensionSide;
1475
1475
  }
1476
+ /**
1477
+ * Registration options for the provider request and stream hooks (`before_provider_request`, `provider_stream_event`,
1478
+ * `after_provider_response`).
1479
+ */
1480
+ export interface ProviderHookOptions extends HandlerRegistrationOptions {
1481
+ /**
1482
+ * The providers whose requests or streams this handler reads or changes; omitted, every provider. A hand advertises
1483
+ * these to a hosted brain so it knows which calls need the device's hooks (hosted model-key custody CK21). The
1484
+ * declaration never filters dispatch: the handler still receives every event wherever it runs.
1485
+ */
1486
+ providers?: readonly ProviderId[];
1487
+ }
1476
1488
  /** Registration options for `pi.on("before_agent_start", handler, options)`. */
1477
1489
  export interface BeforeAgentStartHandlerOptions extends HandlerRegistrationOptions {
1478
1490
  /**
@@ -2157,10 +2169,10 @@ export interface ExtensionAPI {
2157
2169
  on(event: "session_tree", handler: ExtensionHandler<SessionTreeEvent>, options?: HandlerRegistrationOptions): () => void;
2158
2170
  on(event: "context", handler: ExtensionHandler<ContextEvent, ContextEventResult>, options?: ContextHandlerOptions): () => void;
2159
2171
  on(event: "context_with_system", handler: ExtensionHandler<ContextWithSystemEvent, ContextEventResult>, options?: ContextHandlerOptions): () => void;
2160
- on(event: "before_provider_request", handler: ExtensionHandler<BeforeProviderRequestEvent, BeforeProviderRequestEventResult>, options?: HandlerRegistrationOptions): () => void;
2172
+ on(event: "before_provider_request", handler: ExtensionHandler<BeforeProviderRequestEvent, BeforeProviderRequestEventResult>, options?: ProviderHookOptions): () => void;
2161
2173
  on(event: "before_provider_headers", handler: ExtensionHandler<BeforeProviderHeadersEvent>, options?: HandlerRegistrationOptions): () => void;
2162
- on(event: "after_provider_response", handler: ExtensionHandler<AfterProviderResponseEvent>, options?: HandlerRegistrationOptions): () => void;
2163
- on(event: "provider_stream_event", handler: ExtensionHandler<ProviderStreamEvent>, options?: HandlerRegistrationOptions): () => void;
2174
+ on(event: "after_provider_response", handler: ExtensionHandler<AfterProviderResponseEvent>, options?: ProviderHookOptions): () => void;
2175
+ on(event: "provider_stream_event", handler: ExtensionHandler<ProviderStreamEvent>, options?: ProviderHookOptions): () => void;
2164
2176
  on(event: "before_agent_start", handler: ExtensionHandler<BeforeAgentStartEvent, BeforeAgentStartEventResult>, options?: BeforeAgentStartHandlerOptions): () => void;
2165
2177
  on(event: "agent_start", handler: ExtensionHandler<AgentStartEvent>, options?: HandlerRegistrationOptions): () => void;
2166
2178
  on(event: "agent_end", handler: ExtensionHandler<AgentEndEvent>, options?: HandlerRegistrationOptions): () => void;
@@ -3057,6 +3069,8 @@ export interface Extension {
3057
3069
  nonMutatingContextHandlers?: WeakSet<HandlerFn>;
3058
3070
  /** The declared side of each handler registered with `{ runs }`; a handler that declared none has no entry. */
3059
3071
  handlerSides?: WeakMap<HandlerFn, ExtensionSide>;
3072
+ /** The declared providers of each provider hook registered with `{ providers }`; undeclared hooks have no entry. */
3073
+ handlerProviders?: WeakMap<HandlerFn, readonly ProviderId[]>;
3060
3074
  tools: Map<string, RegisteredTool>;
3061
3075
  /** Optional for compatibility with extension records created before child modules. */
3062
3076
  childModules?: Map<string, RegisteredChildModule>;
@@ -0,0 +1,41 @@
1
+ import type { ProviderId } from "@earendil-works/pi-ai";
2
+ import type { HandSessionAdvertisement, HookModelRequest, HookModelRequestReply } from "../egress/wire.ts";
3
+ import type { Extension } from "../extensions/types.ts";
4
+ export type HookProviders = readonly ProviderId[] | "*";
5
+ export interface HandHookAccess {
6
+ readonly requestHooks: HookProviders;
7
+ readonly streamHooks: HookProviders;
8
+ }
9
+ /** The request and stream hooks of the extensions `only` accepts (the ones that run on this hand). */
10
+ export declare function handHookAccess(extensions: readonly Extension[], only: (extensionPath: string) => boolean): HandHookAccess;
11
+ export interface AdvertHashParts {
12
+ readonly extensionSetHash: string;
13
+ readonly cwd: string;
14
+ /** The project-local hook set, as hook identities; its order does not matter. */
15
+ readonly projectHooks: readonly string[];
16
+ }
17
+ /** `H(extension set hash, cwd, project-local hook set)` (4.9): `sha256:` + hex SHA-256 of their canonical JSON. */
18
+ export declare function computeAdvertHash(parts: AdvertHashParts): string;
19
+ /** A bound session's advertisement, before a connection stamps it. */
20
+ export type HandHookSession = Omit<HandSessionAdvertisement, "t" | "connectionId">;
21
+ /** A hook request for a session not advertised on the connection it names; nothing ran. */
22
+ export declare class HookNotAdvertisedError extends Error {
23
+ readonly name = "HookNotAdvertisedError";
24
+ }
25
+ export interface HandHookLinkOptions {
26
+ send(message: HandSessionAdvertisement | HookModelRequestReply): Promise<void>;
27
+ serve(request: HookModelRequest): Promise<HookModelRequestReply>;
28
+ }
29
+ export interface HandHookLink {
30
+ /** A new connection: every bound session's advertisement goes out first, in order, under `connectionId`. */
31
+ connect(connectionId: string, sessions: readonly HandHookSession[]): Promise<void>;
32
+ /** A session bound (or re-hashed) mid-connection: its advertisement goes out before anything else for it. */
33
+ advertise(session: HandHookSession): Promise<void>;
34
+ /**
35
+ * One `hook.model_request` received on `connectionId`; its reply follows that session's advertisement. Its hooks run
36
+ * outside the outgoing order, so a slow hook holds back no other request's reply and no advertisement.
37
+ */
38
+ receive(connectionId: string, request: HookModelRequest): Promise<void>;
39
+ }
40
+ export declare function createHandHookLink(options: HandHookLinkOptions): HandHookLink;
41
+ //# sourceMappingURL=hook-advertise.d.ts.map
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The hand's hook advertisement of hosted model-key custody (row CK21; custody design 4.9, round-10 fold section 15).
3
+ *
4
+ * A hand tells a hosted brain, per bound session, which providers' calls need the device's hooks: the request hooks
5
+ * (`before_provider_request`) and the stream hooks (`provider_stream_event`, `after_provider_response`) of the user's
6
+ * extensions, each scoped by its `{ providers }` declaration or, undeclared, every provider (`"*"`). The advertisement
7
+ * carries the `connectionId`, the session's `cwd` and its `advertHash`, and it is the first message for its session on
8
+ * every (re)connect: the link serves no `hook.model_request` that its session's advertisement on that connection did
9
+ * not precede.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { canonicalJson } from "@earendil-works/pi-agent-core";
13
+ const REQUEST_HOOK_EVENTS = ["before_provider_request"];
14
+ const STREAM_HOOK_EVENTS = ["provider_stream_event", "after_provider_response"];
15
+ function hookProviders(extensions, events, only) {
16
+ const declared = new Set();
17
+ for (const extension of extensions) {
18
+ if (!only(extension.path))
19
+ continue;
20
+ for (const event of events)
21
+ for (const handler of extension.handlers.get(event) ?? []) {
22
+ const providers = extension.handlerProviders?.get(handler);
23
+ if (providers === undefined)
24
+ return "*";
25
+ for (const provider of providers)
26
+ declared.add(provider);
27
+ }
28
+ }
29
+ return [...declared].sort();
30
+ }
31
+ /** The request and stream hooks of the extensions `only` accepts (the ones that run on this hand). */
32
+ export function handHookAccess(extensions, only) {
33
+ return {
34
+ requestHooks: hookProviders(extensions, REQUEST_HOOK_EVENTS, only),
35
+ streamHooks: hookProviders(extensions, STREAM_HOOK_EVENTS, only),
36
+ };
37
+ }
38
+ /** `H(extension set hash, cwd, project-local hook set)` (4.9): `sha256:` + hex SHA-256 of their canonical JSON. */
39
+ export function computeAdvertHash(parts) {
40
+ const canonical = canonicalJson({
41
+ cwd: parts.cwd,
42
+ extensionSetHash: parts.extensionSetHash,
43
+ projectHooks: [...parts.projectHooks].sort(),
44
+ });
45
+ return `sha256:${createHash("sha256").update(canonical, "utf8").digest("hex")}`;
46
+ }
47
+ /** A hook request for a session not advertised on the connection it names; nothing ran. */
48
+ export class HookNotAdvertisedError extends Error {
49
+ constructor() {
50
+ super(...arguments);
51
+ this.name = "HookNotAdvertisedError";
52
+ }
53
+ }
54
+ export function createHandHookLink(options) {
55
+ let connectionId;
56
+ let advertised = new Set();
57
+ // One outgoing order: an advertisement queued before a reply is sent before it.
58
+ let outgoing = Promise.resolve();
59
+ const enqueue = (work) => {
60
+ const run = outgoing.then(work);
61
+ outgoing = run.then(() => undefined, () => undefined);
62
+ return run;
63
+ };
64
+ const sendAdvert = (connection, session, into) => enqueue(async () => {
65
+ if (connectionId !== connection)
66
+ return;
67
+ await options.send({ t: "hand.session.advertise", connectionId: connection, ...session });
68
+ into.add(session.sessionId);
69
+ });
70
+ return {
71
+ async connect(next, sessions) {
72
+ connectionId = next;
73
+ const fresh = new Set();
74
+ advertised = fresh;
75
+ await Promise.all(sessions.map((session) => sendAdvert(next, session, fresh)));
76
+ },
77
+ async advertise(session) {
78
+ if (connectionId === undefined)
79
+ throw new HookNotAdvertisedError("no connection to advertise on");
80
+ await sendAdvert(connectionId, session, advertised);
81
+ },
82
+ async receive(connection, request) {
83
+ // Only the advertised check and the reply's send take a place in the outgoing order.
84
+ await enqueue(async () => {
85
+ if (connection !== connectionId || !advertised.has(request.sessionId))
86
+ throw new HookNotAdvertisedError(`hook.model_request for session ${JSON.stringify(request.sessionId)} was not preceded by its advertisement on connection ${JSON.stringify(connection)}`);
87
+ });
88
+ const reply = await options.serve(request);
89
+ await enqueue(async () => {
90
+ // A reconnect since the request arrived: its connection is gone, so its reply is not sent.
91
+ if (connection !== connectionId)
92
+ return;
93
+ await options.send(reply);
94
+ });
95
+ },
96
+ };
97
+ }
98
+ //# sourceMappingURL=hook-advertise.js.map