@ljwei-stak/dsh-model-route-bot 0.66.4 → 0.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/.dsh-plugin/bot-product.mjs +81 -5
  2. package/.dsh-plugin/client.js +17490 -1791
  3. package/README.i18n.yaml +4 -4
  4. package/README.md +409 -364
  5. package/README.zh.md +409 -364
  6. package/apps/integration-client/README.md +31 -7
  7. package/apps/integration-client/api.mjs +14 -3
  8. package/apps/integration-client/binding-editor.jsx +308 -109
  9. package/apps/integration-client/binding-model.mjs +262 -0
  10. package/apps/integration-client/binding.css +144 -0
  11. package/apps/integration-client/bot-model-status.mjs +18 -0
  12. package/apps/integration-client/bot-profile-identity.jsx +197 -0
  13. package/apps/integration-client/bot-profile.css +57 -0
  14. package/apps/integration-client/bot-profile.mjs +97 -0
  15. package/apps/integration-client/bot-settings-dialog.css +18 -0
  16. package/apps/integration-client/bot-settings-dialog.jsx +111 -0
  17. package/apps/integration-client/bot-thread-header-bridge.css +20 -0
  18. package/apps/integration-client/bot-thread-header.jsx +278 -0
  19. package/apps/integration-client/bot-threads-panel.jsx +378 -0
  20. package/apps/integration-client/bot-threads.css +169 -0
  21. package/apps/integration-client/chat-design.mjs +250 -0
  22. package/apps/integration-client/chat-ui.jsx +175 -0
  23. package/apps/integration-client/chat.css +477 -0
  24. package/apps/integration-client/composer-draft-binding.mjs +50 -0
  25. package/apps/integration-client/composer-draft-bound.jsx +14 -0
  26. package/apps/integration-client/composer-draft-host.jsx +142 -0
  27. package/apps/integration-client/composer-draft-session.jsx +287 -0
  28. package/apps/integration-client/composer-draft-witness.mjs +115 -0
  29. package/apps/integration-client/composer-draft.css +4 -0
  30. package/apps/integration-client/composer-draft.mjs +322 -0
  31. package/apps/integration-client/contact-attention.jsx +54 -0
  32. package/apps/integration-client/data-panel.jsx +439 -0
  33. package/apps/integration-client/data.css +69 -0
  34. package/apps/integration-client/desktop-bridge.mjs +1 -0
  35. package/apps/integration-client/desktop-http-socket.mjs +49 -0
  36. package/apps/integration-client/desktop-keyboard.mjs +32 -0
  37. package/apps/integration-client/desktop-pane.jsx +99 -30
  38. package/apps/integration-client/desktop-stream.mjs +13 -3
  39. package/apps/integration-client/desktop-tools.jsx +19 -0
  40. package/apps/integration-client/desktop-touch.mjs +27 -0
  41. package/apps/integration-client/desktop.css +41 -0
  42. package/apps/integration-client/independent-chat-controller.mjs +224 -0
  43. package/apps/integration-client/independent-chat-entry.jsx +61 -0
  44. package/apps/integration-client/independent-chat-navigation.mjs +21 -0
  45. package/apps/integration-client/independent-chat-page.jsx +148 -0
  46. package/apps/integration-client/independent-chat-rich.jsx +64 -0
  47. package/apps/integration-client/independent-chat-rich.mjs +30 -0
  48. package/apps/integration-client/independent-chat.css +73 -0
  49. package/apps/integration-client/index.jsx +24 -16
  50. package/apps/integration-client/library-panel.jsx +4 -1
  51. package/apps/integration-client/licenses/jsqr-1.4.0-LICENSE.txt +202 -0
  52. package/apps/integration-client/main-route-editor.jsx +94 -0
  53. package/apps/integration-client/main-route-model.mjs +100 -0
  54. package/apps/integration-client/model-picker.css +37 -0
  55. package/apps/integration-client/model-picker.jsx +83 -0
  56. package/apps/integration-client/native-activity.css +141 -0
  57. package/apps/integration-client/native-activity.jsx +276 -0
  58. package/apps/integration-client/native-activity.mjs +384 -0
  59. package/apps/integration-client/notifications-panel.jsx +186 -0
  60. package/apps/integration-client/notifications.css +126 -0
  61. package/apps/integration-client/pairing-qr.jsx +79 -0
  62. package/apps/integration-client/panels.jsx +126 -115
  63. package/apps/integration-client/product-slots.jsx +46 -0
  64. package/apps/integration-client/routine-recorder.mjs +23 -2
  65. package/apps/integration-client/secure-pairing.jsx +84 -0
  66. package/apps/integration-client/tasks-panel.jsx +32 -10
  67. package/apps/integration-client/ui-icons.jsx +17 -0
  68. package/apps/integration-client/usage-explorer.jsx +43 -0
  69. package/apps/integration-client/usage-report.mjs +71 -0
  70. package/apps/integration-client/usage.css +34 -0
  71. package/apps/integration-client/web-panel-model.mjs +140 -0
  72. package/apps/integration-client/web-panel-page.jsx +221 -0
  73. package/apps/integration-client/web-panel-pins-live.jsx +28 -0
  74. package/apps/integration-client/web-panel.css +139 -0
  75. package/apps/integration-client/workspace-model.mjs +67 -0
  76. package/apps/integration-client/workspace-navigation.mjs +56 -0
  77. package/apps/integration-client/workspace-ui.jsx +173 -0
  78. package/apps/integration-client/workspace.css +332 -0
  79. package/modules/bot-runtime/SOURCE.json +176 -50
  80. package/modules/bot-runtime/index.mjs +169 -13
  81. package/modules/bot-runtime/product-overlay/OVERLAY.json +128 -2
  82. package/modules/bot-runtime/product-overlay/base/src/client/header.js +244 -0
  83. package/modules/bot-runtime/product-overlay/base/src/client/learning-card.js +202 -0
  84. package/modules/bot-runtime/product-overlay/base/src/client/model-menu.js +371 -0
  85. package/modules/bot-runtime/product-overlay/base/src/client/secret-card.js +363 -0
  86. package/modules/bot-runtime/product-overlay/base/src/client/sources.js +68 -0
  87. package/modules/bot-runtime/product-overlay/base/src/client/turn-error.js +260 -0
  88. package/modules/bot-runtime/product-overlay/base/src/host/channel.js +647 -0
  89. package/modules/bot-runtime/product-overlay/base/src/host/memory.js +2050 -0
  90. package/modules/bot-runtime/product-overlay/base/src/host/models.js +314 -0
  91. package/modules/bot-runtime/product-overlay/base/src/host/parts.js +1473 -0
  92. package/modules/bot-runtime/product-overlay/base/src/host/privacy.js +368 -0
  93. package/modules/bot-runtime/product-overlay/base/src/host/roster.js +776 -0
  94. package/modules/bot-runtime/product-overlay/base/src/host/store.js +512 -0
  95. package/modules/bot-runtime/product-overlay/base/src/host/usage.js +653 -0
  96. package/modules/bot-runtime/product-overlay/patches/src/client/details-drawer.js.patch +48 -2
  97. package/modules/bot-runtime/product-overlay/patches/src/client/header.js.patch +41 -0
  98. package/modules/bot-runtime/product-overlay/patches/src/client/index.js.patch +33 -1
  99. package/modules/bot-runtime/product-overlay/patches/src/client/learning-card.js.patch +20 -0
  100. package/modules/bot-runtime/product-overlay/patches/src/client/model-menu.js.patch +24 -0
  101. package/modules/bot-runtime/product-overlay/patches/src/client/secret-card.js.patch +25 -0
  102. package/modules/bot-runtime/product-overlay/patches/src/client/sources.js.patch +52 -0
  103. package/modules/bot-runtime/product-overlay/patches/src/client/turn-error.js.patch +23 -0
  104. package/modules/bot-runtime/product-overlay/patches/src/host/channel.js.patch +46 -0
  105. package/modules/bot-runtime/product-overlay/patches/src/host/memory.js.patch +11 -0
  106. package/modules/bot-runtime/product-overlay/patches/src/host/models.js.patch +16 -0
  107. package/modules/bot-runtime/product-overlay/patches/src/host/parts.js.patch +95 -0
  108. package/modules/bot-runtime/product-overlay/patches/src/host/privacy.js.patch +11 -0
  109. package/modules/bot-runtime/product-overlay/patches/src/host/roster.js.patch +145 -0
  110. package/modules/bot-runtime/product-overlay/patches/src/host/store.js.patch +68 -0
  111. package/modules/bot-runtime/product-overlay/patches/src/host/usage.js.patch +11 -0
  112. package/modules/bot-runtime/read-model.mjs +19 -4
  113. package/modules/bot-runtime/vendor/ds-bot/src/client/details-drawer.js +15 -5
  114. package/modules/bot-runtime/vendor/ds-bot/src/client/header.js +9 -2
  115. package/modules/bot-runtime/vendor/ds-bot/src/client/index.js +9 -2
  116. package/modules/bot-runtime/vendor/ds-bot/src/client/learning-card.js +2 -2
  117. package/modules/bot-runtime/vendor/ds-bot/src/client/model-menu.js +5 -3
  118. package/modules/bot-runtime/vendor/ds-bot/src/client/secret-card.js +3 -3
  119. package/modules/bot-runtime/vendor/ds-bot/src/client/sources.js +25 -2
  120. package/modules/bot-runtime/vendor/ds-bot/src/client/turn-error.js +3 -2
  121. package/modules/bot-runtime/vendor/ds-bot/src/host/channel.js +14 -2
  122. package/modules/bot-runtime/vendor/ds-bot/src/host/memory.js +1 -1
  123. package/modules/bot-runtime/vendor/ds-bot/src/host/models.js +7 -0
  124. package/modules/bot-runtime/vendor/ds-bot/src/host/parts.js +26 -9
  125. package/modules/bot-runtime/vendor/ds-bot/src/host/privacy.js +1 -1
  126. package/modules/bot-runtime/vendor/ds-bot/src/host/roster.js +112 -3
  127. package/modules/bot-runtime/vendor/ds-bot/src/host/store.js +34 -1
  128. package/modules/bot-runtime/vendor/ds-bot/src/host/usage.js +1 -1
  129. package/modules/independent-runtime/index.mjs +152 -62
  130. package/modules/independent-runtime/remote-tools.mjs +8 -5
  131. package/modules/openmaus-protocol/NOTICE +10 -0
  132. package/modules/openmaus-protocol/README.md +15 -0
  133. package/modules/openmaus-protocol/SOURCE.json +68 -0
  134. package/modules/openmaus-protocol/dist/protocol.mjs +1203 -0
  135. package/modules/openmaus-protocol/index.mjs +1 -0
  136. package/modules/openmaus-protocol/transport.mjs +77 -0
  137. package/modules/openmaus-protocol/vendor/LICENSE +202 -0
  138. package/modules/openmaus-protocol/vendor/companion/src/e2e/README.md +405 -0
  139. package/modules/openmaus-protocol/vendor/companion/src/e2e/credentials.ts +128 -0
  140. package/modules/openmaus-protocol/vendor/companion/src/e2e/descriptor.ts +84 -0
  141. package/modules/openmaus-protocol/vendor/companion/src/e2e/noise.ts +456 -0
  142. package/modules/openmaus-protocol/vendor/companion/src/e2e/phone.ts +104 -0
  143. package/modules/openmaus-protocol/vendor/companion/src/e2e/session.ts +417 -0
  144. package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.json +2565 -0
  145. package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.sha256 +1 -0
  146. package/modules/openmaus-protocol/vendor/companion/src/e2e/yamux.ts +335 -0
  147. package/package.json +18 -6
  148. package/packages/bot-acp/README.md +156 -0
  149. package/packages/bot-acp/licenses/LICENSE-OpenMausBot +202 -0
  150. package/packages/bot-acp/licenses/NOTICE.md +27 -0
  151. package/packages/bot-acp/src/acp-adapter.mjs +795 -0
  152. package/packages/bot-acp/src/acp-host.mjs +148 -0
  153. package/packages/bot-acp/src/native-launch.mjs +121 -0
  154. package/packages/bot-acp/src/permission-policy.mjs +283 -0
  155. package/packages/bot-acp/src/rpc-codec.mjs +265 -0
  156. package/packages/bot-acp/src/session-core.mjs +581 -0
  157. package/packages/bot-acp/src/stdio-rpc.mjs +329 -0
  158. package/packages/bot-engines/README.md +281 -0
  159. package/packages/bot-engines/licenses/LICENSE-OpenMausBot +202 -0
  160. package/packages/bot-engines/licenses/NOTICE.md +32 -0
  161. package/packages/bot-engines/src/claude-engine.mjs +772 -0
  162. package/packages/bot-engines/src/claude-llm-adapter.mjs +797 -0
  163. package/packages/bot-engines/src/claude-llm-turn.mjs +292 -0
  164. package/packages/bot-engines/src/claude-permission-broker.mjs +130 -0
  165. package/packages/bot-engines/src/claude-permission-proxy.mjs +87 -0
  166. package/packages/bot-engines/src/claude-protocol.mjs +177 -0
  167. package/packages/bot-engines/src/driver-catalog.mjs +334 -0
  168. package/packages/bot-engines/src/host-loop-request.mjs +95 -0
  169. package/packages/bot-engines/src/host-registrations.mjs +105 -0
  170. package/packages/capability-broker/src/cli-bootstrap.mjs +86 -16
  171. package/packages/capability-broker/src/cli-bridge-response.mjs +80 -0
  172. package/packages/capability-broker/src/cli-stdio-bridge.mjs +21 -8
  173. package/packages/cli-runtime/src/codex-session-core.mjs +4 -0
  174. package/packages/cli-runtime/src/native-cli-output.mjs +57 -0
  175. package/packages/cli-runtime/src/native-peer.ps1 +51 -3
  176. package/packages/cli-runtime/src/native-process.mjs +77 -14
  177. package/packages/model-router-plugin/package.json +1 -1
  178. package/packages/orchestration/src/binding-product.mjs +19 -0
  179. package/packages/orchestration/src/bot-data-actions.mjs +45 -0
  180. package/packages/orchestration/src/bot-data-chart.mjs +109 -0
  181. package/packages/orchestration/src/bot-data-context.mjs +44 -0
  182. package/packages/orchestration/src/bot-data-engine.mjs +809 -0
  183. package/packages/orchestration/src/bot-data-io.mjs +186 -0
  184. package/packages/orchestration/src/bot-data-sql.mjs +195 -0
  185. package/packages/orchestration/src/bot-data-surface.mjs +144 -0
  186. package/packages/orchestration/src/bot-data-tools.mjs +68 -0
  187. package/packages/orchestration/src/bot-data-worker.mjs +228 -0
  188. package/packages/orchestration/src/bot-independent-execution.mjs +195 -0
  189. package/packages/orchestration/src/bot-independent-stream.mjs +74 -0
  190. package/packages/orchestration/src/bot-loop.mjs +152 -19
  191. package/packages/orchestration/src/bot-notifications.mjs +159 -0
  192. package/packages/orchestration/src/bot-policy.mjs +43 -4
  193. package/packages/orchestration/src/bot-profile.mjs +171 -0
  194. package/packages/orchestration/src/bot-routine.mjs +4 -4
  195. package/packages/orchestration/src/bot-runtime-assembly.mjs +255 -8
  196. package/packages/orchestration/src/bot-runtime-switch.mjs +13 -2
  197. package/packages/orchestration/src/bot-serving.mjs +164 -0
  198. package/packages/orchestration/src/bot-session-store.mjs +105 -9
  199. package/packages/orchestration/src/bot-settings.mjs +50 -11
  200. package/packages/orchestration/src/bot-thread-admission.mjs +84 -0
  201. package/packages/orchestration/src/bot-thread-host.mjs +187 -0
  202. package/packages/orchestration/src/bot-threads.mjs +790 -0
  203. package/packages/orchestration/src/data-product-access.mjs +37 -0
  204. package/packages/orchestration/src/data-tool-contract.mjs +291 -0
  205. package/packages/orchestration/src/desktop-http-carrier.mjs +120 -0
  206. package/packages/orchestration/src/desktop-proxy.mjs +311 -0
  207. package/packages/orchestration/src/desktop-sessions.mjs +43 -9
  208. package/packages/orchestration/src/ds-lifecycle.mjs +31 -4
  209. package/packages/orchestration/src/grant-refresh-queue.mjs +17 -0
  210. package/packages/orchestration/src/independent-chat-page.mjs +39 -0
  211. package/packages/orchestration/src/independent-chat-service.mjs +308 -0
  212. package/packages/orchestration/src/independent-context-state.mjs +44 -0
  213. package/packages/orchestration/src/independent-message-wire.mjs +84 -0
  214. package/packages/orchestration/src/independent-tool-catalog.mjs +175 -0
  215. package/packages/orchestration/src/independent-tool-families.mjs +73 -0
  216. package/packages/orchestration/src/independent-tool-host.mjs +344 -0
  217. package/packages/orchestration/src/main-bot-grant.mjs +15 -0
  218. package/packages/orchestration/src/main-model-routes.mjs +208 -0
  219. package/packages/orchestration/src/model-accounting.mjs +99 -35
  220. package/packages/orchestration/src/model-auxiliary-dispatch.mjs +187 -0
  221. package/packages/orchestration/src/model-loop-frame.mjs +179 -0
  222. package/packages/orchestration/src/model-prepared-generation.mjs +175 -0
  223. package/packages/orchestration/src/model-request-bounds.mjs +111 -0
  224. package/packages/orchestration/src/native-activity-contract.mjs +491 -0
  225. package/packages/orchestration/src/native-activity-service.mjs +102 -0
  226. package/packages/orchestration/src/native-activity-store.mjs +249 -0
  227. package/packages/orchestration/src/native-conversation-tools.mjs +78 -0
  228. package/packages/orchestration/src/native-data-tools.mjs +100 -0
  229. package/packages/orchestration/src/native-remote-actions.mjs +742 -0
  230. package/packages/orchestration/src/native-team-tools.mjs +73 -0
  231. package/packages/orchestration/src/pairing-invite.mjs +37 -0
  232. package/packages/orchestration/src/product-composition.mjs +1559 -1001
  233. package/packages/orchestration/src/remote-machines.mjs +82 -14
  234. package/packages/orchestration/src/routine-execution.mjs +5 -3
  235. package/packages/orchestration/src/routine-session.mjs +19 -13
  236. package/packages/orchestration/src/secure-pairing-host.mjs +112 -0
  237. package/packages/orchestration/src/task-journal.mjs +29 -0
  238. package/packages/orchestration/src/task-product.mjs +17 -0
  239. package/packages/orchestration/src/team-assignment-store.mjs +120 -0
  240. package/packages/orchestration/src/team-bridge.mjs +282 -0
  241. package/packages/orchestration/src/team-scheduler.mjs +83 -0
  242. package/packages/orchestration/src/team-sdk-tools.mjs +84 -0
  243. package/packages/orchestration/src/team-tool-contract.mjs +284 -0
  244. package/packages/orchestration/src/ui-host-namespace.mjs +23 -0
  245. package/packages/orchestration/src/web-panel-actions.mjs +103 -0
  246. package/packages/orchestration/src/web-panel-framing.mjs +227 -0
  247. package/packages/orchestration/src/web-panel-store.mjs +206 -0
  248. package/remote-computer-agent/README.md +39 -29
  249. package/remote-computer-agent/build.mjs +3 -0
  250. package/remote-computer-agent/dist/OPENMAUSBOT_LICENSE +202 -0
  251. package/remote-computer-agent/dist/OPENMAUSBOT_NOTICE +10 -0
  252. package/remote-computer-agent/dist/OPENMAUSBOT_SOURCE.json +68 -0
  253. package/remote-computer-agent/dist/cli-stdio-bridge.mjs +215 -25
  254. package/remote-computer-agent/dist/cli-stdio-bridge.mjs.map +4 -4
  255. package/remote-computer-agent/dist/cli.mjs +1688 -168
  256. package/remote-computer-agent/dist/cli.mjs.map +4 -4
  257. package/remote-computer-agent/dist/index.mjs +1579 -122
  258. package/remote-computer-agent/dist/index.mjs.map +4 -4
  259. package/remote-computer-agent/dist/native-peer.ps1 +51 -3
  260. package/remote-computer-agent/package-lock.json +308 -2
  261. package/remote-computer-agent/package.json +2 -1
  262. package/remote-computer-agent/pair-device.ps1 +20 -0
  263. package/remote-computer-agent/setup.ps1 +38 -16
  264. package/remote-computer-agent/src/cli.ts +24 -3
  265. package/remote-computer-agent/src/desktop-session.ts +11 -3
  266. package/remote-computer-agent/src/e2e-server.ts +81 -0
  267. package/remote-computer-agent/src/pairing-page.ts +35 -0
  268. package/remote-computer-agent/src/protocol.ts +25 -25
  269. package/remote-computer-agent/src/secure-pairing.ts +105 -0
  270. package/remote-computer-agent/src/store.ts +20 -5
  271. package/remote-computer-agent/src/transport.ts +39 -15
