@ljwei-stak/dsh-model-route-bot 0.66.3 → 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 (272) 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 +20 -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/README.md +3 -3
  178. package/packages/model-router-plugin/package.json +1 -1
  179. package/packages/orchestration/src/binding-product.mjs +19 -0
  180. package/packages/orchestration/src/bot-data-actions.mjs +45 -0
  181. package/packages/orchestration/src/bot-data-chart.mjs +109 -0
  182. package/packages/orchestration/src/bot-data-context.mjs +44 -0
  183. package/packages/orchestration/src/bot-data-engine.mjs +809 -0
  184. package/packages/orchestration/src/bot-data-io.mjs +186 -0
  185. package/packages/orchestration/src/bot-data-sql.mjs +195 -0
  186. package/packages/orchestration/src/bot-data-surface.mjs +144 -0
  187. package/packages/orchestration/src/bot-data-tools.mjs +68 -0
  188. package/packages/orchestration/src/bot-data-worker.mjs +228 -0
  189. package/packages/orchestration/src/bot-independent-execution.mjs +195 -0
  190. package/packages/orchestration/src/bot-independent-stream.mjs +74 -0
  191. package/packages/orchestration/src/bot-loop.mjs +152 -19
  192. package/packages/orchestration/src/bot-notifications.mjs +159 -0
  193. package/packages/orchestration/src/bot-policy.mjs +43 -4
  194. package/packages/orchestration/src/bot-profile.mjs +171 -0
  195. package/packages/orchestration/src/bot-routine.mjs +4 -4
  196. package/packages/orchestration/src/bot-runtime-assembly.mjs +255 -8
  197. package/packages/orchestration/src/bot-runtime-switch.mjs +13 -2
  198. package/packages/orchestration/src/bot-serving.mjs +164 -0
  199. package/packages/orchestration/src/bot-session-store.mjs +105 -9
  200. package/packages/orchestration/src/bot-settings.mjs +50 -11
  201. package/packages/orchestration/src/bot-thread-admission.mjs +84 -0
  202. package/packages/orchestration/src/bot-thread-host.mjs +187 -0
  203. package/packages/orchestration/src/bot-threads.mjs +790 -0
  204. package/packages/orchestration/src/data-product-access.mjs +37 -0
  205. package/packages/orchestration/src/data-tool-contract.mjs +291 -0
  206. package/packages/orchestration/src/desktop-http-carrier.mjs +120 -0
  207. package/packages/orchestration/src/desktop-proxy.mjs +311 -0
  208. package/packages/orchestration/src/desktop-sessions.mjs +43 -9
  209. package/packages/orchestration/src/ds-lifecycle.mjs +31 -4
  210. package/packages/orchestration/src/grant-refresh-queue.mjs +17 -0
  211. package/packages/orchestration/src/independent-chat-page.mjs +39 -0
  212. package/packages/orchestration/src/independent-chat-service.mjs +308 -0
  213. package/packages/orchestration/src/independent-context-state.mjs +44 -0
  214. package/packages/orchestration/src/independent-message-wire.mjs +84 -0
  215. package/packages/orchestration/src/independent-tool-catalog.mjs +175 -0
  216. package/packages/orchestration/src/independent-tool-families.mjs +73 -0
  217. package/packages/orchestration/src/independent-tool-host.mjs +344 -0
  218. package/packages/orchestration/src/main-bot-grant.mjs +15 -0
  219. package/packages/orchestration/src/main-model-routes.mjs +208 -0
  220. package/packages/orchestration/src/model-accounting.mjs +99 -35
  221. package/packages/orchestration/src/model-auxiliary-dispatch.mjs +187 -0
  222. package/packages/orchestration/src/model-loop-frame.mjs +179 -0
  223. package/packages/orchestration/src/model-prepared-generation.mjs +175 -0
  224. package/packages/orchestration/src/model-request-bounds.mjs +111 -0
  225. package/packages/orchestration/src/native-activity-contract.mjs +491 -0
  226. package/packages/orchestration/src/native-activity-service.mjs +102 -0
  227. package/packages/orchestration/src/native-activity-store.mjs +249 -0
  228. package/packages/orchestration/src/native-conversation-tools.mjs +78 -0
  229. package/packages/orchestration/src/native-data-tools.mjs +100 -0
  230. package/packages/orchestration/src/native-remote-actions.mjs +742 -0
  231. package/packages/orchestration/src/native-team-tools.mjs +73 -0
  232. package/packages/orchestration/src/pairing-invite.mjs +37 -0
  233. package/packages/orchestration/src/product-composition.mjs +1559 -1001
  234. package/packages/orchestration/src/remote-machines.mjs +82 -14
  235. package/packages/orchestration/src/routine-execution.mjs +5 -3
  236. package/packages/orchestration/src/routine-session.mjs +19 -13
  237. package/packages/orchestration/src/secure-pairing-host.mjs +112 -0
  238. package/packages/orchestration/src/task-journal.mjs +29 -0
  239. package/packages/orchestration/src/task-product.mjs +17 -0
  240. package/packages/orchestration/src/team-assignment-store.mjs +120 -0
  241. package/packages/orchestration/src/team-bridge.mjs +282 -0
  242. package/packages/orchestration/src/team-scheduler.mjs +83 -0
  243. package/packages/orchestration/src/team-sdk-tools.mjs +84 -0
  244. package/packages/orchestration/src/team-tool-contract.mjs +284 -0
  245. package/packages/orchestration/src/ui-host-namespace.mjs +23 -0
  246. package/packages/orchestration/src/web-panel-actions.mjs +103 -0
  247. package/packages/orchestration/src/web-panel-framing.mjs +227 -0
  248. package/packages/orchestration/src/web-panel-store.mjs +206 -0
  249. package/remote-computer-agent/README.md +39 -29
  250. package/remote-computer-agent/build.mjs +3 -0
  251. package/remote-computer-agent/dist/OPENMAUSBOT_LICENSE +202 -0
  252. package/remote-computer-agent/dist/OPENMAUSBOT_NOTICE +10 -0
  253. package/remote-computer-agent/dist/OPENMAUSBOT_SOURCE.json +68 -0
  254. package/remote-computer-agent/dist/cli-stdio-bridge.mjs +215 -25
  255. package/remote-computer-agent/dist/cli-stdio-bridge.mjs.map +4 -4
  256. package/remote-computer-agent/dist/cli.mjs +1688 -168
  257. package/remote-computer-agent/dist/cli.mjs.map +4 -4
  258. package/remote-computer-agent/dist/index.mjs +1579 -122
  259. package/remote-computer-agent/dist/index.mjs.map +4 -4
  260. package/remote-computer-agent/dist/native-peer.ps1 +51 -3
  261. package/remote-computer-agent/package-lock.json +308 -2
  262. package/remote-computer-agent/package.json +2 -1
  263. package/remote-computer-agent/pair-device.ps1 +20 -0
  264. package/remote-computer-agent/setup.ps1 +38 -16
  265. package/remote-computer-agent/src/cli.ts +24 -3
  266. package/remote-computer-agent/src/desktop-session.ts +11 -3
  267. package/remote-computer-agent/src/e2e-server.ts +81 -0
  268. package/remote-computer-agent/src/pairing-page.ts +35 -0
  269. package/remote-computer-agent/src/protocol.ts +25 -25
  270. package/remote-computer-agent/src/secure-pairing.ts +105 -0
  271. package/remote-computer-agent/src/store.ts +20 -5
  272. package/remote-computer-agent/src/transport.ts +39 -15
