@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,795 @@
1
+ // The generic ACP adapter the Host composition can wire.
2
+ //
3
+ // Ported and re-grounded from OpenMausBot server/drivers/acp/core.ts at pin
4
+ // 1bc4749aa8307d7d6998bf86de4ef6478460dd93 (Apache-2.0); see licenses/NOTICE.md.
5
+ // Upstream's ACP driver is embedded in a product server; this module is the
6
+ // same protocol core with every product decision pushed out to trusted Host
7
+ // state: authority, binding, entry, argv, environment, model routes, hooks.
8
+ //
9
+ // What this adapter is NOT: it is not wired into product composition. Being
10
+ // present in the tree does not make any ACP CLI a usable Bot engine, and no CLI
11
+ // is claimed to be verified here.
12
+ //
13
+ // Authority and money boundaries, matching packages/bot-engines:
14
+ // - a turn payload may carry `text` and nothing else;
15
+ // - model, tool, executable, endpoint and credential authority comes from the
16
+ // committed Host authority object, re-asserted every turn;
17
+ // - usage/cost figures the agent does not report stay null/unknown — this
18
+ // adapter keeps no second ledger and never estimates a dollar amount;
19
+ // - permission decisions come only from the Host handler against committed
20
+ // Tool/Task/operation grants;
21
+ // - a cancel request is recorded separately from a confirmed process stop.
22
+ //
23
+ // Turn-fence invariants this module enforces (see packages/bot-acp/README.md):
24
+ // - one turn slot per adapter, reserved synchronously before the first await,
25
+ // so a same-tick second submit is refused with zero extra admission, launch
26
+ // or prompt and no turn can be overwritten;
27
+ // - a turn is settled exactly once, and the durable Host settlement hook runs
28
+ // before the success is published or the slot released;
29
+ // - "nothing was dispatched" (not-run) and "it may have been dispatched"
30
+ // (unknown) are different answers and never merged;
31
+ // - `turnsDispatched` counts dispatches, never completions;
32
+ // - the session and the model identity a turn runs under cannot be changed
33
+ // while that turn is pending: a new binding means a new adapter.
34
+
35
+ import { EventEmitter } from 'node:events'
36
+ import { randomBytes } from 'node:crypto'
37
+ import { mkdir, realpath, stat } from 'node:fs/promises'
38
+ import { isAbsolute, join } from 'node:path'
39
+
40
+ import { ACP_METHODS } from './rpc-codec.mjs'
41
+ import { createChildPipe, createStdioRpc } from './stdio-rpc.mjs'
42
+ import { createAcpHost } from './acp-host.mjs'
43
+ import { createAcpSession } from './session-core.mjs'
44
+ import { createClientMethodGuard } from './permission-policy.mjs'
45
+ import { DEFAULT_LAUNCHER, acpProcessHome, createSupervisorPipe, launchAcpProcess, pinAcpEntry } from './native-launch.mjs'
46
+
47
+ export const DEFAULT_ADAPTER_LIMITS = Object.freeze({
48
+ deadlineMs: 120_000,
49
+ cancelTimeoutMs: 30_000,
50
+ stopGraceMs: 8_000,
51
+ guardIntervalMs: 100,
52
+ ttlMs: 600_000,
53
+ permissionTimeoutMs: 15 * 60_000,
54
+ })
55
+
56
+ const identifier = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value)
57
+ const BILLING_KINDS = ['subscription', 'api', 'unknown']
58
+ /** Same prompt bound the session core enforces; decided before a slot is taken. */
59
+ const MAX_TURN_BYTES = 64_000
60
+ /** Transport refusals that provably never wrote a request to the peer. */
61
+ const PROVABLY_UNSENT = ['acpNotDispatched', 'acpInvalidRequest', 'acpPendingLimit', 'acpFrameLimit', 'acpBacklogLimit']
62
+ const provablyUnsent = error => PROVABLY_UNSENT.some(marker => error?.[marker] === true)
63
+
64
+ /**
65
+ * @param {object} options every value is trusted Host state.
66
+ * @param {{describe: Function, assertModel: Function, assertTool?: Function}} options.authority
67
+ * @param {object} options.subject registered authority subject
68
+ * @param {{taskId: string, attemptId: string, machineId: string, workspaceId: string, location: 'local'|'remote'}} options.binding
69
+ * @param {string} options.operationId durable operation identity
70
+ * @param {{executable: string, sha256: string, version: string, script?: string, scriptSha256?: string}} options.entry pinned native entry
71
+ * @param {string[]} [options.spawnArgs] Host-constructed argv after the executable (e.g. ['acp'])
72
+ * @param {string[]} [options.versionArgs] Host-owned version probe args (default ['--version'])
73
+ * @param {string} options.runtimeRoot isolated absolute runtime root
74
+ * @param {string} options.workspace absolute conversation workspace (agent cwd)
75
+ * @param {{catalogProvider?: string, catalogModel: string}} [options.model]
76
+ * @param {string} [options.billing]
77
+ * @param {number} [options.deadlineMs] bounded lifetime for the whole adapter
78
+ * @param {AbortSignal} [options.signal]
79
+ * @param {{onBeforeRequest?: Function, onObserve?: Function, onSettle?: Function, onClose?: Function}} [options.hooks] the only funding/ledger recipients
80
+ * @param {Function} [options.onPermissionRequest] Host permission callback; absent denies every ask
81
+ * @param {(identity: object) => string|null} [options.operationTool] maps an ask onto a committed tool name
82
+ * @param {{fileSystem?: boolean, terminal?: boolean, roots?: string[], readTextFile?: Function, writeTextFile?: Function}} [options.clientCapabilities]
83
+ * @param {{pickMethod?: Function, onAuthFailure?: 'fail'|'continue'}} [options.auth]
84
+ * @param {string} [options.resumeSessionId] resume only when the agent advertises loadSession
85
+ * @param {{probe?: Function, protect?: Function, supervise?: Function}} [options.launcher] process-launcher seam (tests may substitute)
86
+ */
87
+ export async function createAcpAdapter(options = {}) {
88
+ const {
89
+ authority, subject, binding, operationId,
90
+ entry, spawnArgs = [], versionArgs = ['--version'],
91
+ runtimeRoot, workspace,
92
+ model = {}, billing = 'subscription',
93
+ deadlineMs = DEFAULT_ADAPTER_LIMITS.deadlineMs,
94
+ signal, hooks = {}, onPermissionRequest, operationTool = null,
95
+ clientCapabilities = {}, auth = {}, resumeSessionId = null, expectedModelId = null,
96
+ launcher = DEFAULT_LAUNCHER, python,
97
+ } = options
98
+ const limits = { ...DEFAULT_ADAPTER_LIMITS, ...(options.limits ?? {}) }
99
+
100
+ if (!authority || typeof authority.describe !== 'function' || typeof authority.assertModel !== 'function') throw new TypeError('ACP_AUTHORITY_REQUIRED')
101
+ if (!subject || typeof subject !== 'object') throw new TypeError('ACP_SUBJECT_REQUIRED')
102
+ if (!identifier(operationId)) throw new TypeError('INVALID_ACP_OPERATION')
103
+ if (!isAbsolute(runtimeRoot ?? '') || !isAbsolute(workspace ?? '')) throw new TypeError('INVALID_ACP_PATHS')
104
+ const catalogProvider = model.catalogProvider ?? 'unknown'
105
+ const catalogModel = model.catalogModel
106
+ if (typeof catalogProvider !== 'string' || !catalogProvider || typeof catalogModel !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/.test(catalogModel)) throw new TypeError('INVALID_ACP_MODEL')
107
+ if (!BILLING_KINDS.includes(billing)) throw new TypeError('INVALID_ACP_BILLING')
108
+ if (!Number.isSafeInteger(deadlineMs) || deadlineMs < 1000 || deadlineMs > 7_200_000) throw new TypeError('INVALID_ACP_DEADLINE')
109
+ for (const key of ['onBeforeRequest', 'onObserve', 'onSettle', 'onClose']) {
110
+ if (hooks[key] !== undefined && typeof hooks[key] !== 'function') throw new TypeError(`ACP_HOOK_${key.toUpperCase()}_REQUIRED_FUNCTION`)
111
+ }
112
+ if (onPermissionRequest !== undefined && typeof onPermissionRequest !== 'function') throw new TypeError('ACP_PERMISSION_CALLBACK_REQUIRED_FUNCTION')
113
+ if (!Array.isArray(spawnArgs) || spawnArgs.some(arg => typeof arg !== 'string')) throw new TypeError('INVALID_ACP_SPAWN_ARGS')
114
+
115
+ const events = new EventEmitter()
116
+ const log = detail => events.emit('log', detail)
117
+ signal?.throwIfAborted()
118
+
119
+ const cwd = await realpath(workspace)
120
+ if (!(await stat(cwd)).isDirectory()) throw new Error('INVALID_ACP_WORKSPACE')
121
+ await mkdir(runtimeRoot, { recursive: true, mode: 0o700 })
122
+ const root = await realpath(runtimeRoot)
123
+ const home = join(root, `acp-${operationId}`)
124
+ await mkdir(home, { recursive: true, mode: 0o700 })
125
+ for (const folder of ['tmp', 'cache', 'config', 'data', 'appdata', 'local']) await mkdir(join(home, folder), { recursive: true, mode: 0o700 })
126
+ const probe = await launcher.probe({ home, python })
127
+ if (!probe?.available) throw new Error(`NATIVE_PEER_UNAVAILABLE: ${probe?.reason ?? 'unknown'}`)
128
+ await launcher.protect(probe.helper, home)
129
+ const pinned = await pinAcpEntry(entry, home, { versionArgs })
130
+ const deadlineEnd = Date.now() + deadlineMs
131
+ const { botId, revision } = authority.describe(subject)
132
+
133
+ let rpc = null, host = null, session = null, pipe = null
134
+ let currentProcess = null, stopPromise = null, stopped = false, fatalError = null
135
+ let stopRequestedAt = null, launches = 0, turnsDispatched = 0, turnsAdmitted = 0, turnsSettled = 0, resumeRefused = false
136
+ let sessionId = null, reportedModel = null, modelMismatch = false
137
+ // One generation per launch: a retired process never answers for the turn
138
+ // that replaced it, and a stop never leaves a late spawn behind.
139
+ let generation = 0, launchPromise = null, retiredSpawn = null
140
+ let guardTimer = null, deadlineTimer = null, cancelTimer = null
141
+ let currentTurn = null
142
+ let stopEvidenceValue = null
143
+
144
+ const clientGuard = createClientMethodGuard({
145
+ fileSystem: clientCapabilities.fileSystem === true,
146
+ terminal: clientCapabilities.terminal === true,
147
+ roots: Array.isArray(clientCapabilities.roots) ? clientCapabilities.roots : [],
148
+ readTextFile: clientCapabilities.readTextFile ?? null,
149
+ writeTextFile: clientCapabilities.writeTextFile ?? null,
150
+ log,
151
+ })
152
+
153
+ const assertModelAuthorized = modelId => {
154
+ if (typeof modelId !== 'string' || !modelId) throw new TypeError('ACP_MODEL_REQUIRED')
155
+ // A model switch is a Host decision on committed state, never a turn input.
156
+ authority.assertModel(subject, catalogProvider, modelId)
157
+ }
158
+
159
+ const fatal = (error, reason) => {
160
+ if (stopped) return
161
+ fatalError = error
162
+ void stop(reason ?? 'adapter-fatal')
163
+ }
164
+
165
+ // ---- turn settlement ------------------------------------------------------
166
+ // Settlement is two steps in a fixed order: the durable Host settlement hook
167
+ // first, and only then the published answer and the released slot. A slow hook
168
+ // keeps the turn observably pending (`busy === true`); a hook that refuses
169
+ // refuses the success — the caller receives the original native result with
170
+ // its unknown funding evidence, never a fabricated ok — and the adapter
171
+ // retires rather than let a new request run on a turn the ledger never
172
+ // recorded. Nothing here is caught and swallowed into a success.
173
+ let settleTail = Promise.resolve()
174
+ let settleInFlight = 0
175
+ let settleDrain = null
176
+ let settleDrainRelease = null
177
+
178
+ /** Resolves once no settlement is in flight. `stop` waits on this, never on a
179
+ * settlement that is itself waiting for the stop to finish. */
180
+ const settlementIdle = () => {
181
+ if (settleInFlight === 0) return Promise.resolve()
182
+ settleDrain ??= new Promise(resolve => { settleDrainRelease = resolve })
183
+ return settleDrain
184
+ }
185
+
186
+ const releaseSettlement = () => {
187
+ settleInFlight--
188
+ if (settleInFlight === 0 && settleDrainRelease) {
189
+ const release = settleDrainRelease
190
+ settleDrainRelease = null
191
+ settleDrain = null
192
+ release()
193
+ }
194
+ }
195
+
196
+ const recordSettlement = async (turn, evidence) => {
197
+ let hookError = null
198
+ try {
199
+ await hooks.onSettle?.(evidence)
200
+ } catch (error) { hookError = error }
201
+ // The durable record is in: the slot is released and the answer published.
202
+ if (currentTurn === turn) currentTurn = null
203
+ turn.settlement = evidence
204
+ if (hookError) {
205
+ fatalError = hookError
206
+ turn.reject(Object.assign(
207
+ new Error(`ACP_SETTLEMENT_FAILED: ${hookError?.message ?? String(hookError)}`),
208
+ { evidence, cause: hookError, acpSettlementFailed: true },
209
+ ))
210
+ events.emit('turn-settlement-failed', { turnId: turn.turnId, error: String(hookError?.message ?? hookError), evidence })
211
+ return 'settle-hook-failed'
212
+ }
213
+ events.emit('turn', evidence)
214
+ turn.resolve(evidence)
215
+ return null
216
+ }
217
+
218
+ const settleTurn = (native, hostCause) => {
219
+ const turn = currentTurn
220
+ if (!turn || turn.settled) return
221
+ turn.settled = true
222
+ clearTimeout(turn.timer)
223
+ if (cancelTimer) { clearTimeout(cancelTimer); cancelTimer = null }
224
+ turnsSettled++
225
+ let ok = native.ok
226
+ let stopReason = native.stopReason
227
+ let outcome = native.outcome
228
+ if (hostCause) { ok = false; stopReason = hostCause.stopReason; outcome = hostCause.outcome }
229
+ const evidence = {
230
+ schemaVersion: 1, source: 'acp', driver: 'acp', botId, ...binding, operationId,
231
+ turnId: turn.turnId, ok, outcome, stopReason,
232
+ // Whether the prompt really reached the agent: a completion count is
233
+ // never used as dispatch evidence.
234
+ dispatched: turn.dispatched === true,
235
+ sessionId: sessionId ?? native.sessionId ?? null,
236
+ model: { catalogProvider, catalogModel, reportedModel, verification: modelMismatch ? 'mismatch' : reportedModel ? 'agent-reported' : 'unreported' },
237
+ usage: native.usage ?? null,
238
+ usageStatus: native.usage?.reported === true ? 'reported' : 'unknown',
239
+ // ACP reports no dollar figure. Nothing is estimated here; the Host ledger
240
+ // receives this evidence and remains the only place money is recorded.
241
+ costUsd: null, costStatus: 'unknown',
242
+ billing,
243
+ elapsedMs: native.elapsedMs ?? null,
244
+ cancelRequestedAt: native.cancelRequestedAt ?? null,
245
+ updates: native.updates ?? null,
246
+ text: typeof native.text === 'string' ? native.text.slice(0, 64_000) : null,
247
+ errorText: native.errorText ?? null,
248
+ entry: { path: pinned.path, sha256: pinned.sha256, version: pinned.version, script: pinned.script, scriptSha256: pinned.scriptSha256 },
249
+ }
250
+ settleInFlight++
251
+ const run = settleTail.then(() => recordSettlement(turn, evidence))
252
+ // The chain itself never rejects: one refused settlement must not cancel the
253
+ // settlements queued behind it.
254
+ settleTail = run.then(() => undefined, () => undefined)
255
+ void run.then(retireReason => {
256
+ releaseSettlement()
257
+ if (retireReason) void stop(retireReason)
258
+ }, error => {
259
+ releaseSettlement()
260
+ // A settlement that crashed still ends the turn: the caller is told, with
261
+ // the evidence that was built, instead of being left waiting on it.
262
+ turn.reject(Object.assign(new Error(`ACP_SETTLEMENT_CRASHED: ${error?.message ?? String(error)}`), { evidence }))
263
+ events.emit('turn-settlement-failed', { turnId: turn.turnId, error: String(error?.message ?? error), evidence })
264
+ void stop('settlement-crashed')
265
+ })
266
+ }
267
+
268
+ const assertDispatchAuthorization = () => {
269
+ signal?.throwIfAborted()
270
+ // Every turn re-reads the Host's current authorization for this model.
271
+ authority.assertModel(subject, catalogProvider, catalogModel)
272
+ if (authority.describe(subject).revision !== revision) throw new Error('ACP_GRANT_CHANGED')
273
+ if (stopped) throw new Error(fatalError ? `ACP_STOPPED: ${fatalError.message}` : 'ACP_STOPPED')
274
+ }
275
+
276
+ // ---- process --------------------------------------------------------------
277
+ const launch = async () => {
278
+ if (stopped) throw new Error(fatalError ? `ACP_STOPPED: ${fatalError.message}` : 'ACP_STOPPED')
279
+ const remaining = deadlineEnd - Date.now()
280
+ if (remaining <= 1000) throw new Error('ACP_DEADLINE')
281
+ const own = ++generation
282
+ let started = null
283
+ try {
284
+ started = await launchAcpProcess({
285
+ entry: pinned, args: spawnArgs, cwd, home, ttlMs: Math.min(remaining, limits.ttlMs), launcher, python, probe,
286
+ })
287
+ } catch (error) {
288
+ if (stopped) throw new Error(`ACP_STOPPED: ${fatalError?.message ?? 'stopped during launch'}`)
289
+ throw error
290
+ }
291
+ const { supervisor, rootProcess } = started
292
+ if (stopped) {
293
+ // A stop raced this launch: the process must not outlive the stop, and the
294
+ // stop evidence has to name it instead of claiming there was no process.
295
+ const retired = { supervisor, rootProcess, dead: false, stopEvidence: null, exitInfo: null }
296
+ const onStopped = value => {
297
+ retired.stopEvidence = value
298
+ retired.dead = true
299
+ retired.exitInfo = { exitCode: value.exitCode, reason: value.reason }
300
+ }
301
+ supervisor.events.on('control', onStopped)
302
+ supervisor.events.once('closed', () => { retired.dead = true })
303
+ retiredSpawn = retired
304
+ supervisor.end()
305
+ await supervisor.exit.catch(() => {})
306
+ throw new Error(`ACP_STOPPED: ${fatalError?.message ?? 'stopped during launch'}`)
307
+ }
308
+ launches++
309
+ const state = { supervisor, rootProcess, dead: false, stopEvidence: null, exitInfo: null }
310
+ const relay = createSupervisorPipe(supervisor)
311
+ // One link owns this process, so any traffic is this session's traffic; a
312
+ // message naming another session is attributed to nobody.
313
+ const connection = createStdioRpc({
314
+ pipe: relay,
315
+ signal,
316
+ route: classified => {
317
+ const named = classified?.params?.sessionId
318
+ if (typeof named === 'string') return named === sessionInstance.sessionId ? operationId : null
319
+ return operationId
320
+ },
321
+ log,
322
+ })
323
+ const hostLink = createAcpHost({ rpc: connection, key: null, source: 'acp', onUnrouted: refuseUnrouted, log })
324
+ const acpLink = hostLink.open(operationId, cwd)
325
+ const sessionInstance = createAcpSession({
326
+ host: hostLink,
327
+ link: acpLink,
328
+ cwd,
329
+ clientInfo: { name: 'dsh-model-router', version: pinned.version },
330
+ capabilities: { fileSystem: clientGuard.capabilities.fileSystem, terminal: clientGuard.capabilities.terminal, meta: clientCapabilities.meta },
331
+ auth,
332
+ onUpdate: entry => {
333
+ if (entry.kind === 'agent_message_chunk' && typeof entry.text === 'string') {
334
+ events.emit('delta', { streamKind: 'assistant_text', delta: entry.text })
335
+ } else if (entry.kind === 'agent_thought_chunk' && typeof entry.text === 'string') {
336
+ events.emit('delta', { streamKind: 'reasoning_text', delta: entry.text })
337
+ }
338
+ events.emit('update', entry)
339
+ },
340
+ onPermissionRequest: ask => {
341
+ events.emit('permission-ask', ask)
342
+ if (!onPermissionRequest) return { behavior: 'deny' }
343
+ return Promise.resolve(onPermissionRequest(ask)).then(decision => {
344
+ if (!decision || typeof decision !== 'object') throw new TypeError('ACP_PERMISSION_DECISION_INVALID')
345
+ if (decision.behavior !== 'allow' && decision.behavior !== 'deny') throw new TypeError('ACP_PERMISSION_BEHAVIOR_INVALID')
346
+ return decision
347
+ })
348
+ },
349
+ onServerRequest: classified => {
350
+ if (classified.method === ACP_METHODS.fsReadTextFile || classified.method === ACP_METHODS.fsWriteTextFile || classified.method.startsWith('terminal/')) {
351
+ return clientGuard.handle(classified)
352
+ }
353
+ return null
354
+ },
355
+ authority, subject, operationTool,
356
+ assertModelAuthorized,
357
+ limits: {
358
+ initTimeoutMs: limits.initTimeoutMs,
359
+ sessionTimeoutMs: limits.sessionTimeoutMs,
360
+ loadTimeoutMs: limits.loadTimeoutMs,
361
+ promptTimeoutMs: limits.promptTimeoutMs,
362
+ promptIdleMs: limits.promptIdleMs,
363
+ maxTurns: limits.maxTurns,
364
+ permissionTimeoutMs: limits.permissionTimeoutMs,
365
+ },
366
+ signal,
367
+ log,
368
+ })
369
+ const onControl = value => {
370
+ if (value.control === 'spawned') state.rootProcess = value
371
+ if (value.control === 'stopped') { state.stopEvidence = value; markDead(value) }
372
+ }
373
+ const markDead = stoppedControl => {
374
+ if (state.dead) return
375
+ state.dead = true
376
+ state.exitInfo = stoppedControl ? { exitCode: stoppedControl.exitCode, reason: stoppedControl.reason } : null
377
+ supervisor.events.off('control', onControl)
378
+ supervisor.events.off('message', onMessage)
379
+ const live = currentProcess === state
380
+ if (live) currentProcess = null
381
+ if (sessionInstance && !sessionInstance.closed) sessionInstance.close('agent-exit')
382
+ // Only the live generation settles a turn: a late exit, error or response
383
+ // from a process the adapter already retired never answers for the turn
384
+ // that replaced it.
385
+ if (live && !stopped && currentTurn && !currentTurn.settled && currentTurn.generation === own) {
386
+ if (resumeRefused) settleTurn({ ok: false, stopReason: 'resume-refused', outcome: 'unknown', usage: null, elapsedMs: null, cancelRequestedAt: null }, { stopReason: 'resume-refused', outcome: 'unknown' })
387
+ else settleTurn({ ok: false, stopReason: 'exit-before-result', outcome: 'unknown', usage: null, elapsedMs: null, cancelRequestedAt: null }, { stopReason: 'exit-before-result', outcome: 'unknown' })
388
+ }
389
+ }
390
+ const onMessage = frame => {
391
+ // A retired process reports nothing: its late traffic is not evidence
392
+ // about the session that replaced it.
393
+ if (currentProcess !== state) return
394
+ // Session-level model confirmation: the agent names the model it runs.
395
+ let reported = null
396
+ if (frame?.method === ACP_METHODS.sessionUpdate) {
397
+ const update = frame.params?.update ?? {}
398
+ if (update.sessionUpdate === 'current_mode_update' && typeof update.currentModeId === 'string') reported = update.currentModeId
399
+ }
400
+ if (frame?.id !== undefined && frame.method === undefined) {
401
+ const params = frame.params ?? {}
402
+ if (typeof params.modelId === 'string') reported = params.modelId
403
+ if (typeof params.currentModelId === 'string') reported = params.currentModelId
404
+ }
405
+ if (typeof reported === 'string' && reported) {
406
+ reportedModel = reported
407
+ // Only a Host-declared expectation can make a report a mismatch.
408
+ if (typeof expectedModelId === 'string' && expectedModelId && reported !== expectedModelId) modelMismatch = true
409
+ }
410
+ }
411
+ supervisor.events.on('control', onControl)
412
+ supervisor.events.on('message', onMessage)
413
+ supervisor.events.once('failure', error => { if (!state.dead) fatal(error, 'native-failure'); markDead(null) })
414
+ supervisor.events.once('closed', () => markDead(null))
415
+ rpc = connection
416
+ host = hostLink
417
+ session = sessionInstance
418
+ pipe = relay
419
+ currentProcess = state
420
+ return state
421
+ }
422
+
423
+ /** Fail-closed answer for a request no session holds: never silence. */
424
+ const refuseUnrouted = (msg, send) => {
425
+ if (typeof msg?.id === 'number' || typeof msg?.id === 'string') {
426
+ send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'method not found' } })
427
+ }
428
+ }
429
+
430
+ const launchOnce = () => {
431
+ if (!launchPromise) {
432
+ launchPromise = launch()
433
+ // A finished launch clears the slot: the next session starts a new one.
434
+ launchPromise.then(() => { launchPromise = null }, () => { launchPromise = null })
435
+ }
436
+ return launchPromise
437
+ }
438
+
439
+ const ensureSession = async () => {
440
+ if (!session || session.closed) await launchOnce()
441
+ return session
442
+ }
443
+
444
+ // ---- stop -----------------------------------------------------------------
445
+ /**
446
+ * Real process stop plus evidence. A pending turn is settled first — before
447
+ * the tree is touched — with the honest split between "nothing was dispatched"
448
+ * (not-run) and "it may have been dispatched" (unknown). The native tree is
449
+ * then actually stopped, and only afterwards is a slow settlement hook waited
450
+ * on, so `onClose` never reports a stop whose turn the ledger has not recorded.
451
+ * A launch that raced this stop is awaited first: no late spawn outlives it.
452
+ */
453
+ const stop = (reason = 'host-stop') => {
454
+ if (stopPromise) return stopPromise
455
+ stopRequestedAt = Date.now()
456
+ stopped = true
457
+ if (guardTimer) clearInterval(guardTimer)
458
+ if (deadlineTimer) clearTimeout(deadlineTimer)
459
+ if (cancelTimer) { clearTimeout(cancelTimer); cancelTimer = null }
460
+ if (currentTurn && !currentTurn.settled) {
461
+ const turn = currentTurn
462
+ const cancelled = reason === 'interrupt' || reason === 'cancel-timeout' || signal?.aborted === true
463
+ const outcome = turn.dispatched ? (cancelled ? 'cancelled' : 'unknown') : 'not-run'
464
+ settleTurn({ ok: false, stopReason: reason, outcome, usage: null, elapsedMs: Date.now() - turn.startedAt, cancelRequestedAt: turn.cancelRequestedAt }, { stopReason: reason, outcome })
465
+ }
466
+ stopPromise = (async () => {
467
+ if (launchPromise) { try { await launchPromise } catch {} }
468
+ // A spawn that raced this stop is reported as the process the stop ended.
469
+ const state = currentProcess ?? retiredSpawn
470
+ retiredSpawn = null
471
+ currentProcess = null
472
+ let forcedKill = false
473
+ if (state && !state.dead) {
474
+ state.supervisor.end()
475
+ const exited = await Promise.race([
476
+ state.supervisor.exit.then(() => true),
477
+ new Promise(resolve => { const timer = setTimeout(() => resolve(false), limits.stopGraceMs); timer.unref() }),
478
+ ])
479
+ if (!exited) {
480
+ forcedKill = true
481
+ state.supervisor.child.kill()
482
+ await state.supervisor.exit
483
+ }
484
+ }
485
+ const native = state?.stopEvidence ?? null
486
+ session?.close(reason)
487
+ host?.close(reason)
488
+ // The tree is really stopped; now the durable turn records are waited for.
489
+ await settlementIdle()
490
+ const stopEvidence = {
491
+ stopRequestedAt, stoppedAt: Date.now(), reason,
492
+ pid: state?.rootProcess?.pid ?? native?.pid ?? null,
493
+ exitCode: native?.exitCode ?? state?.exitInfo?.exitCode ?? null,
494
+ nativeStopReason: native?.reason ?? state?.exitInfo?.reason ?? (state ? 'no-stopped-control' : 'no-process'),
495
+ containment: native?.containment ?? probe.supervisor ?? null,
496
+ // Only the launcher's own accounting (Windows job object) confirms a tree stop.
497
+ treeStopped: native?.treeStopped === true,
498
+ status: native?.treeStopped === true ? 'confirmed' : state ? 'unconfirmed' : 'no-process',
499
+ forcedKill,
500
+ // A cancel request was sent; this record is the confirmation, kept apart from it.
501
+ stopRequestedIsNotConfirmation: true,
502
+ cancelRequestedAt: session?.stats?.cancelRequestedAt ?? null,
503
+ turns: { admitted: turnsAdmitted, dispatched: turnsDispatched, settled: turnsSettled },
504
+ }
505
+ await hooks.onClose?.(stopEvidence)
506
+ stopEvidenceValue = stopEvidence
507
+ return stopEvidence
508
+ })()
509
+ return stopPromise
510
+ }
511
+ // ---- guards ---------------------------------------------------------------
512
+ guardTimer = setInterval(() => {
513
+ try {
514
+ if (stopped) return
515
+ authority.assertModel(subject, catalogProvider, catalogModel)
516
+ if (authority.describe(subject).revision !== revision) throw new Error('ACP_GRANT_CHANGED')
517
+ } catch (error) {
518
+ if (currentTurn && !currentTurn.settled) {
519
+ settleTurn({ ok: false, stopReason: 'grant-revoked', outcome: 'unknown', usage: null, elapsedMs: null, cancelRequestedAt: null }, { stopReason: 'grant-revoked', outcome: 'unknown' })
520
+ }
521
+ fatal(error instanceof Error ? error : new Error(String(error)), 'grant-revoked')
522
+ }
523
+ }, limits.guardIntervalMs)
524
+ guardTimer.unref()
525
+ deadlineTimer = setTimeout(() => fatal(new Error('ACP_DEADLINE'), 'deadline'), deadlineMs)
526
+ deadlineTimer.unref()
527
+ const onAbort = () => fatal(new Error('ACP_CANCELLED'), 'cancelled')
528
+ signal?.addEventListener('abort', onAbort, { once: true })
529
+ if (signal?.aborted) onAbort()
530
+
531
+ // ---- public surface -------------------------------------------------------
532
+ /**
533
+ * Start (or resume) the agent session. Resume is only attempted when the
534
+ * agent advertises loadSession; a refusal is reported and never replayed —
535
+ * the Host must call startFresh() for a new conversation on purpose.
536
+ * A pending turn fences this: the session a turn runs under is not replaced
537
+ * underneath it.
538
+ */
539
+ const assertNoPendingTurn = action => {
540
+ if (currentTurn) throw new Error(`ACP_TURN_IN_PROGRESS: ${action} would change the session or model identity while a turn is pending`)
541
+ }
542
+
543
+ const beginSession = async ({ fresh = false } = {}) => {
544
+ if (stopped) throw new Error(fatalError ? `ACP_STOPPED: ${fatalError.message}` : 'ACP_STOPPED')
545
+ if (resumeRefused && !fresh) {
546
+ throw new Error('ACP_RESUME_REFUSED: the agent refused the resume; call startFresh() for a new conversation, never a replay')
547
+ }
548
+ const live = await ensureSession()
549
+ const opened = await live.openSession({ resumeSessionId: fresh ? null : resumeSessionId })
550
+ if (!opened.ok) {
551
+ resumeRefused = true
552
+ sessionId = null
553
+ events.emit('session', { sessionId: null, resumed: false, refused: true, reason: opened.reason, stopReason: opened.stopReason })
554
+ return opened
555
+ }
556
+ resumeRefused = false
557
+ sessionId = opened.sessionId
558
+ events.emit('session', { sessionId: sessionId, resumed: opened.resumed === true, refused: false })
559
+ // Diagnostic only: this reports that a session exists. It is never a
560
+ // settlement and never carries money — settlement is `onSettle` alone.
561
+ await hooks.onObserve?.({ kind: 'session-confirmed', sessionId, model: catalogModel, resumed: opened.resumed === true, agent: live.agentCapabilities?.agentName ?? null })
562
+ return opened
563
+ }
564
+
565
+ const start = async ({ fresh = false } = {}) => {
566
+ assertNoPendingTurn('start')
567
+ return beginSession({ fresh })
568
+ }
569
+
570
+ /** A new conversation, explicitly: never a silent substitute for a refused resume. */
571
+ const startFresh = async () => {
572
+ assertNoPendingTurn('startFresh')
573
+ return beginSession({ fresh: true })
574
+ }
575
+
576
+ /**
577
+ * Submit one conversation turn. The payload may carry `text` and nothing else.
578
+ *
579
+ * The turn slot is reserved synchronously, before the first await, so a second
580
+ * call in the same tick is refused with `ACP_TURN_IN_PROGRESS` and performs no
581
+ * admission, no launch and no prompt. Everything that follows the reservation
582
+ * — admission, session start, dispatch, the peer's result and the Host
583
+ * settlement — belongs to that one slot, and the Host grant is re-judged after
584
+ * every asynchronous boundary.
585
+ */
586
+ const submitTurn = async input => {
587
+ if (stopped) throw new Error(fatalError ? `ACP_STOPPED: ${fatalError.message}` : 'ACP_STOPPED')
588
+ // The payload shape is decided before anything is reserved: a malformed turn
589
+ // never occupies the slot.
590
+ if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(key => key !== 'text')) {
591
+ throw new TypeError('INVALID_ACP_TURN_INPUT: only { text } is accepted')
592
+ }
593
+ const promptText = input.text
594
+ if (typeof promptText !== 'string' || !promptText.trim() || promptText.includes('\u0000') || Buffer.byteLength(promptText) > MAX_TURN_BYTES) {
595
+ // Same code the session core uses, so a caller sees one stable error
596
+ // whichever layer catches the malformed text first.
597
+ throw new TypeError('ACP_TURN_TEXT_INVALID')
598
+ }
599
+ if (currentTurn) throw new Error('ACP_TURN_IN_PROGRESS')
600
+ const turnId = `t-${randomBytes(8).toString('hex')}`
601
+ let resolveTurn, rejectTurn
602
+ const promise = new Promise((resolve, reject) => { resolveTurn = resolve; rejectTurn = reject })
603
+ const timer = setTimeout(() => {
604
+ settleTurn({ ok: false, stopReason: 'turn-timeout', outcome: 'unknown', usage: null, elapsedMs: null, cancelRequestedAt: null }, { stopReason: 'turn-timeout', outcome: 'unknown' })
605
+ fatal(new Error('ACP_TURN_TIMEOUT'), 'turn-timeout')
606
+ }, Math.max(1000, deadlineEnd - Date.now()))
607
+ timer.unref()
608
+ const turn = {
609
+ turnId, settled: false, dispatched: false, generation: 0,
610
+ resolve: resolveTurn, reject: rejectTurn, timer, startedAt: Date.now(), cancelRequestedAt: null,
611
+ }
612
+ currentTurn = turn
613
+ promise.catch(() => {})
614
+ events.emit('turn-started', { turnId, sessionId })
615
+ // A turn that was settled underneath us (cancel, stop, revocation, peer exit)
616
+ // is never resumed: no late continuation dispatches a new prompt for it.
617
+ const assertTurnLive = () => {
618
+ if (turn.settled) {
619
+ throw new Error(`ACP_TURN_NOT_DISPATCHED: the turn was settled as ${turn.settlement?.stopReason ?? 'settled'} before dispatch`)
620
+ }
621
+ assertDispatchAuthorization()
622
+ }
623
+ try {
624
+ // Host admission first: a refusal here proves nothing was dispatched.
625
+ await hooks.onBeforeRequest?.({ turnId, botId, ...binding, operationId, source: 'acp', driver: 'acp', requestedModel: catalogModel, entry: { path: pinned.path, sha256: pinned.sha256, version: pinned.version } })
626
+ } catch (error) {
627
+ // The Host refused this request. The slot is released so the caller may
628
+ // call again, and the Host's own error is reported. Nothing was dispatched,
629
+ // so there is no settlement to record for it.
630
+ clearTimeout(turn.timer)
631
+ if (currentTurn === turn) currentTurn = null
632
+ events.emit('turn-refused', { turnId, reason: error?.message ?? String(error) })
633
+ throw error
634
+ }
635
+ turnsAdmitted++
636
+ try {
637
+ // The grant is re-judged across the admission boundary.
638
+ assertTurnLive()
639
+ // The session this turn runs under. Starting one may launch a new process,
640
+ // so the live session is read back afterwards.
641
+ if (!session || session.closed || session.sessionId === null) await beginSession({ fresh: false })
642
+ const live = session
643
+ if (!live || typeof live.prompt !== 'function') {
644
+ throw Object.assign(new Error('ACP_SESSION_UNAVAILABLE'), { acpNotDispatched: true })
645
+ }
646
+ assertTurnLive()
647
+ const result = await live.prompt(input, {
648
+ // Marked at the moment the request is issued, never at its completion.
649
+ onDispatched: () => { turn.dispatched = true; turn.generation = generation; turnsDispatched++ },
650
+ })
651
+ if (turn.settled) return promise
652
+ // A transport failure on a prompt that was issued is unknown: the frame may
653
+ // have reached the agent, so it is never reported as not-run and never
654
+ // replayed automatically.
655
+ if (result?.ok === false && result.outcome === 'failed' && result.stopReason === 'rpc-error') {
656
+ settleTurn({ ...result, stopReason: 'dispatch-unknown', outcome: 'unknown' }, { stopReason: 'dispatch-unknown', outcome: 'unknown' })
657
+ } else {
658
+ settleTurn(result)
659
+ }
660
+ } catch (error) {
661
+ if (currentTurn !== turn || turn.settled) throw error
662
+ // One terminal settlement. The dispatch marker decides: a prompt that was
663
+ // never issued is not-run, a prompt whose write provably never left is
664
+ // not-run too, and anything else is unknown — never replayed.
665
+ const notRun = turn.dispatched !== true || provablyUnsent(error)
666
+ const stopReason = notRun ? 'not-dispatched' : 'dispatch-unknown'
667
+ const outcome = notRun ? 'not-run' : 'unknown'
668
+ settleTurn({
669
+ ok: false, stopReason, outcome, usage: null,
670
+ elapsedMs: Date.now() - turn.startedAt, cancelRequestedAt: turn.cancelRequestedAt,
671
+ errorText: error?.message ?? String(error),
672
+ }, { stopReason, outcome })
673
+ throw error
674
+ }
675
+ return promise
676
+ }
677
+
678
+ /**
679
+ * Ask the agent to stop the running turn. This records a cancel request; the
680
+ * turn settles on the agent's own answer, and an unanswered cancel ends in a
681
+ * real process stop whose evidence is reported separately.
682
+ *
683
+ * It works at every stage of a turn: while admission or a launch is still
684
+ * pending there is no prompt to cancel, so the reservation itself is settled
685
+ * as not-run — no prompt is ever sent for a cancelled turn.
686
+ */
687
+ const cancel = () => {
688
+ const turn = currentTurn
689
+ if (!turn || turn.settled) return false
690
+ if (!turn.dispatched) {
691
+ turn.cancelRequestedAt = Date.now()
692
+ settleTurn({
693
+ ok: false, stopReason: 'cancelled-before-dispatch', outcome: 'not-run', usage: null,
694
+ elapsedMs: Date.now() - turn.startedAt, cancelRequestedAt: turn.cancelRequestedAt,
695
+ }, { stopReason: 'cancelled-before-dispatch', outcome: 'not-run' })
696
+ return true
697
+ }
698
+ if (!session || !session.busy) return false
699
+ const requested = session.cancel()
700
+ if (!requested) return false
701
+ turn.cancelRequestedAt = session.stats.cancelRequestedAt
702
+ events.emit('cancel-requested', { sessionId, at: session.stats.cancelRequestedAt })
703
+ if (cancelTimer) clearTimeout(cancelTimer)
704
+ cancelTimer = setTimeout(() => { void stop('cancel-timeout') }, limits.cancelTimeoutMs)
705
+ cancelTimer.unref()
706
+ return true
707
+ }
708
+
709
+ /**
710
+ * Switch the session model. The model a turn runs under is the Host-committed
711
+ * `catalogModel`: a peer advertising another model is not authority, and
712
+ * selecting one anyway would make the turn evidence and the funded grant
713
+ * disagree. A different binding needs a rebuilt, re-authorized adapter — never
714
+ * a silent live switch, and never owner or permission data smuggled in through
715
+ * request JSON.
716
+ */
717
+ const selectModel = async modelId => {
718
+ assertNoPendingTurn('selectModel')
719
+ if (typeof modelId !== 'string' || modelId !== catalogModel) {
720
+ throw new Error(`ACP_MODEL_BINDING_CHANGED: this adapter is committed to ${catalogModel}; a different model binding requires a rebuilt, re-authorized adapter`)
721
+ }
722
+ const live = await ensureSession()
723
+ // Re-judged across the launch boundary.
724
+ assertNoPendingTurn('selectModel')
725
+ assertModelAuthorized(modelId)
726
+ const selected = await live.selectModel(modelId)
727
+ reportedModel = modelId
728
+ events.emit('model-selected', { modelId, sessionId: live.sessionId })
729
+ return selected
730
+ }
731
+
732
+ const setMode = async modeId => {
733
+ assertNoPendingTurn('setMode')
734
+ const live = await ensureSession()
735
+ // Re-judged across the launch boundary.
736
+ assertNoPendingTurn('setMode')
737
+ return live.setMode(modeId)
738
+ }
739
+
740
+ const snapshot = () => Object.freeze({
741
+ schemaVersion: 1, source: 'acp', driver: 'acp', botId, ...binding, operationId,
742
+ entry: { path: pinned.path, sha256: pinned.sha256, version: pinned.version, versionOutput: pinned.versionOutput, script: pinned.script, scriptSha256: pinned.scriptSha256 },
743
+ spawnArgs: [...spawnArgs],
744
+ model: { catalogProvider, catalogModel, reportedModel, verification: modelMismatch ? 'mismatch' : reportedModel ? 'agent-reported' : 'unreported', mismatch: modelMismatch },
745
+ identity: { botId, operationId, taskId: binding.taskId, attemptId: binding.attemptId },
746
+ sessionId,
747
+ resumeRefused,
748
+ turnsAdmitted, turnsDispatched, turnsSettled, launches,
749
+ turn: currentTurn ? {
750
+ turnId: currentTurn.turnId, dispatched: currentTurn.dispatched, settled: currentTurn.settled,
751
+ cancelRequestedAt: currentTurn.cancelRequestedAt, elapsedMs: Date.now() - currentTurn.startedAt,
752
+ } : null,
753
+ settlement: { inFlight: settleInFlight },
754
+ generation,
755
+ session: session ? session.stats : null,
756
+ process: currentProcess ? { pid: currentProcess.rootProcess?.pid ?? null, dead: currentProcess.dead } : null,
757
+ permissions: session ? session.permissions.stats : null,
758
+ transport: { rpc: rpc?.stats ?? null, pipe: pipe?.stats ?? null },
759
+ clientCapabilities: clientGuard.capabilities,
760
+ billing: { kind: billing, amountUsd: null, amountStatus: 'unknown', note: 'ACP reports no dollar figure; cost evidence flows only to the Host ledger and is never estimated here' },
761
+ stopRequestedAt,
762
+ stopEvidence: stopEvidenceValue,
763
+ fatalError: fatalError ? String(fatalError.message) : null,
764
+ closed: stopped,
765
+ limitations: [
766
+ 'not wired into product composition: presence in the tree is not product availability',
767
+ 'no ACP CLI is verified end-to-end here; entry pinning is proven against offline fixtures only',
768
+ 'session-scoped "allow always" approvals are deliberately not implemented: every ask is decided by the Host',
769
+ 'ACP reports token/context usage and no dollar amount, so cost stays null/unknown',
770
+ 'a live model switch away from the committed catalogModel is refused: a different binding needs a rebuilt, re-authorized adapter',
771
+ 'this adapter proves no OS sandbox for a generic ACP CLI: the boundary here is the session/turn fence, not process isolation',
772
+ ],
773
+ })
774
+
775
+ return Object.freeze({
776
+ driver: 'acp',
777
+ events,
778
+ start,
779
+ startFresh,
780
+ submitTurn,
781
+ cancel,
782
+ selectModel,
783
+ setMode,
784
+ stop,
785
+ close: stop,
786
+ snapshot,
787
+ get sessionId() { return sessionId },
788
+ get busy() { return currentTurn !== null },
789
+ get closed() { return stopped },
790
+ get agentCapabilities() { return session?.agentCapabilities ?? null },
791
+ get permissions() { return session?.permissions ?? null },
792
+ })
793
+ }
794
+
795
+ export { acpProcessHome, createChildPipe, createStdioRpc, createAcpHost, createAcpSession, createSupervisorPipe, pinAcpEntry, launchAcpProcess, DEFAULT_LAUNCHER }