@@ -0,0 +1,797 @@
1
+ // The public LlmAdapter bridge for the Claude retained conversation engine.
2
+ //
3
+ // One Bot conversation keeps ONE native CLI session (packages/bot-engines/src/claude-engine.mjs)
4
+ // and speaks to the installed SDK through the public `LlmAdapter` surface, so the Host's own
5
+ // `llm/stream` waterfall — the same one an integrated turn goes through — applies the Bot
6
+ // policy, re-decides the route and records the ONE receipt. This bridge holds no money and
7
+ // issues no receipt of its own.
8
+ //
9
+ // Trust boundary. Only a Host constructs this adapter, and only a Host supplies the trusted
10
+ // state it dispatches on:
11
+ // - `entry` / `entryFor()` — the pinned installation reference (executable, digest, version,
12
+ // optional pinned interpreter script). Never read from a request.
13
+ // - `identityFor({provider, model, sessionId, purpose})` — the committed Bot identity:
14
+ // the authority subject, the committed route's authority object, the durable binding and
15
+ // the Host-confirmed persona. It reads committed Host state; a request can never name a
16
+ // Bot, a path, a command, a credential or an engine configuration, and a request carrying
17
+ // any field outside the accepted set is refused.
18
+ // - `funding` / `fundingFor()` — the committed funding of this route. Only an explicitly
19
+ // configured Claude **subscription** chat route is open: an API-funded CLI route cannot
20
+ // prove a strict per-call cost or output bound here, so it is refused rather than priced
21
+ // by guesswork.
22
+ // - `engineFactory` — the retained-session engine seam. The default is the real
23
+ // `createClaudeEngine`; a Host (or a test) may inject another, and the injection is the
24
+ // only way the engine configuration is ever chosen.
25
+ //
26
+ // What a turn is. `stream(options)` is one conversation turn: the new user text is projected
27
+ // out of the request history (the retained CLI already holds everything before it), dispatched
28
+ // on the one native session, and mapped onto the SDK chunk vocabulary. Turns are serial: the
29
+ // engine's own single-turn fence refuses a concurrent second turn, and that refusal is
30
+ // surfaced, never queued silently. Cancellation belongs to the turn that asked for it — the
31
+ // session is never bound to the first turn's short-lived signal — and it really stops the
32
+ // native tree and waits for the engine's own settlement before the stream finishes.
33
+ //
34
+ // What is never done here: no second chat surface, no second ledger, no fabricated
35
+ // contextWindow / defaultMaxTokens / price / response-backend proof, no blind replay of a
36
+ // turn whose outcome is unknown, and no re-execution of the engine's own internal tools as
37
+ // loop tool calls.
38
+
39
+ import { randomBytes } from 'node:crypto'
40
+ import { isAbsolute } from 'node:path'
41
+
42
+ import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
43
+
44
+ import { createClaudeEngine } from './claude-engine.mjs'
45
+ import {
46
+ CLAUDE_LLM_CAPABILITIES, assertRouteRequest, emptyTurnState, finishReasonFromTurn,
47
+ projectRetainedTurn, seedTurnState, sessionKeyOf, turnFacts, usageChunkFromTurn,
48
+ } from './claude-llm-turn.mjs'
49
+
50
+ /** The one funding kind this route opens. Everything else is refused with a reason. */
51
+ export const CLAUDE_LLM_FUNDING = 'subscription'
52
+
53
+ const identifier = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value)
54
+ const text = value => typeof value === 'string' && value.trim() ? value : null
55
+ const coded = (code, message) => Object.assign(new Error(message), { code })
56
+ /** A construction refusal carries its machine code beside the message, so a Host can assert on it. */
57
+ const badOptions = (message) => { const [code, ...rest] = String(message).split(': '); return Object.assign(new TypeError(message), { code, detail: rest.join(': ') }) }
58
+
59
+ /** Two pinned entries are the same installation only when every recorded fact agrees. */
60
+ const entryEquals = (left, right) => Boolean(left) && Boolean(right)
61
+ && left.executable === right.executable && left.sha256 === right.sha256
62
+ && left.version === right.version && (left.script ?? null) === (right.script ?? null)
63
+ && (left.scriptSha256 ?? null) === (right.scriptSha256 ?? null)
64
+
65
+ /** Turn refusal → one terminal SDK failure, so no consumer has to guess an outcome. */
66
+ const failureOf = error => {
67
+ const code = typeof error?.code === 'string' && error.code ? error.code : codeOfMessage(error?.message)
68
+ return { code, message: String(error?.message ?? error ?? 'claude route failed') }
69
+ }
70
+
71
+ /** The engine refuses with plain Errors whose code leads the message; keep it machine-readable. */
72
+ const codeOfMessage = message => {
73
+ const match = /^([A-Z][A-Z0-9_]{2,})(?::[ \n]|$)/.exec(typeof message === 'string' ? message : '')
74
+ return match ? match[1] : 'CLAUDE_TURN_FAILED'
75
+ }
76
+
77
+ const finishChunk = (kind, failure) => ({ type: 'finish', reason: { kind, failure } })
78
+
79
+ /** A native pid that no longer exists. */
80
+ const pidAlive = pid => { try { process.kill(pid, 0); return true } catch { return false } }
81
+
82
+ /**
83
+ * Wait, bounded, for the engine to notice that its retained process is gone.
84
+ *
85
+ * A process that exits right after its `result` frame is only noticed when the supervisor
86
+ * reports the stop, which can land after the turn promise already resolved. Dispatching the
87
+ * next prompt into that dead pipe loses the turn without any outcome, so the bridge gives
88
+ * the engine a moment to retire it and relaunch with `--resume`. A live process, or no
89
+ * process at all, returns immediately; the wait never blocks a healthy turn.
90
+ */
91
+ async function awaitRetiredProcessNotice(engine, timeoutMs = 5_000) {
92
+ const end = Date.now() + timeoutMs
93
+ while (Date.now() < end) {
94
+ const retained = engine.snapshot?.().process ?? null
95
+ if (!retained || !Number.isInteger(retained.pid) || retained.pid <= 0 || pidAlive(retained.pid)) return
96
+ await new Promise(resolve => setTimeout(resolve, 25))
97
+ }
98
+ }
99
+ /**
100
+ * Native stop reasons after which the retained session cannot be continued: the upstream
101
+ * conversation is gone, the identity failed, the grant was revoked, the process died
102
+ * mid-turn or the turn timed out. Each of them ends the native session, so the bridge
103
+ * retires it and the Host is told to open a new one instead of silently starting a fresh
104
+ * session that has lost the conversation.
105
+ */
106
+ const RETIRING_STOP_REASONS = Object.freeze([
107
+ 'resume-refused', 'model-identity-mismatch', 'grant-revoked', 'engine-stopped',
108
+ 'turn-timeout', 'exit-before-result',
109
+ ])
110
+
111
+ /**
112
+ * The Claude retained-session route as a public `LlmAdapter`.
113
+ *
114
+ * Register with the installed runtime exactly like any other adapter:
115
+ * `ctx.llm.registerAdapter([provider], createClaudeLlmAdapter(hostOptions))`.
116
+ */
117
+ export class ClaudeLlmAdapter extends LlmAdapter {
118
+ #provider
119
+ #model
120
+ #name
121
+ #funding
122
+ #fundingFor
123
+ #entry
124
+ #entryFor
125
+ #runtimeRoot
126
+ #workspaceFor
127
+ #workspace
128
+ #identityFor
129
+ #requestFor
130
+ #persona
131
+ #hooks
132
+ #onPermissionRequest
133
+ #provisionCredentials
134
+ #tools
135
+ #handlers
136
+ #toolsFor
137
+ #onActivity
138
+ #activityObserverFailures = 0
139
+ #permissionMode
140
+ #deadlineMs
141
+ #launcher
142
+ #python
143
+ #engineFactory
144
+ #generation = 1
145
+ #closed = false
146
+ #sessions = new Map()
147
+ #creations = new Set()
148
+ #engines = new Set()
149
+
150
+ /**
151
+ * @param {object} options every value is trusted Host state.
152
+ * @param {string} options.provider the one provider route id this adapter serves
153
+ * @param {string} options.model the one exact committed model id
154
+ * @param {string} [options.name] display name for selectors and diagnostics
155
+ * @param {string} [options.funding='subscription'] committed funding kind; only `subscription` opens the route
156
+ * @param {Function} [options.fundingFor] `({provider, model}) => funding` trusted committed-funding read, re-checked per prepared call
157
+ * @param {{executable:string, sha256:string, version:string, script?:string, scriptSha256?:string}} [options.entry] pinned installation reference
158
+ * @param {Function} [options.entryFor] `() => entry|Promise<entry>` trusted pinned-installation read; a changed pin refuses the captured generation
159
+ * @param {string} options.runtimeRoot isolated absolute runtime root the engine creates its private homes under
160
+ * @param {Function} [options.workspaceFor] `({botId, sessionId}) => absolute workspace` trusted per-conversation workspace
161
+ * @param {string} [options.workspace] default absolute conversation workspace
162
+ * @param {Function} options.identityFor `({provider, model, sessionId, purpose}) => identity` — see the module header
163
+ * @param {string} [options.persona] Host-confirmed persona, when it is not per-Bot identity state
164
+ * @param {{onBeforeRequest?:Function, onObserve?:Function, onSettle?:Function, onClose?:Function}} [options.hooks] engine hooks; the money authority stays the SDK waterfall
165
+ * @param {Function} [options.onPermissionRequest] Host permission callback; absent denies every ask
166
+ * @param {Function} [options.provisionCredentials] `(home, identity) => Promise` writes subscription credentials into the isolated home
167
+ * @param {Function} [options.requestFor] Host-only canonical request projection; never supplied by model arguments
168
+ * @param {string[]} [options.tools] committed-grant tool names bridged into the engine over MCP
169
+ * @param {Array<{definition:{name}, parse:Function, execute:Function}>} [options.handlers] bridge handlers for `tools`
170
+ * @param {string} [options.permissionMode='default'] engine permission mode (bypassPermissions is refused by the engine)
171
+ * @param {number} [options.deadlineMs=120000] bounded lifetime of one engine
172
+ * @param {{probe?:Function, protect?:Function, supervise?:Function}} [options.launcher] process-launcher seam
173
+ * @param {string} [options.python] native-peer interpreter selection
174
+ * @param {Function} [options.engineFactory] `(config) => Promise<engine>` retained-session seam; the default is the real `createClaudeEngine`
175
+ */
176
+ constructor(options = {}) {
177
+ super()
178
+ const {
179
+ provider, model, name, funding = CLAUDE_LLM_FUNDING, fundingFor = null,
180
+ entry = null, entryFor = null, runtimeRoot, workspace = null, workspaceFor = null,
181
+ identityFor, requestFor = null, persona = null, hooks = {}, onPermissionRequest = null, provisionCredentials = null,
182
+ tools = [], handlers = [], toolsFor = null, onActivity = null, permissionMode = 'default', deadlineMs = 120_000,
183
+ launcher = null, python, engineFactory = null,
184
+ } = options ?? {}
185
+ if (!identifier(provider)) throw badOptions('CLAUDE_ROUTE_PROVIDER_REQUIRED')
186
+ if (typeof model !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/.test(model)) throw badOptions('CLAUDE_ROUTE_MODEL_REQUIRED')
187
+ if (funding !== CLAUDE_LLM_FUNDING) {
188
+ throw badOptions(`CLAUDE_ROUTE_FUNDING_REFUSED: this bridge only opens an explicitly configured Claude subscription chat route, not ${JSON.stringify(funding)}`)
189
+ }
190
+ if (fundingFor !== null && typeof fundingFor !== 'function') throw badOptions('CLAUDE_ROUTE_FUNDING_READER_REQUIRED_FUNCTION')
191
+ if ((entry === null) === (entryFor === null)) throw badOptions('CLAUDE_ROUTE_ENTRY_REQUIRED: provide exactly one of entry / entryFor')
192
+ if (entry !== null && (!entry.executable || !/^[a-f0-9]{64}$/.test(entry.sha256 ?? '') || typeof entry.version !== 'string')) throw badOptions('CLAUDE_ROUTE_ENTRY_INVALID')
193
+ if (entryFor !== null && typeof entryFor !== 'function') throw badOptions('CLAUDE_ROUTE_ENTRY_READER_REQUIRED_FUNCTION')
194
+ if (!isAbsolute(runtimeRoot ?? '')) throw badOptions('CLAUDE_ROUTE_RUNTIME_ROOT_REQUIRED')
195
+ if (workspace !== null && !isAbsolute(workspace)) throw badOptions('CLAUDE_ROUTE_WORKSPACE_REQUIRED_ABSOLUTE')
196
+ if (workspaceFor !== null && typeof workspaceFor !== 'function') throw badOptions('CLAUDE_ROUTE_WORKSPACE_READER_REQUIRED_FUNCTION')
197
+ if (workspace === null && workspaceFor === null) throw badOptions('CLAUDE_ROUTE_WORKSPACE_REQUIRED')
198
+ if (typeof identityFor !== 'function') throw badOptions('CLAUDE_ROUTE_IDENTITY_RESOLVER_REQUIRED')
199
+ if (requestFor !== null && typeof requestFor !== 'function') throw badOptions('CLAUDE_ROUTE_REQUEST_PROJECTOR_REQUIRED_FUNCTION')
200
+ if (persona !== null && typeof persona !== 'string') throw badOptions('CLAUDE_ROUTE_PERSONA_REQUIRED_STRING')
201
+ for (const key of ['onBeforeRequest', 'onObserve', 'onSettle', 'onClose']) {
202
+ if (hooks[key] !== undefined && typeof hooks[key] !== 'function') throw badOptions(`CLAUDE_ROUTE_HOOK_${key.toUpperCase()}_REQUIRED_FUNCTION`)
203
+ }
204
+ if (onPermissionRequest !== null && typeof onPermissionRequest !== 'function') throw badOptions('CLAUDE_ROUTE_PERMISSION_CALLBACK_REQUIRED_FUNCTION')
205
+ if (provisionCredentials !== null && typeof provisionCredentials !== 'function') throw badOptions('CLAUDE_ROUTE_CREDENTIAL_PROVIDER_REQUIRED_FUNCTION')
206
+ if (!Array.isArray(tools) || tools.some(tool => typeof tool !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(tool))) throw badOptions('CLAUDE_ROUTE_TOOLS_INVALID')
207
+ if (!Array.isArray(handlers) || handlers.some(handler => !handler?.definition?.name || typeof handler.parse !== 'function' || typeof handler.execute !== 'function')) throw badOptions('CLAUDE_ROUTE_HANDLERS_INVALID')
208
+ if (toolsFor !== null && typeof toolsFor !== 'function') throw badOptions('CLAUDE_ROUTE_TOOLS_PROVIDER_REQUIRED_FUNCTION')
209
+ if (onActivity !== null && typeof onActivity !== 'function') throw badOptions('CLAUDE_ROUTE_ACTIVITY_CALLBACK_REQUIRED_FUNCTION')
210
+ if (engineFactory !== null && typeof engineFactory !== 'function') throw badOptions('CLAUDE_ROUTE_ENGINE_FACTORY_REQUIRED_FUNCTION')
211
+ if (launcher !== null && (typeof launcher !== 'object' || typeof launcher.probe !== 'function' || typeof launcher.protect !== 'function' || typeof launcher.supervise !== 'function')) throw badOptions('CLAUDE_ROUTE_LAUNCHER_INVALID')
212
+
213
+ this.#provider = provider
214
+ this.#model = model
215
+ this.#name = typeof name === 'string' && name.trim() ? name.trim() : `${provider} (Claude session)`
216
+ this.#funding = funding
217
+ this.#fundingFor = fundingFor
218
+ this.#entry = entry === null ? null : Object.freeze({ ...entry })
219
+ this.#entryFor = entryFor
220
+ this.#runtimeRoot = runtimeRoot
221
+ this.#workspaceFor = workspaceFor
222
+ this.#workspace = workspace
223
+ this.#identityFor = identityFor
224
+ this.#requestFor = requestFor
225
+ this.#persona = persona
226
+ this.#hooks = hooks
227
+ this.#onPermissionRequest = onPermissionRequest
228
+ this.#provisionCredentials = provisionCredentials
229
+ this.#tools = [...tools]
230
+ this.#handlers = [...handlers]
231
+ this.#toolsFor = toolsFor
232
+ this.#onActivity = onActivity
233
+ this.#permissionMode = permissionMode
234
+ this.#deadlineMs = Number.isSafeInteger(deadlineMs) && deadlineMs >= 1000 && deadlineMs <= 7_200_000 ? deadlineMs : 120_000
235
+ this.#launcher = launcher
236
+ this.#python = python
237
+ this.#engineFactory = engineFactory ?? (config => createClaudeEngine(config))
238
+ }
239
+
240
+ /** Display metadata for the one route this registration holds. */
241
+ providerInfo(provider) {
242
+ if (provider !== this.#provider) throw new LlmError(`this adapter does not own provider "${provider}"`, 'NO_ADAPTER')
243
+ return Object.freeze({ id: this.#provider, name: this.#name })
244
+ }
245
+
246
+ /** The one committed model this route serves; nothing is advertised that is not committed. */
247
+ async listModels(provider) {
248
+ if (provider !== this.#provider) return []
249
+ return [Object.freeze({
250
+ provider: this.#provider, id: this.#model, name: this.#model,
251
+ description: 'Claude 订阅会话路由:一次一条新的用户文本,保留一个原生 CLI 会话。',
252
+ inputModalities: Object.freeze(['text']),
253
+ })]
254
+ }
255
+
256
+ /**
257
+ * Exact-route metadata. Deliberately absent: `context` (no context window is known for a
258
+ * subscription CLI session, so none is invented), `defaultMaxTokens` (the route cannot
259
+ * bound output tokens, so it declares no cap to materialize), and `reasoning` (no
260
+ * selectable efforts are exposed).
261
+ */
262
+ async resolveModel(provider, model, signal) {
263
+ if (provider !== this.#provider || model !== this.#model) {
264
+ throw new LlmError(`this route serves only ${this.#provider}/${this.#model}`, 'CLAUDE_MODEL_NOT_CONFIGURED')
265
+ }
266
+ signal?.throwIfAborted()
267
+ return Object.freeze({
268
+ provider: this.#provider, id: this.#model, name: this.#model,
269
+ description: 'Claude 订阅会话路由:仅文本,上下文窗口与输出上界未知(不伪造)。',
270
+ inputModalities: Object.freeze(['text']),
271
+ })
272
+ }
273
+
274
+ /**
275
+ * Bind this call to one immutable route generation: the exact model metadata, the funding
276
+ * the Host has committed right now, and the pinned installation reference as it stands at
277
+ * preparation time. A later Host re-pin (`replaceRoute`) refuses the captured generation
278
+ * instead of mixing one generation's route with another's entry or funding.
279
+ */
280
+ async prepareCall(provider, model, signal) {
281
+ const generation = await this.#capture(provider, model, signal)
282
+ return Object.freeze({
283
+ model: generation.resolved,
284
+ stream: options => this.#streamGeneration(generation, options),
285
+ })
286
+ }
287
+
288
+ /** The unprepared entry point the SDK's `stream()` reaches through the waterfall. */
289
+ async *stream(options) {
290
+ const prepared = await this.prepareCall(options?.provider, options?.model, options?.signal)
291
+ yield* prepared.stream(options)
292
+ }
293
+
294
+ // ---- Host-only route control ------------------------------------------------
295
+
296
+ /**
297
+ * Re-pin this registration's route: a changed committed model, funding, entry or persona.
298
+ * The swap is atomic and bumps the generation, so a call prepared against the previous
299
+ * generation is refused rather than dispatched on mixed state. Sessions bound to the old
300
+ * route are retired without creating any engine. Host-only: a request can never call this.
301
+ *
302
+ * @param {{model?:string, funding?:string, entry?:object, persona?:string|null, name?:string}} next
303
+ */
304
+ replaceRoute(next = {}) {
305
+ if (this.#closed) throw new LlmError('this registration has been released', 'REGISTRATION_DISPOSED')
306
+ if (!next || typeof next !== 'object') throw badOptions('CLAUDE_ROUTE_REPLACEMENT_REQUIRED')
307
+ const model = next.model ?? this.#model
308
+ if (typeof model !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/.test(model)) throw badOptions('CLAUDE_ROUTE_MODEL_REQUIRED')
309
+ const funding = next.funding ?? this.#funding
310
+ if (funding !== CLAUDE_LLM_FUNDING) throw badOptions(`CLAUDE_ROUTE_FUNDING_REFUSED: ${JSON.stringify(funding)}`)
311
+ const entry = next.entry === undefined ? this.#entry : Object.freeze({ ...next.entry })
312
+ if (entry !== null && (!entry.executable || !/^[a-f0-9]{64}$/.test(entry.sha256 ?? '') || typeof entry.version !== 'string')) throw badOptions('CLAUDE_ROUTE_ENTRY_INVALID')
313
+ const persona = next.persona === undefined ? this.#persona : next.persona
314
+ if (persona !== null && typeof persona !== 'string') throw badOptions('CLAUDE_ROUTE_PERSONA_REQUIRED_STRING')
315
+ this.#model = model
316
+ this.#funding = funding
317
+ this.#entry = entry
318
+ this.#persona = persona
319
+ if (typeof next.name === 'string' && next.name.trim()) this.#name = next.name.trim()
320
+ this.#generation++
321
+ // Every session was bound to the previous route facts: retire them now, and create
322
+ // nothing. A later call on a retired session is refused, never silently re-bound.
323
+ for (const session of [...this.#sessions.values()]) void this.#retire(session, 'route-replaced', false)
324
+ return Object.freeze({ provider: this.#provider, model: this.#model, generation: this.#generation })
325
+ }
326
+
327
+ /**
328
+ * Release the registration: refuse every further call, wait for an engine that is still
329
+ * being created, and stop every live native tree with real settlement. Creates nothing.
330
+ */
331
+ async invalidateBot(botId, reason = 'host-state-changed') {
332
+ const sessions = [...this.#sessions.values()].filter(session => session.botId === botId)
333
+ const creating = sessions.map(session => session.creation).filter(Boolean)
334
+ const evidence = await Promise.all(sessions.map(session => this.#retire(session, reason, false)))
335
+ await Promise.allSettled(creating)
336
+ return Object.freeze({ retired: sessions.length, evidence: sessions.map((session, index) => session.evidence ?? evidence[index]) })
337
+ }
338
+
339
+ async close() {
340
+ this.#closed = true
341
+ const creations = [...this.#creations]
342
+ this.#creations.clear()
343
+ // Wait for a creation that is still in flight before stopping anything: a tree that is
344
+ // being launched right now is exactly the one disposal must not orphan.
345
+ await Promise.allSettled(creations)
346
+ const stopped = []
347
+ for (const session of [...this.#sessions.values()]) {
348
+ if (session.engine) stopped.push(this.#retire(session, 'adapter-closed', false))
349
+ else session.retired = Object.freeze({ reason: 'adapter-closed', resumable: false, at: Date.now() })
350
+ }
351
+ this.#sessions.clear()
352
+ await Promise.allSettled(stopped)
353
+ return Object.freeze({ closed: true, sessions: stopped.length })
354
+ }
355
+
356
+ /** Observable route state: no money, no credentials, no message content. */
357
+ snapshot() {
358
+ return Object.freeze({
359
+ schemaVersion: 1, driver: 'claude', provider: this.#provider, model: this.#model,
360
+ name: this.#name, funding: this.#funding, generation: this.#generation, closed: this.#closed,
361
+ activityObserverFailures: this.#activityObserverFailures,
362
+ capabilities: CLAUDE_LLM_CAPABILITIES,
363
+ sessions: Object.freeze([...this.#sessions.values()].map(session => Object.freeze({
364
+ key: session.key, botId: session.botId, sessionId: session.sessionId,
365
+ turns: session.turns, retired: session.retired, lastSessionId: session.lastSessionId,
366
+ engineClosed: session.engine ? session.engine.closed : null,
367
+ pid: session.engine?.snapshot?.().process?.pid ?? null,
368
+ usageStatus: session.usageStatus, stopEvidence: session.evidence,
369
+ }))),
370
+ })
371
+ }
372
+
373
+ // ---- internals ---------------------------------------------------------------
374
+
375
+ /** One immutable route generation: model, funding and pinned entry as of preparation. */
376
+ async #capture(provider, model, signal) {
377
+ if (this.#closed) throw new LlmError('this registration has been released', 'REGISTRATION_DISPOSED')
378
+ if (provider !== this.#provider || model !== this.#model) {
379
+ throw new LlmError(`this route serves only ${this.#provider}/${this.#model}`, 'CLAUDE_MODEL_NOT_CONFIGURED')
380
+ }
381
+ signal?.throwIfAborted()
382
+ const funding = this.#fundingFor === null ? this.#funding : await this.#fundingFor({ provider, model })
383
+ if (funding !== CLAUDE_LLM_FUNDING) {
384
+ throw new LlmError(
385
+ `this bridge only opens an explicitly configured Claude subscription chat route; committed funding is ${JSON.stringify(funding)} and an API-funded CLI route cannot prove a strict per-call cost or output bound here`,
386
+ 'CLAUDE_ROUTE_FUNDING_REFUSED')
387
+ }
388
+ const entry = this.#entryFor === null ? this.#entry : await this.#entryFor()
389
+ if (!entry || !entry.executable || !/^[a-f0-9]{64}$/.test(entry.sha256 ?? '')) {
390
+ throw new LlmError('the Host pinned installation reference is missing or invalid', 'CLAUDE_ROUTE_ENTRY_INVALID')
391
+ }
392
+ const resolved = await this.resolveModel(provider, model, signal)
393
+ return Object.freeze({
394
+ generation: this.#generation, provider, model, funding, entry: Object.freeze({ ...entry }), resolved,
395
+ })
396
+ }
397
+
398
+ /** The stream of one captured generation. Every refusal here is a terminal chunk. */
399
+ async *#streamGeneration(generation, options) {
400
+ if (this.#closed) { yield finishChunk('error', { code: 'CLAUDE_ADAPTER_CLOSED', message: '本路由已释放。' }); return }
401
+ if (generation.generation !== this.#generation) {
402
+ yield finishChunk('error', {
403
+ code: 'CLAUDE_ROUTE_GENERATION_CHANGED',
404
+ message: '宿主在准备之后重新登记了本路由(模型/资金/入口/人格代际已变),这次调用被拒绝而不是混代投递。',
405
+ })
406
+ return
407
+ }
408
+ let gate
409
+ let bootstrap = null
410
+ let identity
411
+ try {
412
+ identity = await this.#identityFor({
413
+ provider: generation.provider, model: generation.model,
414
+ sessionId: options.sessionId ?? null, purpose: null,
415
+ })
416
+ } catch (error) { yield finishChunk('error', { code: 'CLAUDE_IDENTITY_UNRESOLVED', message: String(error?.message ?? error) }); return }
417
+ const invalid = this.#validateIdentity(identity)
418
+ if (invalid) { yield finishChunk('error', { code: invalid, message: '宿主未提供可信的已提交 Bot 身份。' }); return }
419
+
420
+ // The persona this call is measured against comes from committed Host identity state,
421
+ // so a request's own system message can only ever be compared with it — never trusted.
422
+ const persona = identity.persona ?? this.#persona ?? null
423
+ try {
424
+ const projected = this.#requestFor ? await this.#requestFor(options, identity) : null
425
+ if (projected?.request) bootstrap = projected.bootstrap ?? null
426
+ gate = assertRouteRequest(projected?.request ?? projected ?? options, { provider: generation.provider, model: generation.model, persona })
427
+ } catch (error) { yield finishChunk('error', failureOf(error)); return }
428
+
429
+ const key = sessionKeyOf(identity.botId, identity.conversationId ?? options.sessionId ?? null)
430
+ let session = this.#sessions.get(key) ?? null
431
+ let fingerprint
432
+ try {
433
+ fingerprint = this.#fingerprint(generation, identity)
434
+ } catch (error) { yield finishChunk('error', { code: 'CLAUDE_IDENTITY_UNRESOLVED', message: String(error?.message ?? error) }); return }
435
+ if (session !== null && session.fingerprint !== fingerprint) {
436
+ // A different Bot, model, persona or grant revision on one conversation key is a new
437
+ // binding, not a continuation: retire the old session and refuse this call.
438
+ await this.#retire(session, 'binding-changed', false)
439
+ yield finishChunk('error', { code: 'CLAUDE_SESSION_BINDING_CHANGED', message: '本次调用与已保留会话的 Bot/模型/人格/授权代际不一致,已退休旧会话并拒绝。' })
440
+ return
441
+ }
442
+ const newSession = (prompt = persona, state = emptyTurnState()) => ({
443
+ key, botId: identity.botId, sessionId: options.sessionId ?? null, fingerprint,
444
+ persona: prompt, state,
445
+ engine: null, creation: null, turns: 0, retired: null, lastSessionId: null,
446
+ evidence: null, usageStatus: 'unknown',
447
+ })
448
+ if (session === null) {
449
+ session = newSession()
450
+ this.#sessions.set(key, session)
451
+ } else if (session.retired) {
452
+ // A retired conversation key cannot be continued: the native session is gone and an
453
+ // unknown-outcome process is never replayed into a fresh one. The Host opens a new session.
454
+ yield finishChunk('error', {
455
+ code: 'CLAUDE_SESSION_RETIRED',
456
+ message: `本会话已退休(${session.retired.reason}):不为未知结果自动新开会话,请开启新会话。`,
457
+ })
458
+ return
459
+ }
460
+ let projection
461
+ try {
462
+ projection = projectRetainedTurn({
463
+ messages: gate.messages, provider: generation.provider, model: generation.model, state: session.state,
464
+ })
465
+ } catch (error) {
466
+ if (error.code === 'CLAUDE_SESSION_HISTORY_UNSEEN' && bootstrap && bootstrap.prefixLength >= session.state.sent.length) {
467
+ try {
468
+ if (session.creation || session.engine?.snapshot?.().turn) throw coded('CLAUDE_TURN_BUSY', '上一轮仍在处理,不能切换原生上下文。')
469
+ const state = seedTurnState(gate.messages, bootstrap.prefixLength)
470
+ projection = projectRetainedTurn({ messages: gate.messages, provider: generation.provider, model: generation.model, state })
471
+ const prompt = `${persona ?? ''}${bootstrap.context}`
472
+ if (Buffer.byteLength(prompt) > 32_000 || prompt.includes('\0')) throw coded('CLAUDE_HOST_ROUTE_CONTEXT_LIMIT', '完整文本上下文超过原生会话可接收的上限。')
473
+ if (session.engine) {
474
+ const evidence = await this.#retire(session, 'host-route-switch', false)
475
+ if (!evidence?.treeStopped || evidence.status === 'unknown' || evidence.possiblyApplied === true) throw coded('CLAUDE_HOST_ROUTE_STOP_UNCONFIRMED', '旧原生进程尚未确认停止。')
476
+ }
477
+ options.signal?.throwIfAborted()
478
+ session = newSession(prompt, state)
479
+ this.#sessions.set(key, session)
480
+ } catch (failure) { yield finishChunk('error', failureOf(failure)); return }
481
+ } else {
482
+ // Retire only a session that was actually used. A rejected first request
483
+ // leaves its key available for the Host to supply valid history.
484
+ if (['CLAUDE_SESSION_HISTORY_CHANGED', 'CLAUDE_SESSION_HISTORY_UNSEEN'].includes(error.code)
485
+ && (session.engine !== null || session.turns > 0)) {
486
+ await this.#retire(session, 'history-changed', false)
487
+ }
488
+ yield finishChunk('error', failureOf(error))
489
+ return
490
+ }
491
+ }
492
+
493
+ let engine
494
+ try {
495
+ engine = await this.#ensureEngine(session, identity, generation)
496
+ } catch (error) {
497
+ yield finishChunk('error', failureOf(error))
498
+ return
499
+ }
500
+
501
+ yield* this.#runTurn(session, engine, generation, projection, options)
502
+ }
503
+
504
+ /** Trusted identity shape check; the values themselves are Host state, never request state. */
505
+ #validateIdentity(identity) {
506
+ if (!identity || typeof identity !== 'object') return 'CLAUDE_IDENTITY_UNRESOLVED'
507
+ if (typeof identity.botId !== 'string' || !/^[A-Za-z0-9_.-]{1,128}$/.test(identity.botId)) return 'CLAUDE_IDENTITY_BOT_REQUIRED'
508
+ if (!identity.subject || typeof identity.subject !== 'object') return 'CLAUDE_IDENTITY_SUBJECT_REQUIRED'
509
+ if (!identity.authority || typeof identity.authority.assertModel !== 'function' || typeof identity.authority.describe !== 'function') return 'CLAUDE_IDENTITY_AUTHORITY_REQUIRED'
510
+ const binding = identity.binding
511
+ if (!binding || typeof binding !== 'object' || !identifier(binding.taskId) || !identifier(binding.attemptId)
512
+ || typeof binding.machineId !== 'string' || !binding.machineId || typeof binding.workspaceId !== 'string' || !binding.workspaceId
513
+ || !['local', 'remote'].includes(binding.location)) return 'CLAUDE_IDENTITY_BINDING_REQUIRED'
514
+ if (identity.persona !== undefined && identity.persona !== null && typeof identity.persona !== 'string') return 'CLAUDE_IDENTITY_PERSONA_INVALID'
515
+ if (identity.conversationId !== undefined && (typeof identity.conversationId !== 'string' || !/^[A-Za-z0-9_.:-]{1,128}$/.test(identity.conversationId))) return 'CLAUDE_IDENTITY_CONVERSATION_INVALID'
516
+ return null
517
+ }
518
+
519
+ /** The binding one conversation key may not change underneath a retained session. */
520
+ #fingerprint(generation, identity) {
521
+ const revision = identity.authority.describe(identity.subject).revision
522
+ return JSON.stringify([
523
+ generation.provider, generation.model, identity.botId,
524
+ identity.conversationId ?? null,
525
+ identity.persona ?? this.#persona ?? null, revision,
526
+ ])
527
+ }
528
+ /**
529
+ * The one engine of this session, created lazily. A creation already in flight is shared,
530
+ * so a concurrent second turn meets the engine's own single-turn fence rather than a
531
+ * second process. Disposal waits on these creations; nothing is created after close.
532
+ */
533
+ async #ensureEngine(session, identity, generation) {
534
+ if (this.#closed) throw coded('CLAUDE_ADAPTER_CLOSED', '本路由已释放。')
535
+ if (session.engine && !session.engine.closed) return session.engine
536
+ if (session.creation) return session.creation
537
+ if (session.retired) {
538
+ throw coded('CLAUDE_SESSION_RETIRED',
539
+ `本会话的原生引擎已退休(${session.retired.reason}):结果未知的进程不会自动开新会话重放,请开启新会话。`)
540
+ }
541
+ // The pinned entry is re-read at creation: a Host re-pin that did not go through
542
+ // replaceRoute still refuses this generation instead of launching a different binary.
543
+ const current = this.#entryFor === null ? this.#entry : await this.#entryFor()
544
+ if (!entryEquals(current, generation.entry)) {
545
+ throw coded('CLAUDE_ROUTE_ENTRY_CHANGED', '宿主固定的安装引用在准备之后发生变化,这次调用被拒绝。')
546
+ }
547
+ const workspace = this.#workspaceFor === null
548
+ ? this.#workspace
549
+ : await this.#workspaceFor({ botId: identity.botId, sessionId: session.sessionId })
550
+ if (typeof workspace !== 'string' || !isAbsolute(workspace)) throw coded('CLAUDE_ROUTE_WORKSPACE_REQUIRED', '宿主未提供绝对的工作区路径。')
551
+ const nativeTools = this.#toolsFor ? await this.#toolsFor(identity) : { tools: this.#tools, handlers: this.#handlers }
552
+ const config = {
553
+ authority: identity.authority,
554
+ subject: identity.subject,
555
+ binding: identity.binding,
556
+ operationId: `op-${randomBytes(8).toString('hex')}`,
557
+ entry: generation.entry,
558
+ runtimeRoot: this.#runtimeRoot,
559
+ workspace,
560
+ model: { catalogProvider: generation.provider, catalogModel: generation.model },
561
+ permissionMode: this.#permissionMode,
562
+ billing: generation.funding,
563
+ tools: nativeTools.tools,
564
+ handlers: nativeTools.handlers,
565
+ deadlineMs: this.#deadlineMs,
566
+ hooks: this.#hooks,
567
+ ...(this.#onPermissionRequest ? { onPermissionRequest: this.#onPermissionRequest } : {}),
568
+ ...(this.#provisionCredentials ? { provisionCredentials: home => this.#provisionCredentials(home, identity) } : {}),
569
+ ...(this.#launcher ? { launcher: this.#launcher } : {}),
570
+ ...(this.#python ? { python: this.#python } : {}),
571
+ // The persona travels to the engine as a Host-confirmed value. The current engine
572
+ // ignores unknown options; the engine system-prompt seam is the parent's wiring
573
+ // decision (see the bridge report's wiring.patch). It is never re-sent as turn text.
574
+ ...(session.persona ? { systemPrompt: session.persona } : {}),
575
+ }
576
+ const creation = (async () => {
577
+ const engine = await this.#engineFactory(config)
578
+ this.#engines.add(engine)
579
+ engine.events.on('session', value => { if (value?.sessionId) session.lastSessionId = value.sessionId })
580
+ engine.events.on('tree-stopped', value => { session.evidence = value })
581
+ let activeActivity = null
582
+ const observe = value => {
583
+ if (!this.#onActivity) return
584
+ try {
585
+ if (!value || !['turn-open', 'tool', 'turn-close', 'stop-confirmed', 'cancel-requested'].includes(value.kind)
586
+ || typeof value.turnId !== 'string' || typeof value.processGeneration !== 'string'
587
+ || typeof identity.conversationId !== 'string') throw new Error('CLAUDE_ACTIVITY_IDENTITY_MISSING')
588
+ const scope = Object.freeze({ botId: identity.botId, mode: identity.independent ? 'independent' : 'integration',
589
+ conversationId: identity.conversationId, contextId: identity.independent ? identity.conversationId : null,
590
+ processGeneration: value.processGeneration, turnId: value.turnId })
591
+ if (value.kind === 'turn-open') activeActivity = { turnId: value.turnId, processGeneration: value.processGeneration }
592
+ const result = this.#onActivity(Object.freeze({ kind: value.kind, identity: scope,
593
+ ...(value.kind === 'tool' ? { tool: Object.freeze({ phase: value.tool?.phase, id: value.tool?.id,
594
+ ...(value.tool?.phase === 'started' ? { name: value.tool?.name } : { ok: value.tool?.ok }) }) } : {}),
595
+ ...(value.kind === 'turn-close' ? { outcome: value.outcome } : {}) }))
596
+ if (result?.then) { result.catch?.(() => {}); throw new Error('CLAUDE_ACTIVITY_CALLBACK_MUST_BE_SYNCHRONOUS') }
597
+ } catch { this.#activityObserverFailures++ }
598
+ }
599
+ engine.events.on('activity', observe)
600
+ // Only the adapter's own current request signal asks for cancellation.
601
+ // The actual engine supplies separate supervisor-confirmed stop evidence.
602
+ session.requestActivityCancel = () => {
603
+ if (activeActivity && engine.snapshot?.().turn?.turnId === activeActivity.turnId) observe({ kind: 'cancel-requested', ...activeActivity })
604
+ }
605
+ return engine
606
+ })()
607
+ const tracked = creation.then(engine => {
608
+ this.#creations.delete(tracked)
609
+ session.creation = null
610
+ if (session.retired) {
611
+ // The session was retired while this engine was being created (a route re-pin, a
612
+ // binding change or a disposal). Stop what was just launched instead of storing an
613
+ // engine nobody may dispatch into.
614
+ this.#engines.delete(engine)
615
+ return engine.stop(`bridge-retire:${session.retired.reason}`).then(() => engine, () => engine)
616
+ }
617
+ session.engine = engine
618
+ return engine
619
+ }, error => { this.#creations.delete(tracked); session.creation = null; throw error })
620
+ this.#creations.add(tracked)
621
+ session.creation = tracked
622
+ return tracked
623
+ }
624
+
625
+ /** Stop one session's native tree and record why; the engine's own settlement is awaited. */
626
+ async #retire(session, reason, resumable) {
627
+ const engine = session.engine
628
+ session.retired = Object.freeze({ reason, resumable: resumable === true, at: Date.now() })
629
+ session.engine = null
630
+ session.creation = null
631
+ if (!engine) return session.evidence ?? null
632
+ this.#engines.delete(engine)
633
+ try {
634
+ const proof = await engine.stop(`bridge-retire:${reason}`)
635
+ session.evidence = proof ?? { status: 'unknown', treeStopped: false }
636
+ if (engine.snapshot?.().launches === 0 && proof?.status === 'no-process' && !(proof.interruptedLaunches > 0)) {
637
+ session.evidence = { ...proof, notStarted: true, possiblyApplied: false }
638
+ }
639
+ return session.evidence
640
+ } catch (error) {
641
+ session.evidence = { status: 'unknown', treeStopped: false, reason: 'native-stop-failed' }
642
+ throw error
643
+ }
644
+ }
645
+
646
+ /**
647
+ * One turn: dispatch the projected new user text, stream the native answer as SDK chunks,
648
+ * and finish exactly once — after the engine's own Host settlement has completed.
649
+ */
650
+ async *#runTurn(session, engine, generation, projection, options) {
651
+ const queue = []
652
+ const started = { text: false, reasoning: false }
653
+ const facts = { text: '', reasoning: '', toolCalls: 0, assistantText: null }
654
+ let wake = null
655
+ const wakeUp = () => { if (wake) { const resolve = wake; wake = null; resolve() } }
656
+ const push = chunk => { queue.push(chunk); wakeUp() }
657
+ const onDelta = value => {
658
+ if (value?.streamKind === 'assistant_text' && typeof value.delta === 'string') {
659
+ if (!started.text) { started.text = true; push({ type: 'block-start', index: 0, blockType: 'text' }) }
660
+ facts.text += value.delta
661
+ push({ type: 'text-delta', index: 0, text: value.delta })
662
+ return
663
+ }
664
+ if (value?.streamKind === 'reasoning_text' && typeof value.delta === 'string') {
665
+ if (!started.reasoning) { started.reasoning = true; push({ type: 'block-start', index: 1, blockType: 'reasoning' }) }
666
+ facts.reasoning += value.delta
667
+ push({ type: 'reasoning-delta', index: 1, text: value.delta })
668
+ }
669
+ }
670
+ const onAssistant = value => {
671
+ // A CLI that emits no partial messages still publishes its whole assistant message:
672
+ // use it only when no delta arrived, so text is never counted twice.
673
+ if (facts.text === '' && typeof value?.text === 'string' && value.text) facts.assistantText = value.text
674
+ }
675
+ const onTool = value => { if (value?.phase === 'started') facts.toolCalls++ }
676
+ engine.events.on('delta', onDelta)
677
+ engine.events.on('assistant', onAssistant)
678
+ engine.events.on('tool', onTool)
679
+
680
+ // The retained process may have exited while the previous turn was settling, before the
681
+ // supervisor reported it. Writing the prompt into that pipe loses the turn silently, so
682
+ // wait (bounded) for the engine to notice before dispatching. A live process, or none at
683
+ // all, dispatches immediately.
684
+ await awaitRetiredProcessNotice(engine)
685
+
686
+ let outcome = null
687
+ const submission = engine.submitTurn({ text: projection.text }).then(
688
+ result => { outcome = { result }; wakeUp() },
689
+ error => { outcome = { error }; wakeUp() },
690
+ )
691
+ submission.catch(() => {})
692
+ // A retained process that exits after its result is only noticed when the supervisor
693
+ // reports the stop, which can land after the turn promise already resolved. If the
694
+ // engine never settles this dispatched turn because of that death, the prompt is lost
695
+ // without any outcome — so watch for it and stop the tree, which settles the turn as the
696
+ // unknown outcome it really is instead of waiting for the turn timer.
697
+ const launchMark = engine.snapshot?.().launches ?? null
698
+ let processLost = false
699
+ const watcher = setInterval(() => {
700
+ const snap = engine.snapshot?.()
701
+ if (!snap || processLost) return
702
+ if (snap.process !== null || snap.launches !== launchMark) return
703
+ const turn = snap.turn
704
+ if (!turn || turn.settled === true || turn.dispatched !== true) return
705
+ processLost = true
706
+ void engine.stop('bridge-process-lost')
707
+ }, 100)
708
+ watcher.unref?.()
709
+ const onAbort = () => { if (!outcome) { session.requestActivityCancel?.(); outcome = { aborted: true }; wakeUp() } }
710
+ if (options.signal) {
711
+ if (options.signal.aborted) onAbort()
712
+ else options.signal.addEventListener('abort', onAbort, { once: true })
713
+ }
714
+
715
+ try {
716
+ while (queue.length || outcome === null) {
717
+ while (queue.length) yield queue.shift()
718
+ if (outcome !== null) break
719
+ await new Promise(resolve => { wake = resolve })
720
+ }
721
+ while (queue.length) yield queue.shift()
722
+
723
+ if (outcome.aborted === true) {
724
+ // Cancellation stops the native tree first and waits for the engine's own
725
+ // settlement, so the turn's outcome is observed — never a guessed success.
726
+ const evidence = await engine.stop('turn-cancelled')
727
+ await submission
728
+ await this.#retire(session, 'turn-cancelled', false)
729
+ yield finishChunk('aborted', {
730
+ code: 'CLAUDE_TURN_CANCELLED',
731
+ message: `本轮已取消,原生进程树已停止(${evidence?.status ?? 'unknown'})。`,
732
+ })
733
+ return
734
+ }
735
+ if (outcome.error) {
736
+ // A submit refusal (busy, stopped) is surfaced as it is; only a stopped engine
737
+ // makes the session itself unusable.
738
+ if (engine.closed) await this.#retire(session, 'engine-stopped', false)
739
+ yield finishChunk('error', failureOf(outcome.error))
740
+ return
741
+ }
742
+ const result = outcome.result
743
+ // Only a turn the engine proves it wrote to the CLI's stdin advances the session's
744
+ // delivered history: a provably undelivered turn is offered again by the next call.
745
+ if (result?.dispatched === true) session.state = projection.state
746
+ session.turns++
747
+ session.usageStatus = result?.usageStatus ?? 'unknown'
748
+ if (result?.ok !== true && (engine.closed || RETIRING_STOP_REASONS.includes(result.stopReason))) {
749
+ // The native session cannot continue after this outcome; retiring it here keeps the
750
+ // next call from silently opening a fresh session that lost the conversation.
751
+ await this.#retire(session, `native-${result.stopReason ?? 'failed'}`, false)
752
+ }
753
+ if (started.reasoning) {
754
+ yield { type: 'block-end', index: 1, block: { type: 'reasoning', text: facts.reasoning } }
755
+ }
756
+ if (!started.text && typeof facts.assistantText === 'string') {
757
+ // No partial messages from this CLI: publish the whole assistant message as one
758
+ // block instead of dropping the answer.
759
+ yield { type: 'block-start', index: 0, blockType: 'text' }
760
+ yield { type: 'text-delta', index: 0, text: facts.assistantText }
761
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: facts.assistantText } }
762
+ facts.text = facts.assistantText
763
+ } else if (started.text) {
764
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: facts.text } }
765
+ }
766
+ const usage = usageChunkFromTurn(result)
767
+ if (usage) yield { type: 'usage', usage }
768
+ const reason = finishReasonFromTurn(result, { textChars: facts.text.length })
769
+ if (processLost && reason.kind !== 'stop') {
770
+ reason.failure = {
771
+ code: 'CLAUDE_SESSION_PROCESS_LOST',
772
+ message: '保留会话的原生进程在本轮投递之后消失且没有结算:本轮结果未知,进程树已停止、会话已退休。',
773
+ }
774
+ }
775
+ yield {
776
+ type: 'finish',
777
+ reason,
778
+ replayState: { response: Object.freeze({
779
+ ...turnFacts(result), sessionId: session.lastSessionId, provider: generation.provider, model: generation.model,
780
+ toolCallsObserved: facts.toolCalls, reasoningChars: facts.reasoning.length,
781
+ provenance: 'the CLI init frame names the configured model; the protocol does not prove the response backend',
782
+ }) },
783
+ }
784
+ } finally {
785
+ clearInterval(watcher)
786
+ engine.events.off('delta', onDelta)
787
+ engine.events.off('assistant', onAssistant)
788
+ engine.events.off('tool', onTool)
789
+ if (options.signal) options.signal.removeEventListener('abort', onAbort)
790
+ }
791
+ }
792
+ }
793
+
794
+ /** Host-facing constructor. See {@link ClaudeLlmAdapter} for the trusted option set. */
795
+ export function createClaudeLlmAdapter(options) {
796
+ return new ClaudeLlmAdapter(options)
797
+ }