@@ -0,0 +1,772 @@
1
+ // Claude Code conversation-session engine adapter.
2
+ //
3
+ // Ported and re-grounded from OpenMausBot server/drivers/claude.ts at pin
4
+ // 1bc4749aa8307d7d6998bf86de4ef6478460dd93 (Apache-2.0); see licenses/NOTICE.md.
5
+ // Upstream behavior kept: stream-json both ways, one retained CLI process per session,
6
+ // each turn settled by the CLI's own `result` frame, `init` confirming session id /
7
+ // model / permission mode, permission asks over a local ask/answer broker with fail-closed
8
+ // semantics, total_cost_usd treated as a running total, --resume on relaunch, and an error
9
+ // result before init on a resumed process meaning the session was refused.
10
+ //
11
+ // Target seams used (all read-only, same repository):
12
+ // - nativeProcess(helper,'supervise',…) private launcher with Windows job-object tree-stop evidence
13
+ // - privateEnvironment isolated home; protectProfile ACL
14
+ // - createCliBootstrap committed-grant MCP tool bridge
15
+ // - createAuthority grant revision/model assertions (guard interval, like codex-session-core)
16
+ //
17
+ // Policy: every dependency is trusted Host state. Caller-submitted JSON may only carry a
18
+ // message text (submitTurn) or a permission decision (respondToRequest). Authority,
19
+ // environment, executable paths, arguments and auto-approval can never arrive through
20
+ // those payloads. Usage/cost figures that the CLI does not report stay null; money flows
21
+ // only to the unique Host hooks (onSettle/onClose) — this adapter keeps no second ledger.
22
+ //
23
+ // Turn lifecycle fence (this port's hardening beyond upstream):
24
+ // - submitTurn reserves the single turn slot synchronously, before its first await:
25
+ // a same-tick second submit is refused busy with zero admissions and zero launches.
26
+ // The slot is held across admission, launch, dispatch, result and Host settlement;
27
+ // only a completed settlement (or an admission refusal, which proves nothing was
28
+ // dispatched) frees it for the next turn.
29
+ // - The public turn promise and the `turn` event complete only after the unique Host
30
+ // settlement callback (onSettle) ran. A settlement failure rejects with the native
31
+ // evidence attached, funding stays unknown, and the engine stops: no next turn, no
32
+ // fresh process pretending success, never a replay.
33
+ // - Post-admission failures settle exactly once. Failures provably before the prompt
34
+ // was written to the CLI's stdin settle `not-run`; a failed stdin write is `unknown`
35
+ // (may-dispatch). Every receipt binds its turnId and operationId; no second ledger.
36
+ // - stop() ends the native tree first, independent of any Host hook; real tree-stop
37
+ // evidence comes only from the supervisor's accounting. stop() completes only after
38
+ // settlement ran — `tree-stopped` carries the supervisor evidence before that wait —
39
+ // and a pending settlement stays observable in snapshot().
40
+ // - hooks.onObserve is an observation hook, not the ledger: its failures are counted
41
+ // and emitted (`observe-failed`), never silently swallowed, and are never themselves
42
+ // a turn or financial outcome.
43
+
44
+ import { createHash, randomBytes } from 'node:crypto'
45
+ import { spawn } from 'node:child_process'
46
+ import { EventEmitter } from 'node:events'
47
+ import { mkdir, readFile, realpath, stat, writeFile } from 'node:fs/promises'
48
+ import { isAbsolute, join } from 'node:path'
49
+ import { fileURLToPath } from 'node:url'
50
+ import { capabilityProbe, nativeProcess, privateEnvironment, protectProfile } from '../../cli-runtime/src/native-process.mjs'
51
+ import { createCliBootstrap } from '../../capability-broker/src/cli-bootstrap.mjs'
52
+ import {
53
+ brokerAnswer, classifyClaudeFrame, claudeResultError, claudeUserMessage,
54
+ sumNativeTurnResults, turnCostFromRunningTotal, validTurnText,
55
+ } from './claude-protocol.mjs'
56
+ import { brokerPipeCandidates, createPermissionBroker } from './claude-permission-broker.mjs'
57
+
58
+ const PROXY_PATH = fileURLToPath(new URL('./claude-permission-proxy.mjs', import.meta.url))
59
+ const digest = value => createHash('sha256').update(value).digest('hex')
60
+ const identifier = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value)
61
+ const DEFAULT_LAUNCHER = Object.freeze({ probe: capabilityProbe, protect: protectProfile, supervise: nativeProcess })
62
+
63
+ /** Defense in depth; --tools '' is the closed built-in tool set, including future tools. */
64
+ export const DISALLOWED_NATIVE_TOOLS = Object.freeze(['Bash', 'PowerShell', 'Read', 'Glob', 'Grep', 'Write', 'Edit', 'WebFetch', 'WebSearch', 'BashOutput', 'KillShell', 'KillBash', 'NotebookEdit', 'Task', 'Agent', 'Skill'])
65
+ export const ENGINE_PERMISSION_MODES = Object.freeze(['default', 'acceptEdits', 'auto'])
66
+
67
+ /**
68
+ * Caller supplies an installation-attested absolute native entry and immutable digest.
69
+ * `script` (optional, also absolute and digest-pinned) covers interpreter-plus-script
70
+ * installs — e.g. a node-based CLI pinned as `node … cli.js` — mirroring upstream's
71
+ * configured executable. Both halves are Host state: a turn payload can never name them.
72
+ */
73
+ export async function pinClaudeEntry({ executable, sha256, version, script, scriptSha256 }, home) {
74
+ if (!isAbsolute(executable ?? '') || !/^[a-f0-9]{64}$/.test(sha256 ?? '') || typeof version !== 'string' || !/^[0-9][A-Za-z0-9.+_-]{0,31}$/.test(version)) throw new TypeError('PINNED_ENGINE_ENTRY_REQUIRED')
75
+ if (script !== undefined && (!isAbsolute(script) || !/^[a-f0-9]{64}$/.test(scriptSha256 ?? ''))) throw new TypeError('PINNED_ENGINE_SCRIPT_REQUIRED')
76
+ const path = await realpath(executable)
77
+ if (!(await stat(path)).isFile() || (process.platform === 'win32' && !path.toLowerCase().endsWith('.exe'))) throw new Error('ENGINE_ENTRY_REQUIRED')
78
+ if (digest(await readFile(path)) !== sha256) throw new Error('ENGINE_ENTRY_CHANGED')
79
+ let scriptPath = null
80
+ if (script !== undefined) {
81
+ scriptPath = await realpath(script)
82
+ if (!(await stat(scriptPath)).isFile() || digest(await readFile(scriptPath)) !== scriptSha256) throw new Error('ENGINE_ENTRY_SCRIPT_CHANGED')
83
+ }
84
+ const reported = await new Promise((resolve, reject) => {
85
+ const child = spawn(path, scriptPath ? [scriptPath, '--version'] : ['--version'], { cwd: home, env: privateEnvironment(home), windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'] })
86
+ let output = '', error = ''
87
+ const timer = setTimeout(() => { child.kill(); reject(new Error('ENGINE_VERSION_TIMEOUT')) }, 15_000)
88
+ child.stdout.on('data', chunk => { output += chunk.toString('utf8'); if (output.length > 1024) child.kill() })
89
+ child.stderr.on('data', chunk => { error = (error + chunk.toString('utf8')).slice(-1024) })
90
+ child.once('error', rejectError => { clearTimeout(timer); reject(rejectError) })
91
+ child.once('close', code => { clearTimeout(timer); if (code === 0) resolve(output.trim()); else reject(new Error(`ENGINE_VERSION_FAILED: ${error.slice(0, 128)}`)) })
92
+ })
93
+ if (!reported.includes(version)) throw new Error(`ENGINE_VERSION_MISMATCH: expected ${version} in ${JSON.stringify(reported.slice(0, 80))}`)
94
+ return Object.freeze({ path, sha256, version, versionOutput: reported, script: scriptPath, scriptSha256: script !== undefined ? scriptSha256 : null })
95
+ }
96
+
97
+ /**
98
+ * @param {object} options — every value is trusted Host state.
99
+ * @param {object} options.authority capability-broker authority (subject grants)
100
+ * @param {object} options.subject registered authority subject
101
+ * @param {object} options.binding { taskId, attemptId, machineId, workspaceId, location }
102
+ * @param {string} options.operationId unique operation identity
103
+ * @param {{executable:string, sha256:string, version:string, script?:string, scriptSha256?:string}} options.entry pinned native entry (script: optional digest-pinned interpreter script, e.g. a node CLI's cli.js)
104
+ * @param {string} options.runtimeRoot isolated absolute runtime root
105
+ * @param {string} options.workspace absolute conversation workspace
106
+ * @param {{catalogProvider?:string, catalogModel:string}} options.model requested model identity
107
+ * @param {string} [options.permissionMode='default'] Host-chosen Claude permission mode (bypassPermissions is refused: this port never auto-approves)
108
+ * @param {string} [options.billing='subscription'] billing evidence label
109
+ * @param {string[]} [options.tools] committed-grant tool names bridged over MCP
110
+ * @param {Array<{definition:{name}, parse:Function, execute:Function}>} [options.handlers] bridge handlers for `tools`
111
+ * @param {number} [options.deadlineMs=120000] bounded lifetime for the whole session
112
+ * @param {AbortSignal} [options.signal] host cancellation
113
+ * @param {{onBeforeRequest?:Function, onObserve?:Function, onSettle?:Function, onClose?:Function}} [options.hooks] the only funding/ledger recipients
114
+ * @param {Function} [options.onPermissionRequest] host permission callback (ask) => {behavior, message?}; absent means every forwarded ask is denied
115
+ * @param {Function} [options.provisionCredentials] (home) => Promise writing credentials into the isolated home
116
+ * @param {{probe?:Function, protect?:Function, supervise?:Function}} [options.launcher] process-launcher seam (tests may substitute)
117
+ */
118
+ export async function createClaudeEngine(options) {
119
+ const {
120
+ authority, subject, binding, operationId,
121
+ entry, runtimeRoot, workspace,
122
+ model = {}, permissionMode = 'default', billing = 'subscription',
123
+ tools = [], handlers = [], systemPrompt = null,
124
+ deadlineMs = 120_000, signal,
125
+ hooks = {}, onPermissionRequest,
126
+ provisionCredentials = null,
127
+ launcher = DEFAULT_LAUNCHER, python,
128
+ } = options ?? {}
129
+ if (!authority || typeof authority.describe !== 'function' || typeof authority.assertModel !== 'function') throw new TypeError('ENGINE_AUTHORITY_REQUIRED')
130
+ if (!subject || typeof subject !== 'object') throw new TypeError('ENGINE_SUBJECT_REQUIRED')
131
+ if (!identifier(operationId)) throw new TypeError('INVALID_ENGINE_OPERATION')
132
+ if (!isAbsolute(runtimeRoot ?? '') || !isAbsolute(workspace ?? '')) throw new TypeError('INVALID_ENGINE_PATHS')
133
+ const catalogProvider = model.catalogProvider ?? 'anthropic'
134
+ const catalogModel = model.catalogModel
135
+ if (typeof catalogProvider !== 'string' || !catalogProvider || typeof catalogModel !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/.test(catalogModel)) throw new TypeError('INVALID_ENGINE_MODEL')
136
+ if (!ENGINE_PERMISSION_MODES.includes(permissionMode)) throw new TypeError(`ENGINE_PERMISSION_MODE_REFUSED: ${JSON.stringify(permissionMode)} (this port never auto-approves; bypassPermissions is not available)`)
137
+ if (!['subscription', 'api', 'unknown'].includes(billing)) throw new TypeError('INVALID_ENGINE_BILLING')
138
+ if (systemPrompt !== null && (typeof systemPrompt !== 'string' || !systemPrompt.trim() || systemPrompt.includes('\0') || Buffer.byteLength(systemPrompt) > 32_000)) throw new TypeError('INVALID_ENGINE_SYSTEM_PROMPT')
139
+ if (!Number.isSafeInteger(deadlineMs) || deadlineMs < 1000 || deadlineMs > 7_200_000) throw new TypeError('INVALID_ENGINE_DEADLINE')
140
+ for (const key of ['onBeforeRequest', 'onObserve', 'onSettle', 'onClose']) if (hooks[key] !== undefined && typeof hooks[key] !== 'function') throw new TypeError(`ENGINE_HOOK_${key.toUpperCase()}_REQUIRED_FUNCTION`)
141
+ if (onPermissionRequest !== undefined && typeof onPermissionRequest !== 'function') throw new TypeError('ENGINE_PERMISSION_CALLBACK_REQUIRED_FUNCTION')
142
+ if (provisionCredentials !== null && typeof provisionCredentials !== 'function') throw new TypeError('ENGINE_CREDENTIAL_PROVIDER_REQUIRED_FUNCTION')
143
+ if (!Array.isArray(tools) || tools.some(name => typeof name !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(name)) || new Set(tools).size !== tools.length) throw new TypeError('INVALID_ENGINE_TOOLS')
144
+ if (!Array.isArray(handlers) || handlers.some(handler => !handler?.definition?.name || typeof handler.parse !== 'function' || typeof handler.execute !== 'function')) throw new TypeError('INVALID_ENGINE_HANDLERS')
145
+ if (new Set(handlers.map(handler => handler.definition.name)).size !== handlers.length || tools.some(name => !handlers.some(handler => handler.definition.name === name))) throw new TypeError('UNKNOWN_ENGINE_BRIDGE_HANDLER')
146
+
147
+ authority.assertModel(subject, catalogProvider, catalogModel)
148
+ const { botId, revision } = authority.describe(subject)
149
+ signal?.throwIfAborted()
150
+
151
+ const cwd = await realpath(workspace)
152
+ if (!(await stat(cwd)).isDirectory()) throw new Error('INVALID_WORKSPACE')
153
+ await mkdir(runtimeRoot, { recursive: true, mode: 0o700 })
154
+ const root = await realpath(runtimeRoot)
155
+ const home = join(root, `e-${operationId}`)
156
+ await mkdir(home, { mode: 0o700 })
157
+ for (const folder of ['tmp', 'cache', 'config', 'data', 'appdata', 'local', 'b']) await mkdir(join(home, folder), { recursive: true, mode: 0o700 })
158
+ const probe = await launcher.probe({ home, python })
159
+ if (!probe?.available) throw new Error(`NATIVE_PEER_UNAVAILABLE: ${probe?.reason ?? 'unknown'}`)
160
+ await launcher.protect(probe.helper, home)
161
+ const pinned = await pinClaudeEntry(entry, home)
162
+ // Host-confirmed persona uses the CLI's own system slot. Keep its contents
163
+ // in the protected private home rather than process arguments or turn text.
164
+ const systemPromptFile = systemPrompt === null ? null : join(home, 'system-prompt.txt')
165
+ if (systemPromptFile) await writeFile(systemPromptFile, systemPrompt, { mode: 0o600, flag: 'wx' })
166
+ const grantedTools = new Set(tools)
167
+ const deadlineEnd = Date.now() + deadlineMs
168
+
169
+ const events = new EventEmitter()
170
+ const stats = { asks: 0, forwarded: 0, autoDeniedUnsupported: 0, duplicateDenied: 0, timeoutDenied: 0, hostDenied: 0, hostAllowed: 0, hostAnswered: 0 }
171
+ const frames = { init: 0, assistant: 0, results: 0, ignored: 0 }
172
+ let currentProcess = null, currentTurn = null, pendingLaunch = null, stopPromise = null, stopped = false, fatalError = null
173
+ let turnsDispatched = 0, launches = 0, resumeRefused = false, strayResults = 0, observeHookFailures = 0
174
+ let confirmedSessionId = null, initModel = null, identityMismatch = false, stopRequestedAt = null
175
+ let settleChain = Promise.resolve()
176
+ let guardTimer = null, deadlineTimer = null
177
+ let lastActivity = null
178
+ // Display observations have their own channel. They can never dispatch tools
179
+ // or alter the unique Host settlement, even when a display sink fails.
180
+ const activity = (identity, kind, facts = {}) => {
181
+ if (!identity) return
182
+ try { events.emit('activity', Object.freeze({ kind, ...identity, ...facts })) }
183
+ catch {
184
+ observeHookFailures++
185
+ try { events.emit('observe-failed', { kind: 'activity', error: 'NATIVE_ACTIVITY_OBSERVER_FAILED' }) } catch {}
186
+ }
187
+ }
188
+
189
+ const fatal = (error, reason) => {
190
+ if (stopped) return
191
+ fatalError = error
192
+ void stop(reason ?? 'engine-fatal')
193
+ }
194
+
195
+ // ---- permission broker --------------------------------------------------
196
+ const denyUnsupported = (ask, note) => {
197
+ stats.autoDeniedUnsupported++
198
+ events.emit('permission-resolved', { ask, behavior: 'deny', source: 'engine', reason: note })
199
+ return note
200
+ }
201
+ const broker = await createPermissionBroker({
202
+ pipeCandidates: brokerPipeCandidates(home),
203
+ isActive: () => !stopped && currentTurn !== null && !currentTurn.settled,
204
+ onAsk: ask => {
205
+ stats.asks++
206
+ events.emit('permission-ask', { ask })
207
+ if (ask.kind === 'question') {
208
+ if (!onPermissionRequest) { stats.hostDenied++; return }
209
+ stats.forwarded++
210
+ void Promise.resolve(onPermissionRequest(ask)).then(decision => {
211
+ if (!decision || typeof decision.behavior !== 'string') throw new TypeError('INVALID_PERMISSION_DECISION')
212
+ if (decision.behavior === 'answer') { if (typeof decision.message !== 'string') throw new TypeError('ANSWER_MESSAGE_REQUIRED') }
213
+ else if (decision.behavior !== 'deny') throw new TypeError('INVALID_PERMISSION_BEHAVIOR')
214
+ if (!broker.answer(ask.id, decision.behavior, decision.message)) stats.hostDenied++
215
+ }).catch(() => {
216
+ stats.hostDenied++
217
+ broker.answer(ask.id, 'deny', 'Host permission callback failed.')
218
+ })
219
+ return
220
+ }
221
+ // Permission asks: only committed-grant bridge tools can ever be allowed.
222
+ const bridged = /^mcp__dsh_task__([A-Za-z0-9_-]{1,64})$/.exec(ask.tool)
223
+ if (!bridged || !grantedTools.has(bridged[1])) {
224
+ // Answer directly on the wire: the broker only knows pending ids it created.
225
+ broker.answer(ask.id, 'deny', `Host: tool ${ask.tool} is not in this engine's committed grants.`)
226
+ denyUnsupported(ask, 'unsupported-tool')
227
+ return
228
+ }
229
+ if (!onPermissionRequest) { stats.hostDenied++; broker.answer(ask.id, 'deny', 'Host: no permission callback is configured.'); return }
230
+ stats.forwarded++
231
+ void Promise.resolve(onPermissionRequest(ask)).then(decision => {
232
+ if (!decision || typeof decision.behavior !== 'string') throw new TypeError('INVALID_PERMISSION_DECISION')
233
+ if (decision.behavior !== 'allow' && decision.behavior !== 'deny') throw new TypeError('INVALID_PERMISSION_BEHAVIOR')
234
+ if (broker.answer(ask.id, decision.behavior, typeof decision.message === 'string' ? decision.message : undefined)) {
235
+ if (decision.behavior === 'allow') stats.hostAllowed++; else stats.hostDenied++
236
+ } else stats.hostDenied++
237
+ }).catch(() => {
238
+ stats.hostDenied++
239
+ broker.answer(ask.id, 'deny', 'Host permission callback failed.')
240
+ })
241
+ },
242
+ onResolve: resolved => {
243
+ if (resolved.source === 'timeout') stats.timeoutDenied++
244
+ events.emit('permission-resolved', { ask: resolved, behavior: resolved.behavior, source: resolved.source })
245
+ },
246
+ })
247
+
248
+ // ---- turn settlement ----------------------------------------------------
249
+ // Settles THE GIVEN TURN (never "whatever is current"): a late callback from an
250
+ // older generation cannot settle a newer turn. The turn slot stays reserved until
251
+ // the Host settlement callback completed; the public promise and the `turn` event
252
+ // fire only afterwards. A settlement failure rejects with the native evidence
253
+ // attached and stops the engine — it is never returned as success and never
254
+ // swallowed. Exactly one settlement runs per admitted turn.
255
+ const settleTurn = (turn, native, hostCause) => {
256
+ if (!turn || turn.settled) return false
257
+ turn.settled = true
258
+ turn.toolController.abort(new Error('ENGINE_TURN_ENDED'))
259
+ turn.phase = 'settling'
260
+ clearTimeout(turn.timer)
261
+ const turnProcess = turn.process
262
+ let ok = native.ok
263
+ let stopReason = native.stopReason
264
+ let outcome
265
+ let costUsd = null
266
+ if (hostCause) {
267
+ ok = false
268
+ stopReason = hostCause.stopReason
269
+ outcome = hostCause.outcome
270
+ } else {
271
+ costUsd = turnCostFromRunningTotal(native.cost ?? null, turnProcess?.costTotal ?? null)
272
+ if (typeof native.cost === 'number' && turnProcess) turnProcess.costTotal = native.cost
273
+ outcome = ok ? (confirmedSessionId ? 'succeeded' : 'unknown') : 'failed'
274
+ }
275
+ const turnResult = {
276
+ schemaVersion: 1, turnId: turn.turnId, operationId, driver: 'claude', ok, outcome, stopReason,
277
+ sessionId: confirmedSessionId, usage: native.usage ?? null,
278
+ usageStatus: native.usage && native.usage.input !== null && native.usage.output !== null ? 'reported' : 'unknown',
279
+ costUsd, costStatus: costUsd === null ? 'unknown' : 'cli-reported-running-total-delta',
280
+ billing, turnsDispatched,
281
+ dispatched: turn.dispatched === true,
282
+ errorText: native.isError && native.errors?.length ? claudeResultError(native) : hostCause?.errorText ?? null,
283
+ stopRequestedAt: stopRequestedAt !== null ? stopRequestedAt : null,
284
+ }
285
+ turn.result = turnResult
286
+ const releaseSlot = () => { if (currentTurn === turn) currentTurn = null }
287
+ settleChain = settleChain.then(async () => {
288
+ try {
289
+ await hooks.onSettle?.(turnResult)
290
+ } catch (error) {
291
+ // The ledger settlement failed. This turn is NOT successful and the failure
292
+ // is not swallowed: the rejection carries the native evidence, funding
293
+ // stays unknown (the CLI really may have run), and the engine stops so no
294
+ // next turn or fresh process can pretend success. Nothing is replayed.
295
+ fatalError = error instanceof Error ? error : new Error(String(error))
296
+ releaseSlot()
297
+ const settleError = new Error(`ENGINE_SETTLE_HOOK_FAILED: ${fatalError.message}`)
298
+ settleError.turnResult = turnResult
299
+ settleError.fundingStatus = 'unknown'
300
+ events.emit('turn-settle-failed', { turnResult, error: fatalError })
301
+ activity(turn.activity, 'turn-close', { outcome: 'unknown' })
302
+ turn.reject(settleError)
303
+ void stop('settle-hook-failed')
304
+ return
305
+ }
306
+ releaseSlot()
307
+ turn.phase = 'settled'
308
+ activity(turn.activity, 'turn-close', { outcome: turnResult.outcome === 'not-run' ? 'refused' : turnResult.outcome })
309
+ events.emit('turn', turnResult)
310
+ turn.resolve(turnResult)
311
+ })
312
+ return true
313
+ }
314
+
315
+ const assertDispatchAuthorization = () => {
316
+ signal?.throwIfAborted()
317
+ authority.assertModel(subject, catalogProvider, catalogModel)
318
+ if (authority.describe(subject).revision !== revision) throw new Error('ENGINE_GRANT_CHANGED')
319
+ if (broker.closed) throw new Error('ENGINE_BROKER_CLOSED')
320
+ }
321
+
322
+ // ---- process launch -----------------------------------------------------
323
+ const writeMcpConfig = async bridge => {
324
+ const mcpServers = {
325
+ dsh: { type: 'stdio', command: process.execPath, args: [PROXY_PATH, broker.pipePath], env: {} },
326
+ }
327
+ if (tools.length) {
328
+ // MCP clients inherit only a small default environment. Carry this
329
+ // Host-owned compiler root explicitly so nested native pipe helpers use
330
+ // the same short storage; the CLI profile and TEMP stay conversation-owned.
331
+ const compilerRoot = privateEnvironment(home).DSH_NATIVE_COMPILER_ROOT
332
+ mcpServers.dsh_task = { type: 'stdio', command: bridge.command, args: bridge.args,
333
+ env: { HOME: home, USERPROFILE: home, TEMP: join(home, 'tmp'), TMP: join(home, 'tmp'),
334
+ ...(compilerRoot ? { DSH_NATIVE_COMPILER_ROOT: compilerRoot } : {}) } }
335
+ }
336
+ const path = join(home, `mcp-${++launches}.json`)
337
+ await writeFile(path, `${JSON.stringify({ mcpServers })}\n`, { mode: 0o600 })
338
+ return path
339
+ }
340
+
341
+ const launch = async ({ resume = false, turn = null } = {}) => {
342
+ const remaining = deadlineEnd - Date.now()
343
+ if (remaining <= 1000) throw new Error('ENGINE_DEADLINE')
344
+ const launchId = launches + 1
345
+ const contextFor = ({ op, id }) => {
346
+ const turn = currentTurn
347
+ if (!turn || !turn.admitted || turn.settled || (op === 'call' && !['dispatching', 'running'].includes(turn.phase))) {
348
+ throw new Error('ENGINE_TOOL_TURN_REQUIRED')
349
+ }
350
+ return { turnId: turn.turnId, callId: `${operationId}-${launchId}-${id}`, signal: turn.toolController.signal }
351
+ }
352
+ const bridge = await createCliBootstrap({ authority, subject, binding, tools, handlers, contextFor, home: join(home, 'b'), ttlMs: Math.min(remaining, deadlineMs), helper: probe.helper })
353
+ const mcpConfigPath = await writeMcpConfig(bridge)
354
+ const allowedTools = ['mcp__dsh', ...tools.map(name => `mcp__dsh_task__${name}`)]
355
+ const args = [
356
+ '-p', '--output-format', 'stream-json', '--input-format', 'stream-json', '--verbose',
357
+ '--include-partial-messages', '--permission-mode', permissionMode,
358
+ // allowedTools is an auto-approval list, not a tool availability whitelist.
359
+ // Empty arguments must survive the native supervisor's Windows quoting.
360
+ '--tools', '', '--setting-sources', '',
361
+ '--settings', JSON.stringify({ disableAllHooks: true }), '--disable-slash-commands',
362
+ '--strict-mcp-config', '--mcp-config', mcpConfigPath,
363
+ '--permission-prompt-tool', 'mcp__dsh__approve',
364
+ '--disallowedTools', DISALLOWED_NATIVE_TOOLS.join(','),
365
+ '--allowedTools', allowedTools.join(','),
366
+ '--model', catalogModel,
367
+ ...(systemPromptFile ? ['--append-system-prompt-file', systemPromptFile] : []),
368
+ ...(resume && confirmedSessionId ? ['--resume', confirmedSessionId] : []),
369
+ ]
370
+ if (digest(await readFile(pinned.path)) !== pinned.sha256 || (pinned.script && digest(await readFile(pinned.script)) !== pinned.scriptSha256)) { await bridge.close(); throw new Error('ENGINE_ENTRY_CHANGED') }
371
+ const env = privateEnvironment(home)
372
+ env.CLAUDE_CONFIG_DIR = join(home, '.claude')
373
+ // The broker locator is engine-constructed Host state, never caller input. The real
374
+ // CLI ignores it; the fixture reads it so it can play the permission-proxy's role.
375
+ env.DSH_CLAUDE_BROKER_PIPE = broker.pipePath
376
+ if (provisionCredentials) await provisionCredentials(home)
377
+ let supervisor
378
+ try {
379
+ supervisor = launcher.supervise(probe.helper, 'supervise', { executable: pinned.path, args: pinned.script ? [pinned.script, ...args] : args, cwd, ttlMs: remaining }, { home, env })
380
+ } catch (error) {
381
+ // The supervisor never came up: release the lease so nothing outlives the launch.
382
+ await bridge.close().catch(() => {})
383
+ throw error
384
+ }
385
+ const state = { supervisor, bridge, resume, turn, sawInit: false, dead: false, rootProcess: null, stopEvidence: null, costTotal: null, exitInfo: null,
386
+ activityGeneration: `native-${randomBytes(16).toString('hex')}` }
387
+ // Registered before the first await after spawning: a stop during launch ends THIS
388
+ // supervisor instead of orphaning a freshly spawned process tree.
389
+ pendingLaunch = state
390
+ const onControl = value => {
391
+ if (value.control === 'spawned') state.rootProcess = value
392
+ if (value.control === 'stopped') { state.stopEvidence = value; markDead(value) }
393
+ }
394
+ const markDead = stoppedControl => {
395
+ if (state.dead) return
396
+ state.dead = true
397
+ state.exitInfo = stoppedControl ? { exitCode: stoppedControl.exitCode, reason: stoppedControl.reason } : null
398
+ supervisor.events.off('control', onControl)
399
+ supervisor.events.off('message', onMessage)
400
+ const wasCurrent = currentProcess === state
401
+ if (wasCurrent) currentProcess = null
402
+ if (pendingLaunch === state) pendingLaunch = null
403
+ void bridge.close().catch(() => {})
404
+ // Without stop evidence the helper's state is unknown: never leave a live tree.
405
+ if (!stoppedControl) supervisor.end()
406
+ // Which turn may this death settle: the turn currently in flight on this
407
+ // process (a retained process dying mid-turn — possibly a later generation
408
+ // than the one that launched it), else this state's launching turn if it
409
+ // never settled (a death during launch). A former process can never settle
410
+ // a newer generation, and a settled turn is never re-settled.
411
+ let bound = null
412
+ if (wasCurrent && currentTurn && !currentTurn.settled) bound = currentTurn
413
+ else if (state.turn && !state.turn.settled) bound = state.turn
414
+ if (stopped || !bound) return
415
+ if (bound.process !== state) {
416
+ // The process died before this turn's prompt was written to its stdin:
417
+ // provably nothing was dispatched, so this is not-run — and no usage or
418
+ // cost figures are invented for a dispatch that never happened.
419
+ settleTurn(bound, { ok: false, stopReason: 'spawn-failed', cost: null }, { stopReason: 'spawn-failed', outcome: 'not-run' })
420
+ return
421
+ }
422
+ if (state.resume && !state.sawInit) {
423
+ // The resumed conversation no longer exists upstream: fail the continuation
424
+ // without ever replaying an earlier task into a fresh session.
425
+ resumeRefused = true
426
+ settleTurn(bound, { ok: false, stopReason: 'resume-refused', cost: null }, { stopReason: 'resume-refused', outcome: 'unknown' })
427
+ void stop('resume-refused')
428
+ } else {
429
+ settleTurn(bound, { ok: false, stopReason: 'exit-before-result', cost: null }, { stopReason: 'exit-before-result', outcome: 'unknown' })
430
+ }
431
+ }
432
+ const onMessage = frame => {
433
+ if (state.dead || stopped) return
434
+ let classified
435
+ try { classified = classifyClaudeFrame(frame) } catch { frames.ignored++; return }
436
+ switch (classified.type) {
437
+ case 'init': {
438
+ frames.init++
439
+ state.sawInit = true
440
+ initModel = classified.model
441
+ if (classified.sessionId) confirmedSessionId = classified.sessionId
442
+ if (classified.model !== null && classified.model !== catalogModel) {
443
+ identityMismatch = true
444
+ events.emit('session', { sessionId: confirmedSessionId, requestedModel: catalogModel, reportedModel: classified.model, verification: 'mismatch' })
445
+ if (state.turn && !state.turn.settled) settleTurn(state.turn, { ok: false, stopReason: 'model-identity-mismatch', cost: null }, { stopReason: 'model-identity-mismatch', outcome: 'failed' })
446
+ fatalError = new Error('ENGINE_MODEL_IDENTITY_MISMATCH')
447
+ void stop('model-identity-mismatch')
448
+ return
449
+ }
450
+ events.emit('session', { sessionId: confirmedSessionId, requestedModel: catalogModel, reportedModel: classified.model, permissionMode: classified.permissionMode, resumed: resume, verification: classified.model === catalogModel ? 'init-confirmed' : 'unreported' })
451
+ settleChain = settleChain.then(async () => { await hooks.onObserve?.({ kind: 'session-confirmed', sessionId: confirmedSessionId, model: classified.model, resumed: resume }) }).catch(error => {
452
+ // onObserve is an observation hook, not the ledger: money flows only
453
+ // through onSettle/onClose. Its failure is recorded and observable,
454
+ // never silently swallowed, and never a turn or financial outcome.
455
+ observeHookFailures++
456
+ events.emit('observe-failed', { kind: 'session-confirmed', error: String(error?.message ?? error) })
457
+ })
458
+ return
459
+ }
460
+ case 'assistant-delta':
461
+ events.emit('delta', { streamKind: 'assistant_text', delta: classified.delta })
462
+ return
463
+ case 'reasoning-delta':
464
+ events.emit('delta', { streamKind: 'reasoning_text', delta: classified.delta })
465
+ return
466
+ case 'assistant': {
467
+ frames.assistant++
468
+ if (classified.text) events.emit('assistant', { text: classified.text })
469
+ for (const use of classified.toolUses ?? []) {
470
+ const tool = { phase: 'started', id: use.id, name: use.name }
471
+ const observation = Object.freeze({ ...tool })
472
+ events.emit('tool', tool)
473
+ if (state === currentProcess && currentTurn?.process === state && !currentTurn.settled && ['dispatching', 'running'].includes(currentTurn.phase)) {
474
+ activity(currentTurn.activity, 'tool', { tool: observation })
475
+ }
476
+ }
477
+ return
478
+ }
479
+ case 'tool-result':
480
+ for (const result of classified.results) {
481
+ const tool = { phase: 'completed', id: result.toolUseId, ok: result.ok }
482
+ const observation = Object.freeze({ ...tool })
483
+ events.emit('tool', tool)
484
+ if (state === currentProcess && currentTurn?.process === state && !currentTurn.settled && ['dispatching', 'running'].includes(currentTurn.phase)) {
485
+ activity(currentTurn.activity, 'tool', { tool: observation })
486
+ }
487
+ }
488
+ return
489
+ case 'result': {
490
+ frames.results++
491
+ if (classified.isError && state.resume && !state.sawInit) {
492
+ resumeRefused = true
493
+ if (state.turn && !state.turn.settled) settleTurn(state.turn, { ok: false, stopReason: 'resume-refused', cost: null, isError: true, errors: classified.errors }, { stopReason: 'resume-refused', outcome: 'unknown' })
494
+ void stop('resume-refused')
495
+ return
496
+ }
497
+ // A result only settles the turn currently in flight ON THIS process; a
498
+ // late frame from a settled turn (or a dead former process) is a stray.
499
+ if (state !== currentProcess || !currentTurn || currentTurn.settled) { strayResults++; return }
500
+ settleTurn(currentTurn, sumNativeTurnResults(currentTurn.deferred ?? null, { ...classified.native, isError: classified.isError, errors: classified.errors }))
501
+ return
502
+ }
503
+ default:
504
+ frames.ignored++
505
+ }
506
+ }
507
+ supervisor.events.on('control', onControl)
508
+ supervisor.events.on('message', onMessage)
509
+ supervisor.events.once('failure', error => { state.dead || fatal(error, 'native-failure'); markDead(null) })
510
+ supervisor.events.once('closed', () => markDead(null))
511
+ try {
512
+ const spawned = await supervisor.waitControl('spawned')
513
+ state.rootProcess = spawned
514
+ await bridge.bind({ pid: spawned.pid, start: spawned.start })
515
+ } catch (error) {
516
+ if (pendingLaunch === state) pendingLaunch = null
517
+ markDead(null)
518
+ throw error
519
+ }
520
+ if (pendingLaunch === state) pendingLaunch = null
521
+ if (stopped) {
522
+ // The engine was stopped while this launch resolved; the stop already settled
523
+ // the turn. Tear the just-started tree down instead of dispatching into it.
524
+ markDead(null)
525
+ throw new Error(fatalError ? `ENGINE_STOPPED: ${fatalError.message}` : 'ENGINE_STOPPED')
526
+ }
527
+ currentProcess = state
528
+ return state
529
+ }
530
+
531
+ // ---- stop ---------------------------------------------------------------
532
+ // The native tree stops FIRST and independently of every Host hook: a slow or
533
+ // stuck settlement may delay public promises, never the tree stop. Real tree-stop
534
+ // evidence comes only from the supervisor's own accounting. Public wait contract:
535
+ // stop() completes only after the settlement chain (and then onClose) ran; a
536
+ // trusted hook that never returns leaves stop() pending — observable through
537
+ // snapshot() — but the tree above is already stopped.
538
+ const stop = (reason = 'host-stop') => {
539
+ if (stopPromise) return stopPromise
540
+ stopRequestedAt = Date.now()
541
+ stopped = true
542
+ if (guardTimer) clearInterval(guardTimer)
543
+ if (deadlineTimer) clearTimeout(deadlineTimer)
544
+ const activeTurn = currentTurn
545
+ const stoppedActivity = activeTurn?.activity ?? lastActivity
546
+ if (activeTurn && !activeTurn.settled) {
547
+ settleTurn(activeTurn, { ok: false, stopReason: reason, cost: null }, {
548
+ stopReason: reason,
549
+ outcome: signal?.aborted || reason === 'interrupt' ? 'cancelled' : activeTurn.dispatched ? 'unknown' : 'not-run',
550
+ })
551
+ }
552
+ stopPromise = (async () => {
553
+ const primary = currentProcess
554
+ const targets = []
555
+ if (primary) targets.push(primary)
556
+ // A launch still in flight owns a live supervisor that stop must end, or the
557
+ // freshly spawned tree would outlive the engine.
558
+ if (pendingLaunch && pendingLaunch !== primary) targets.push(pendingLaunch)
559
+ const interruptedLaunches = targets.length - (primary ? 1 : 0)
560
+ currentProcess = null
561
+ pendingLaunch = null
562
+ let forcedKill = false
563
+ for (const target of targets) {
564
+ if (target.dead) continue
565
+ target.supervisor.end()
566
+ const exited = await Promise.race([target.supervisor.exit.then(() => true), new Promise(resolve => { const timer = setTimeout(() => resolve(false), 8000); timer.unref() })])
567
+ if (!exited) {
568
+ forcedKill = true
569
+ target.supervisor.child.kill()
570
+ await target.supervisor.exit
571
+ }
572
+ }
573
+ const native = primary?.stopEvidence ?? null
574
+ broker.close()
575
+ for (const target of targets) await target.bridge.close().catch(() => {})
576
+ const stopEvidence = {
577
+ stopRequestedAt, stoppedAt: Date.now(), reason,
578
+ pid: primary?.rootProcess?.pid ?? native?.pid ?? null,
579
+ exitCode: native?.exitCode ?? primary?.exitInfo?.exitCode ?? null,
580
+ nativeStopReason: native?.reason ?? primary?.exitInfo?.reason ?? (primary ? 'no-stopped-control' : 'no-process'),
581
+ containment: native?.containment ?? probe.supervisor ?? null,
582
+ // Only the launcher's own accounting (Windows job object) confirms a tree stop.
583
+ treeStopped: native?.treeStopped === true,
584
+ status: native?.treeStopped === true ? 'confirmed' : primary ? 'unconfirmed' : 'no-process',
585
+ interruptedLaunches,
586
+ forcedKill,
587
+ stopRequestedIsNotConfirmation: true,
588
+ }
589
+ // Supervisor-owned tree-stop evidence is observable before the Host
590
+ // settlement wait below, so a slow settlement can never masquerade as
591
+ // "not stopped yet".
592
+ events.emit('tree-stopped', stopEvidence)
593
+ if (stopEvidence.treeStopped) activity(stoppedActivity, 'stop-confirmed')
594
+ await settleChain
595
+ await hooks.onClose?.(stopEvidence)
596
+ return stopEvidence
597
+ })()
598
+ return stopPromise
599
+ }
600
+
601
+ // ---- guards -------------------------------------------------------------
602
+ guardTimer = setInterval(() => {
603
+ try {
604
+ if (broker.closed) throw new Error('ENGINE_BROKER_CLOSED')
605
+ if (currentProcess?.bridge.closed) throw new Error('ENGINE_BRIDGE_CLOSED')
606
+ authority.assertModel(subject, catalogProvider, catalogModel)
607
+ if (authority.describe(subject).revision !== revision) throw new Error('ENGINE_GRANT_CHANGED')
608
+ } catch (error) {
609
+ if (currentTurn && !currentTurn.settled) settleTurn(currentTurn, { ok: false, stopReason: 'grant-revoked', cost: null }, { stopReason: 'grant-revoked', outcome: currentTurn.dispatched ? 'unknown' : 'not-run' })
610
+ fatal(error instanceof Error ? error : new Error(String(error)), 'grant-revoked')
611
+ }
612
+ }, 100)
613
+ guardTimer.unref()
614
+ deadlineTimer = setTimeout(() => { fatal(new Error('ENGINE_DEADLINE'), 'deadline') }, deadlineMs)
615
+ deadlineTimer.unref()
616
+ const onAbort = () => { fatal(new Error('ENGINE_CANCELLED'), 'cancelled') }
617
+ signal?.addEventListener('abort', onAbort, { once: true })
618
+ if (signal?.aborted) onAbort()
619
+
620
+ // ---- public surface ------------------------------------------------------
621
+ /** Submit one conversation turn. The payload may carry `text` and nothing else.
622
+ * The turn slot is reserved synchronously before the first await, so a second
623
+ * same-tick submit is refused busy with zero admissions and zero launches. The
624
+ * returned promise completes only after the Host settlement callback ran. */
625
+ const submitTurn = async input => {
626
+ if (stopped) throw new Error(fatalError ? `ENGINE_STOPPED: ${fatalError.message}` : 'ENGINE_STOPPED')
627
+ if (currentTurn) throw new Error('ENGINE_TURN_IN_PROGRESS')
628
+ if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(key => key !== 'text')) throw new TypeError('INVALID_TURN_INPUT: only { text } is accepted')
629
+ const text = input.text
630
+ if (!validTurnText(text)) throw new TypeError('INVALID_TURN_TEXT: 1..64000 bytes, no NUL, not blank')
631
+ const turnId = `t-${randomBytes(8).toString('hex')}`
632
+ let resolveTurn, rejectTurn
633
+ const promise = new Promise((resolve, reject) => { resolveTurn = resolve; rejectTurn = reject })
634
+ promise.catch(() => {}) // rejections carry the turn evidence; they are never unhandled
635
+ const turn = { turnId, phase: 'admitting', settled: false, admitted: false, dispatched: false, deferred: null, process: null, resolve: resolveTurn, reject: rejectTurn, timer: null, result: null,
636
+ toolController: new AbortController() }
637
+ // The turn deadline covers admission, launch, dispatch and the CLI's result.
638
+ const timer = setTimeout(() => {
639
+ settleTurn(turn, { ok: false, stopReason: 'turn-timeout', cost: null }, { stopReason: 'turn-timeout', outcome: turn.dispatched ? 'unknown' : 'not-run' })
640
+ fatal(new Error('ENGINE_TURN_TIMEOUT'), 'turn-timeout')
641
+ }, Math.max(1000, deadlineEnd - Date.now()))
642
+ timer.unref()
643
+ turn.timer = timer
644
+ // THE FENCE: the slot is taken before any await. It is freed only by a completed
645
+ // Host settlement (or an admission refusal, which proves nothing was dispatched).
646
+ currentTurn = turn
647
+ try {
648
+ // Host admission first: a refusal here proves nothing was dispatched.
649
+ await hooks.onBeforeRequest?.({ turnId, botId, ...binding, operationId, source: 'engine', driver: 'claude', requestedModel: catalogModel, entry: { path: pinned.path, sha256: pinned.sha256, version: pinned.version } })
650
+ } catch (error) {
651
+ if (currentTurn === turn) currentTurn = null
652
+ clearTimeout(turn.timer)
653
+ throw error
654
+ }
655
+ turn.admitted = true
656
+ turn.phase = 'authorized'
657
+ // From here on the admission exists: every terminal path settles it exactly once
658
+ // with real dispatch evidence. `not-run` means provably nothing was written to
659
+ // the CLI's stdin; a failed stdin write stays `unknown` (may-dispatch).
660
+ const failTurn = (stopReason, outcome, errorText) =>
661
+ settleTurn(turn, { ok: false, stopReason, cost: null }, { stopReason, outcome, ...(errorText !== undefined ? { errorText } : {}) })
662
+ if (stopped) {
663
+ // A stop while admission was pending has already settled this turn.
664
+ if (!turn.settled) failTurn('engine-stopped', 'not-run', fatalError ? String(fatalError.message) : undefined)
665
+ return promise
666
+ }
667
+ try {
668
+ assertDispatchAuthorization()
669
+ } catch (error) {
670
+ failTurn(signal?.aborted ? 'cancelled' : 'grant-revoked', signal?.aborted ? 'cancelled' : 'not-run', String(error.message))
671
+ return promise
672
+ }
673
+ let state = currentProcess
674
+ if (!state || state.dead) {
675
+ try {
676
+ state = await launch({ resume: confirmedSessionId !== null, turn })
677
+ } catch (error) {
678
+ // markDead settles spawn/process failures; this backstop covers a supervisor
679
+ // that threw before any state existed. Both are single-settle guarded.
680
+ if (!turn.settled) failTurn('launch-failed', 'not-run', String(error.message))
681
+ return promise
682
+ }
683
+ // The init frame may already have failed the model identity check and stopped
684
+ // the engine while the launch was resolving; a stop during launch has already
685
+ // settled this turn with its own cause. Never dispatch into a dead engine.
686
+ if (stopped) {
687
+ if (!turn.settled) failTurn('engine-stopped', turn.dispatched ? 'unknown' : 'not-run', fatalError ? String(fatalError.message) : undefined)
688
+ return promise
689
+ }
690
+ }
691
+ // A grant revoked between admission and dispatch must not send the prompt.
692
+ try {
693
+ assertDispatchAuthorization()
694
+ } catch (error) {
695
+ failTurn(signal?.aborted ? 'cancelled' : 'grant-revoked', signal?.aborted ? 'cancelled' : 'not-run', String(error.message))
696
+ return promise
697
+ }
698
+ turn.process = state
699
+ turn.phase = 'dispatching'
700
+ turn.activity = Object.freeze({ turnId: turn.turnId, processGeneration: state.activityGeneration })
701
+ lastActivity = turn.activity
702
+ activity(turn.activity, 'turn-open')
703
+ try {
704
+ await state.supervisor.send(claudeUserMessage(text))
705
+ } catch (error) {
706
+ // The write failed mid-flight: the message may or may not have reached the CLI.
707
+ failTurn('stdin-write-failed', 'unknown', String(error.message))
708
+ return promise
709
+ }
710
+ turnsDispatched++
711
+ turn.dispatched = true
712
+ turn.phase = 'running'
713
+ return promise
714
+ }
715
+
716
+ /** Host-side answer to a broker ask. Permission asks take allow/deny; questions take answer+message. */
717
+ const respondToRequest = (requestId, decision) => {
718
+ if (!identifier(requestId)) throw new TypeError('INVALID_REQUEST_ID')
719
+ if (!decision || typeof decision !== 'object' || Object.keys(decision).some(key => !['behavior', 'message'].includes(key))) throw new TypeError('INVALID_PERMISSION_DECISION_INPUT')
720
+ if (!['allow', 'deny', 'answer'].includes(decision.behavior)) throw new TypeError('INVALID_PERMISSION_BEHAVIOR')
721
+ if (decision.behavior === 'answer' && typeof decision.message !== 'string') throw new TypeError('ANSWER_MESSAGE_REQUIRED')
722
+ const answered = broker.answer(requestId, decision.behavior, decision.message)
723
+ if (answered) {
724
+ if (decision.behavior === 'allow') stats.hostAllowed++
725
+ else if (decision.behavior === 'deny') stats.hostDenied++
726
+ else stats.hostAnswered++
727
+ }
728
+ return answered
729
+ }
730
+
731
+ const snapshot = () => Object.freeze({
732
+ schemaVersion: 1, driver: 'claude', botId, ...binding, operationId,
733
+ entry: { path: pinned.path, sha256: pinned.sha256, version: pinned.version, script: pinned.script, scriptSha256: pinned.scriptSha256 },
734
+ identity: {
735
+ catalogProvider, catalogModel, requestedModel: catalogModel,
736
+ reportedInitModel: initModel, verification: identityMismatch ? 'mismatch' : initModel ? 'init-confirmed' : 'unreported',
737
+ mismatch: identityMismatch,
738
+ },
739
+ sessionId: confirmedSessionId,
740
+ sessionConfirmedByHandshake: confirmedSessionId !== null,
741
+ turnsDispatched, launches, resumeRefused, strayResults,
742
+ // Observable turn fence state: which turn holds the slot, how far it got, and
743
+ // whether its Host settlement callback is still pending.
744
+ turn: currentTurn ? { turnId: currentTurn.turnId, phase: currentTurn.phase, admitted: currentTurn.admitted, dispatched: currentTurn.dispatched, settled: currentTurn.settled } : null,
745
+ settlementPending: currentTurn?.settled === true,
746
+ observeHookFailures,
747
+ frames: { ...frames },
748
+ permissions: { ...stats, brokerClosed: broker.closed, pending: broker.pendingCount },
749
+ billing: { kind: billing, amountUsd: null, amountStatus: 'unknown', note: 'cost flows only to Host onSettle from CLI-reported running-total deltas; nothing is fabricated here' },
750
+ process: currentProcess ? { pid: currentProcess.rootProcess?.pid ?? null, resumed: currentProcess.resume, sawInit: currentProcess.sawInit } : null,
751
+ stopRequestedAt, stopEvidence: stopPromise ? null : undefined,
752
+ fatalError: fatalError ? String(fatalError.message) : null,
753
+ closed: stopped,
754
+ limitations: [
755
+ 'init reports the CLI-configured model; the protocol does not prove the response backend model',
756
+ 'after a relaunch, the first cost delta is measured from a null baseline (the CLI restores a running total from an earlier state, not always the latest one seen)',
757
+ 'steering and queued-turn continuation merging are not ported yet: one submitted message equals one settled turn',
758
+ ],
759
+ })
760
+
761
+ return Object.freeze({
762
+ driver: 'claude',
763
+ get sessionId() { return confirmedSessionId },
764
+ get closed() { return stopped },
765
+ events,
766
+ submitTurn,
767
+ respondToRequest,
768
+ stop,
769
+ close: stop,
770
+ snapshot,
771
+ })
772
+ }