@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,742 @@
1
+ /**
2
+ * The durable Host service behind the private MCP browser and desktop tool families.
3
+ *
4
+ * One Bot conversation reaches a paired remote computer through this module and nothing else:
5
+ *
6
+ * ```
7
+ * ctx.provide('botNativeRemoteActions', openNativeRemoteActions({
8
+ * Database, file, directory, authority, machines: () => machines, now,
9
+ * }))
10
+ * const view = actions.forConversation(botId, conversationId)
11
+ * view.schemas() // what this conversation may see now
12
+ * await view.call(name, args, { turnId, callId, signal }) // one durable operation
13
+ * ```
14
+ *
15
+ * Six rules are load-bearing, and each one is a rule the model cannot argue with:
16
+ *
17
+ * 1. **Every identity is Host-owned.** The machine, the root, the Agent `taskId`/`attemptId`, the
18
+ * `operationId` and the request key are derived from the Bot, the conversation and the turn/call
19
+ * identity the loop hands over. A reply may name one of its own bound machines or roots, and may
20
+ * reference a browser session, a screenshot or an operation the Host already issued; it can never
21
+ * mint one, and it is never accepted as an actor, an endpoint or a credential.
22
+ * 2. **A request key is reserved before a byte leaves the process.** The reservation is one
23
+ * synchronous immediate transaction that also carries the input digest, so a repeated call reads
24
+ * its original record instead of dispatching a second effect, and the same key with different
25
+ * content is a refusal rather than a silent overwrite.
26
+ * 3. **An unknown outcome is reconciled, never replayed.** A mutation whose dispatch may have
27
+ * happened answers `unknown` with its original operation identity and a next action; only an
28
+ * actual Agent operation record settles it. A pending or unknown mutation fences every further
29
+ * mutation of the same Bot, conversation, machine and root until it is reconciled.
30
+ * 4. **A browser session and a screenshot belong to the context that produced them.** The Agent
31
+ * owns single-use and expiry of both; this module only records that a session or a capture was
32
+ * really issued to this Bot, conversation and target, and refuses anything else before dispatch.
33
+ * It never invents acceptance and never bypasses the Agent's own freshness checks.
34
+ * 5. **Authority is re-decided, not cached.** Committed grants and paired scopes are read live
35
+ * before the reservation and again after every await, so a grant withdrawn mid-call cannot be
36
+ * turned into a delivered result.
37
+ * 6. **No second ledger.** There is no money accounting and no Task submission here: the paired
38
+ * machine directory, the capability authority and the Agent's own operation journal are the only
39
+ * authorities, and a screenshot's pixels are delivered once and never persisted.
40
+ */
41
+
42
+ import { createHash } from 'node:crypto'
43
+ import { mkdirSync } from 'node:fs'
44
+ import { dirname, resolve } from 'node:path'
45
+
46
+ const SCHEMA_VERSION = 1
47
+ /** The largest screenshot this Host hands back, whatever the remote artifact claims. */
48
+ const IMAGE_LIMIT_BYTES = 6 * 1024 * 1024
49
+ /** Ephemeral registries: a browser session and a desktop capture are per-conversation lookups. */
50
+ const SESSION_LIMIT = 4096
51
+ const CAPTURE_LIMIT = 4096
52
+ const ID_LIMIT_BYTES = 256
53
+
54
+ const fail = (code, extra) => { throw Object.assign(new Error(code), { code, ...(extra ?? {}) }) }
55
+ const plain = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
56
+ const ref = value => typeof value === 'string' && /^[-A-Za-z0-9_.]{1,128}$/.test(value)
57
+ const hex = value => createHash('sha256').update(value).digest('hex')
58
+ const derived = (prefix, parts) => `${prefix}-${hex(JSON.stringify(parts)).slice(0, 32)}`
59
+
60
+ /** What the Agent's own journal says. A status outside this set is not a status. */
61
+ const AGENT_STATUS = new Set(['admitted', 'running', 'cancel_pending', 'succeeded', 'failed', 'cancelled', 'unknown'])
62
+ /** Local, pre-dispatch states: nothing has been proven either way yet. */
63
+ const PENDING_STATUS = new Set(['reserved', 'dispatched'])
64
+ /** Everything that fences a later mutation of the same Bot, conversation, machine and root. */
65
+ const BLOCKING_STATUS = new Set(['reserved', 'dispatched', 'admitted', 'running', 'cancel_pending', 'unknown'])
66
+ const TERMINAL_STATUS = new Set(['succeeded', 'failed', 'cancelled'])
67
+
68
+ const NEXT_ACTION = 'Reconcile this original operation with bot_remote_operation_status using the returned operationId before taking any further action. An unknown remote effect is never replayed.'
69
+
70
+ const familyOf = entry => entry.machineTool.startsWith('browser_') ? 'browser'
71
+ : entry.machineTool.startsWith('desktop_') ? 'desktop' : 'operation'
72
+ const isObservation = entry => entry.machineTool === 'operation_status' || entry.machineTool === 'operation_cancel'
73
+ /**
74
+ * A desktop action also needs the artifact and operation tools: the screenshot is delivered through
75
+ * `machines.download`, and an unknown desktop effect can only be reconciled through
76
+ * `operation_status`/`operation_cancel`. A pairing without them cannot run the family at all, so the
77
+ * tools are not offered rather than offered and failed.
78
+ */
79
+ const ARTIFACT_TOOLS = Object.freeze(['artifact_stat', 'artifact_read', 'operation_status', 'operation_cancel'])
80
+ const requirementsOf = entry => familyOf(entry) === 'desktop'
81
+ ? Object.freeze([entry.capability, ...ARTIFACT_TOOLS.map(tool => `remote/${tool}`)])
82
+ : Object.freeze([entry.capability])
83
+ const pairedToolsOf = entry => familyOf(entry) === 'desktop'
84
+ ? Object.freeze([entry.machineTool, ...ARTIFACT_TOOLS])
85
+ : Object.freeze([entry.machineTool])
86
+ /** A cancel is a stop request, not a workspace effect: a read-only pairing may hold it. */
87
+ const requiresWrite = entry => entry.mutating && !isObservation(entry)
88
+
89
+ const TARGET_KEYS = Object.freeze({
90
+ machineId: { type: 'string', description: 'One of this Bot\'s paired machines; omit to let the Host choose.' },
91
+ rootId: { type: 'string', description: 'One of the roots granted to this Bot on that machine; omit to let the Host choose.' },
92
+ })
93
+
94
+ const objectSchema = (properties, required = []) => Object.freeze({
95
+ type: 'object',
96
+ properties: Object.freeze(properties),
97
+ required: Object.freeze(required),
98
+ additionalProperties: false,
99
+ })
100
+
101
+ const text = description => ({ type: 'string', description })
102
+ const count = description => ({ type: 'integer', description })
103
+
104
+ /**
105
+ * The offered surface. `capability` is the exact authority name a call needs; `parameters` is a flat
106
+ * JSON object schema with `additionalProperties:false`, so a model cannot smuggle an identity, an
107
+ * endpoint or a credential past the parser.
108
+ */
109
+ const CATALOG = Object.freeze([
110
+ Object.freeze({
111
+ name: 'bot_browser_open', machineTool: 'browser_open', capability: 'remote/browser_open', mutating: true,
112
+ description: 'Open a page in this Bot\'s granted remote browser. The browser session stays owned by this Bot conversation; later browser calls reuse it and never name a task id.',
113
+ parameters: objectSchema({ ...TARGET_KEYS, url: text('Absolute http(s) URL inside the origins this Bot is granted to reach.'), profileId: text('Optional Host-declared persistent browser profile on that machine.') }, ['url']),
114
+ }),
115
+ Object.freeze({
116
+ name: 'bot_browser_snapshot', machineTool: 'browser_snapshot', capability: 'remote/browser_snapshot', mutating: false,
117
+ description: 'Read the current page of this Bot\'s browser session: its URL, its text and the interactive nodes with the ref ids later clicks and typing use. Read-only, so it carries no operation envelope.',
118
+ parameters: objectSchema({ ...TARGET_KEYS, browserSessionId: text('The browserSessionId a successful bot_browser_open returned.') }, ['browserSessionId']),
119
+ }),
120
+ Object.freeze({
121
+ name: 'bot_browser_click', machineTool: 'browser_click', capability: 'remote/browser_click', mutating: true,
122
+ description: 'Click one node of the current snapshot in this Bot\'s browser session. The snapshotId and ref must come from the most recent bot_browser_snapshot.',
123
+ parameters: objectSchema({ ...TARGET_KEYS, browserSessionId: text('The browserSessionId a successful bot_browser_open returned.'), snapshotId: text('The snapshotId the most recent bot_browser_snapshot returned.'), ref: text('The ref of one node in that snapshot.') }, ['browserSessionId', 'snapshotId', 'ref']),
124
+ }),
125
+ Object.freeze({
126
+ name: 'bot_browser_type', machineTool: 'browser_type', capability: 'remote/browser_type', mutating: true,
127
+ description: 'Type text into one node of the current snapshot in this Bot\'s browser session. Unicode is sent directly, never through the clipboard.',
128
+ parameters: objectSchema({ ...TARGET_KEYS, browserSessionId: text('The browserSessionId a successful bot_browser_open returned.'), snapshotId: text('The snapshotId the most recent bot_browser_snapshot returned.'), ref: text('The ref of one node in that snapshot.'), text: text('The text to enter into that node.') }, ['browserSessionId', 'snapshotId', 'ref', 'text']),
129
+ }),
130
+ Object.freeze({
131
+ name: 'bot_browser_close', machineTool: 'browser_close', capability: 'remote/browser_close', mutating: true,
132
+ description: 'Close this Bot\'s browser session. It cannot be used afterwards; take a new bot_browser_open instead.',
133
+ parameters: objectSchema({ ...TARGET_KEYS, browserSessionId: text('The browserSessionId a successful bot_browser_open returned.') }, ['browserSessionId']),
134
+ }),
135
+ Object.freeze({
136
+ name: 'bot_desktop_screenshot', machineTool: 'desktop_screenshot', capability: 'remote/desktop_screenshot', mutating: true,
137
+ description: 'Capture the granted remote desktop and return it as an image. The screenshotId is single-use and expires 15 seconds after the capture; take a new screenshot after any use, any failure and any window change.',
138
+ parameters: objectSchema({ ...TARGET_KEYS }),
139
+ }),
140
+ Object.freeze({
141
+ name: 'bot_desktop_click', machineTool: 'desktop_click', capability: 'remote/desktop_click', mutating: true,
142
+ description: 'Click on the granted remote desktop. x and y are pixels of the screenshot named by screenshotId, never scaled by DPI.',
143
+ parameters: objectSchema({ ...TARGET_KEYS, screenshotId: text('The screenshotId the most recent bot_desktop_screenshot returned.'), x: count('Horizontal pixel inside that screenshot.'), y: count('Vertical pixel inside that screenshot.'), button: text('Which mouse button to press.'), clickCount: count('How many clicks to send, 1 to 3.') }, ['screenshotId', 'x', 'y']),
144
+ }),
145
+ Object.freeze({
146
+ name: 'bot_desktop_type', machineTool: 'desktop_type', capability: 'remote/desktop_type', mutating: true,
147
+ description: 'Type text into the granted remote desktop. Unicode is sent directly, never through the clipboard; newline and tab become Enter and Tab.',
148
+ parameters: objectSchema({ ...TARGET_KEYS, screenshotId: text('The screenshotId the most recent bot_desktop_screenshot returned.'), text: text('The text to type.') }, ['screenshotId', 'text']),
149
+ }),
150
+ Object.freeze({
151
+ name: 'bot_desktop_key', machineTool: 'desktop_key', capability: 'remote/desktop_key', mutating: true,
152
+ description: 'Press one key on the granted remote desktop, optionally with modifiers held.',
153
+ parameters: objectSchema({ ...TARGET_KEYS, screenshotId: text('The screenshotId the most recent bot_desktop_screenshot returned.'), key: text('Which key to press.'), modifiers: { type: 'array', items: { type: 'string' }, description: 'Modifier keys to hold while the key is pressed.' } }, ['screenshotId', 'key']),
154
+ }),
155
+ Object.freeze({
156
+ name: 'bot_desktop_scroll', machineTool: 'desktop_scroll', capability: 'remote/desktop_scroll', mutating: true,
157
+ description: 'Scroll the granted remote desktop. 120 is one notch and positive scrolls up; x and y move the pointer onto the target first.',
158
+ parameters: objectSchema({ ...TARGET_KEYS, screenshotId: text('The screenshotId the most recent bot_desktop_screenshot returned.'), delta: count('Wheel delta in notches of 120; positive scrolls up.'), x: count('Optional horizontal pixel to move the pointer to first.'), y: count('Optional vertical pixel to move the pointer to first.') }, ['screenshotId', 'delta']),
159
+ }),
160
+ Object.freeze({
161
+ name: 'bot_remote_operation_status', machineTool: 'operation_status', capability: 'remote/operation_status', mutating: false,
162
+ description: 'Read the current state of one operation this Host issued for this Bot conversation. The operationId must come from an earlier tool result; an unknown or still running operation is reported as such and never as a success.',
163
+ parameters: objectSchema({ operationId: text('The operationId an earlier tool result returned.') }, ['operationId']),
164
+ }),
165
+ Object.freeze({
166
+ name: 'bot_remote_operation_cancel', machineTool: 'operation_cancel', capability: 'remote/operation_cancel', mutating: true,
167
+ description: 'Ask to stop one operation this Host issued for this Bot conversation. This is a stop request, not a confirmation: an already applied effect is not rolled back, and only stopConfirmed:true proves the action stopped.',
168
+ parameters: objectSchema({ operationId: text('The operationId an earlier tool result returned.') }, ['operationId']),
169
+ }),
170
+ ])
171
+
172
+ export const NATIVE_REMOTE_TOOLS = CATALOG
173
+
174
+ const BY_NAME = new Map(CATALOG.map(entry => [entry.name, entry]))
175
+
176
+ /** One offered entry, or null. Frozen, so a caller cannot widen a schema at runtime. */
177
+ export const nativeRemoteToolEntry = name => BY_NAME.get(name) ?? null
178
+
179
+ const KEYS = Object.freeze(['Enter', 'Tab', 'Escape', 'Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'])
180
+ const BUTTONS = Object.freeze(['left', 'right', 'middle'])
181
+ const MODIFIERS = Object.freeze(['ctrl', 'alt', 'shift'])
182
+
183
+ /**
184
+ * Bounds the schema deliberately does not carry. The installed SDK's schema validator is narrower
185
+ * than JSON Schema, so numeric ranges stay here instead of in `parameters`, and this parser is the
186
+ * only place a business argument is accepted.
187
+ */
188
+ const LIMITS = Object.freeze({
189
+ url: { maxLength: 2048, control: false },
190
+ profileId: { pattern: /^[-A-Za-z0-9_.]{1,128}$/ },
191
+ browserSessionId: { pattern: /^[-A-Za-z0-9_.]{1,128}$/ },
192
+ snapshotId: { pattern: /^[-A-Za-z0-9_.]{1,128}$/ },
193
+ ref: { pattern: /^\d{1,3}$/ },
194
+ screenshotId: { pattern: /^[-A-Za-z0-9_.]{1,128}$/ },
195
+ operationId: { pattern: /^[-A-Za-z0-9_.]{1,128}$/ },
196
+ machineId: { pattern: /^[-A-Za-z0-9_.]{1,128}$/ },
197
+ rootId: { pattern: /^[-A-Za-z0-9_.]{1,128}$/ },
198
+ text: { maxLength: 65536, control: true },
199
+ x: { min: 0, max: 100000 },
200
+ y: { min: 0, max: 100000 },
201
+ delta: { min: -1200, max: 1200 },
202
+ clickCount: { min: 1, max: 3 },
203
+ button: { enum: BUTTONS },
204
+ key: { enum: KEYS },
205
+ modifiers: { maxItems: 3, unique: true, items: { enum: MODIFIERS } },
206
+ })
207
+
208
+ /** Strict, offline argument parsing: no unknown key, no missing required key, no loose type. */
209
+ function parseToolArgs(entry, args) {
210
+ const refuse = () => fail('INVALID_TOOL_ARGS', { tool: entry.name })
211
+ if (!plain(args)) refuse()
212
+ const { properties, required } = entry.parameters
213
+ if (Object.keys(args).some(key => !Object.hasOwn(properties, key))) refuse()
214
+ for (const key of required) if (!Object.hasOwn(args, key)) refuse()
215
+ for (const [key, value] of Object.entries(args)) {
216
+ const schema = properties[key], limit = LIMITS[key] ?? {}
217
+ const types = [schema.type].flat()
218
+ const kind = value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value
219
+ const integer = types.includes('integer') && Number.isSafeInteger(value)
220
+ if (!types.includes(kind) && !integer) refuse()
221
+ if (limit.enum && !limit.enum.includes(value)) refuse()
222
+ if (typeof value === 'string') {
223
+ if (limit.pattern && !limit.pattern.test(value)) refuse()
224
+ if (limit.maxLength !== undefined && Buffer.byteLength(value) > limit.maxLength) refuse()
225
+ // Control characters are only ever business content inside typed text.
226
+ if (limit.control !== true && /[\u0000-\u001f\u007f]/.test(value)) refuse()
227
+ }
228
+ if (integer && (limit.min !== undefined && value < limit.min || limit.max !== undefined && value > limit.max)) refuse()
229
+ if (schema.items) {
230
+ if (!Array.isArray(value) || value.some(item => typeof item !== schema.items.type)) refuse()
231
+ if (limit.maxItems !== undefined && value.length > limit.maxItems) refuse()
232
+ if (limit.unique === true && new Set(value).size !== value.length) refuse()
233
+ if (limit.items?.enum && value.some(item => !limit.items.enum.includes(item))) refuse()
234
+ }
235
+ }
236
+ return args
237
+ }
238
+
239
+ const boundedId = (value, code) => {
240
+ if (typeof value !== 'string' || !value.length || Buffer.byteLength(value) > ID_LIMIT_BYTES || /[\u0000-\u001f\u007f]/.test(value)) fail(code)
241
+ return value
242
+ }
243
+
244
+ /**
245
+ * @param {{
246
+ * Database: Function,
247
+ * file: string,
248
+ * directory: { forBot: (botId: string) => unknown },
249
+ * authority: { assertTool: (subject: unknown, name: string) => void },
250
+ * machines: object | (() => object | null),
251
+ * now?: () => number,
252
+ * }} options
253
+ */
254
+ export function openNativeRemoteActions({ Database, file, directory, authority, machines, now = Date.now } = {}) {
255
+ if (typeof Database !== 'function' || typeof file !== 'string' || !file) throw new TypeError('NATIVE_REMOTE_SERVICES_REQUIRED')
256
+ if (!directory || typeof directory.forBot !== 'function') throw new TypeError('NATIVE_REMOTE_SERVICES_REQUIRED')
257
+ if (!authority || typeof authority.assertTool !== 'function') throw new TypeError('NATIVE_REMOTE_SERVICES_REQUIRED')
258
+ if (machines === undefined || machines === null) throw new TypeError('NATIVE_REMOTE_SERVICES_REQUIRED')
259
+ if (typeof now !== 'function') throw new TypeError('NATIVE_REMOTE_SERVICES_REQUIRED')
260
+
261
+ // The same orchestration SQLite file every other component uses, under this module's own prefix.
262
+ const path = file === ':memory:' ? file : resolve(file)
263
+ if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true })
264
+ const db = new Database(path)
265
+ db.pragma('busy_timeout=5000')
266
+ // WAL is a property of the file, already set by whoever opened it first; this connection only
267
+ // refuses to run where it is unavailable, and keeps its own commits durable.
268
+ if (path !== ':memory:' && db.pragma('journal_mode=WAL', { simple: true }) !== 'wal') { db.close(); fail('NATIVE_REMOTE_WAL_REQUIRED') }
269
+ db.pragma('synchronous=FULL')
270
+ try {
271
+ db.transaction(() => {
272
+ db.exec(`CREATE TABLE IF NOT EXISTS native_remote_component(component TEXT PRIMARY KEY,version INTEGER NOT NULL);
273
+ CREATE TABLE IF NOT EXISTS native_remote_requests(request_key TEXT PRIMARY KEY,bot_id TEXT NOT NULL,conversation_id TEXT NOT NULL,turn_id TEXT NOT NULL,call_id TEXT NOT NULL,
274
+ role TEXT NOT NULL,name TEXT NOT NULL,input_digest TEXT NOT NULL,machine_tool TEXT NOT NULL,operation_id TEXT,
275
+ task_id TEXT NOT NULL,attempt_id TEXT NOT NULL,machine_id TEXT NOT NULL,root_id TEXT NOT NULL,status TEXT NOT NULL,value TEXT,error TEXT,artifact_id TEXT,created_ms INTEGER NOT NULL,updated_ms INTEGER NOT NULL);
276
+ CREATE TABLE IF NOT EXISTS native_remote_browser_sessions(browser_session_id TEXT PRIMARY KEY,bot_id TEXT NOT NULL,conversation_id TEXT NOT NULL,machine_id TEXT NOT NULL,root_id TEXT NOT NULL,
277
+ task_id TEXT NOT NULL,attempt_id TEXT NOT NULL,operation_id TEXT,request_key TEXT NOT NULL,status TEXT NOT NULL,created_ms INTEGER NOT NULL,updated_ms INTEGER NOT NULL);
278
+ CREATE TABLE IF NOT EXISTS native_remote_desktop_captures(screenshot_id TEXT PRIMARY KEY,bot_id TEXT NOT NULL,conversation_id TEXT NOT NULL,machine_id TEXT NOT NULL,root_id TEXT NOT NULL,
279
+ task_id TEXT NOT NULL,attempt_id TEXT NOT NULL,operation_id TEXT,artifact_id TEXT NOT NULL,sha256 TEXT,size INTEGER,media_type TEXT,request_key TEXT NOT NULL,created_ms INTEGER NOT NULL);
280
+ CREATE TABLE IF NOT EXISTS native_remote_request_refs(request_key TEXT PRIMARY KEY,browser_session_id TEXT NOT NULL);
281
+ CREATE INDEX IF NOT EXISTS native_remote_requests_scope ON native_remote_requests(bot_id,conversation_id,machine_id,root_id,status,created_ms);
282
+ CREATE INDEX IF NOT EXISTS native_remote_requests_operation ON native_remote_requests(operation_id,role);
283
+ CREATE INDEX IF NOT EXISTS native_remote_browser_sessions_owner ON native_remote_browser_sessions(bot_id,conversation_id,created_ms);
284
+ CREATE INDEX IF NOT EXISTS native_remote_desktop_captures_owner ON native_remote_desktop_captures(bot_id,conversation_id,created_ms);`)
285
+ const row = db.prepare('SELECT version FROM native_remote_component WHERE component=?').get('native-remote-actions')
286
+ if (row && row.version !== SCHEMA_VERSION) fail('NATIVE_REMOTE_SCHEMA_UNSUPPORTED')
287
+ db.prepare('INSERT OR IGNORE INTO native_remote_component VALUES(?,?)').run('native-remote-actions', SCHEMA_VERSION)
288
+ }).immediate()
289
+ } catch (error) { db.close(); throw error }
290
+
291
+ let closed = false
292
+ const alive = () => { if (closed) fail('NATIVE_REMOTE_JOURNAL_CLOSED') }
293
+ const stmt = sql => { alive(); return db.prepare(sql) }
294
+ const fenceStatuses = [...BLOCKING_STATUS]
295
+ const q = {
296
+ getRequest: stmt('SELECT * FROM native_remote_requests WHERE request_key=?'),
297
+ findOperation: stmt("SELECT * FROM native_remote_requests WHERE operation_id=? AND bot_id=? AND conversation_id=? AND role='action'"),
298
+ reserve: stmt(`INSERT INTO native_remote_requests(request_key,bot_id,conversation_id,turn_id,call_id,role,name,input_digest,machine_tool,operation_id,
299
+ task_id,attempt_id,machine_id,root_id,status,value,error,artifact_id,created_ms,updated_ms) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'reserved',NULL,NULL,NULL,?,?)`),
300
+ dispatched: stmt("UPDATE native_remote_requests SET status='dispatched',updated_ms=? WHERE request_key=? AND status='reserved'"),
301
+ settle: stmt('UPDATE native_remote_requests SET status=?,value=?,error=?,updated_ms=? WHERE request_key=?'),
302
+ settleArtifact: stmt('UPDATE native_remote_requests SET status=?,value=?,error=?,artifact_id=?,updated_ms=? WHERE request_key=?'),
303
+ fence: stmt(`SELECT operation_id,status,name,machine_tool FROM native_remote_requests WHERE bot_id=? AND conversation_id=? AND machine_id=? AND root_id=? AND role='action'
304
+ AND operation_id IS NOT NULL AND request_key<>? AND status IN (${fenceStatuses.map(() => '?').join(',')}) ORDER BY created_ms,rowid LIMIT 1`),
305
+ requestRef: stmt('SELECT browser_session_id FROM native_remote_request_refs WHERE request_key=?'),
306
+ reserveRef: stmt('INSERT INTO native_remote_request_refs VALUES(?,?)'),
307
+ session: stmt("SELECT * FROM native_remote_browser_sessions WHERE browser_session_id=? AND status='open'"),
308
+ anySession: stmt('SELECT * FROM native_remote_browser_sessions WHERE browser_session_id=?'),
309
+ openSession: stmt('INSERT INTO native_remote_browser_sessions(browser_session_id,bot_id,conversation_id,machine_id,root_id,task_id,attempt_id,operation_id,request_key,status,created_ms,updated_ms) VALUES(?,?,?,?,?,?,?,?,?,\'open\',?,?)'),
310
+ closeSession: stmt("UPDATE native_remote_browser_sessions SET status='closed',updated_ms=? WHERE browser_session_id=? AND status='open'"),
311
+ capture: stmt('SELECT * FROM native_remote_desktop_captures WHERE screenshot_id=?'),
312
+ openCapture: stmt('INSERT INTO native_remote_desktop_captures(screenshot_id,bot_id,conversation_id,machine_id,root_id,task_id,attempt_id,operation_id,artifact_id,sha256,size,media_type,request_key,created_ms) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)'),
313
+ sessionCount: stmt('SELECT count(*) AS total FROM native_remote_browser_sessions'),
314
+ captureCount: stmt('SELECT count(*) AS total FROM native_remote_desktop_captures'),
315
+ sweepSessions: stmt(`DELETE FROM native_remote_browser_sessions WHERE browser_session_id NOT IN (SELECT browser_session_id FROM native_remote_browser_sessions ORDER BY created_ms DESC LIMIT ${SESSION_LIMIT})`),
316
+ sweepCaptures: stmt(`DELETE FROM native_remote_desktop_captures WHERE screenshot_id NOT IN (SELECT screenshot_id FROM native_remote_desktop_captures ORDER BY created_ms DESC LIMIT ${CAPTURE_LIMIT})`),
317
+ }
318
+
319
+ /** In-process dispatches, so a concurrent duplicate waits for the original instead of repeating it. */
320
+ const inflight = new Map()
321
+
322
+ /** The machine directory, resolved per call: a Host may publish it late or replace it. */
323
+ const machineStore = () => {
324
+ const value = typeof machines === 'function' ? machines() : machines
325
+ if (!value || typeof value.call !== 'function') fail('MACHINE_DIRECTORY_UNAVAILABLE')
326
+ return value
327
+ }
328
+
329
+ const statusOf = value => value && typeof value.status === 'string' && AGENT_STATUS.has(value.status) ? value.status : null
330
+ const codeOf = error => { const code = error?.code; return typeof code === 'string' && /^[A-Z][A-Z0-9_]{0,80}$/.test(code) ? code : 'NATIVE_REMOTE_TOOL_FAILED' }
331
+
332
+ /**
333
+ * One Agent answer, normalised. A mutating tool answers with its operation record; a read-only one
334
+ * answers with the value itself, and that is not an operation envelope.
335
+ */
336
+ const normalizeRecord = (entry, record, target, operationId, original) => {
337
+ const invalid = { status: 'unknown', value: null, result: null, error: 'NATIVE_REMOTE_PROTOCOL_INVALID' }
338
+ if (!plain(record)) return invalid
339
+ if (!entry.mutating && !isObservation(entry)) return { status: 'succeeded', value: record, result: record, error: null }
340
+ const operation = entry.machineTool === 'operation_cancel' ? record.operation : record
341
+ const expectedTool = original?.machine_tool ?? entry.machineTool
342
+ if (!plain(operation) || operation.schemaVersion !== 1 || operation.machineId !== target.machineId
343
+ || operation.operationId !== operationId || operation.tool !== expectedTool || !statusOf(operation)) return invalid
344
+ return { status: operation.status, result: operation.result ?? null,
345
+ value: entry.machineTool === 'operation_cancel' ? record : operation.result ?? null, error: operation.error?.code ?? null }
346
+ }
347
+
348
+ const validateImage = image => {
349
+ if (!plain(image) || typeof image.content !== 'string' || !image.content) fail('NATIVE_REMOTE_IMAGE_INVALID')
350
+ if (image.mediaType !== 'image/png') fail('NATIVE_REMOTE_IMAGE_INVALID', { mediaType: image.mediaType ?? null })
351
+ const bytes = Buffer.from(image.content, 'base64')
352
+ if (!bytes.length || bytes.toString('base64') !== image.content) fail('NATIVE_REMOTE_IMAGE_INVALID')
353
+ if (bytes.length > IMAGE_LIMIT_BYTES) fail('NATIVE_REMOTE_IMAGE_LIMIT', { size: bytes.length })
354
+ if (bytes.length < 8 || !bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) fail('NATIVE_REMOTE_IMAGE_INVALID')
355
+ return Object.freeze({ mimeType: 'image/png', data: image.content })
356
+ }
357
+
358
+ const view = (botId, conversationId) => {
359
+ /** Live committed bindings for one Bot, straight from the machine directory. */
360
+ const scopesFor = () => {
361
+ const store = machineStore()
362
+ const rows = typeof store.scopes === 'function' ? store.scopes(botId)
363
+ : typeof store.bindingsFor === 'function' ? store.bindingsFor(botId) : []
364
+ if (!Array.isArray(rows)) fail('MACHINE_DIRECTORY_UNREADABLE')
365
+ return rows.filter(scope => scope && typeof scope.machineId === 'string' && Array.isArray(scope.tools) && Array.isArray(scope.roots))
366
+ }
367
+
368
+ /** The one live binding that can still run this entry against this exact machine and root. */
369
+ const liveScopeFor = (entry, target) => scopesFor().some(scope => scope.machineId === target.machineId
370
+ && scope.roots.includes(target.rootId)
371
+ && pairedToolsOf(entry).every(tool => scope.tools.includes(tool))
372
+ && (!requiresWrite(entry) || scope.mode === 'workspace-write'))
373
+
374
+ /**
375
+ * Live authority and live scope, both re-read. `soft` turns a refusal into `false` for the
376
+ * post-await re-check, where the effect is already durable and must not be invented away.
377
+ */
378
+ const assertLive = (entry, target, { soft = false } = {}) => {
379
+ try {
380
+ const subject = directory.forBot(botId)
381
+ for (const capability of requirementsOf(entry)) authority.assertTool(subject, capability)
382
+ if (!liveScopeFor(entry, target)) fail('MACHINE_BOT_DENIED')
383
+ return true
384
+ } catch (error) {
385
+ if (soft) return false
386
+ throw error
387
+ }
388
+ }
389
+
390
+ /** The target this call runs on: an explicitly named, still granted one, or the first live match. */
391
+ const resolveTarget = (entry, args) => {
392
+ const scopes = scopesFor()
393
+ const usable = scopes.filter(scope => scope.roots.length && pairedToolsOf(entry).every(tool => scope.tools.includes(tool))
394
+ && (!requiresWrite(entry) || scope.mode === 'workspace-write'))
395
+ let chosen = usable
396
+ if (args.machineId !== undefined) {
397
+ if (!scopes.some(scope => scope.machineId === args.machineId)) fail('MACHINE_NOT_BOUND', { tool: entry.name })
398
+ chosen = usable.filter(scope => scope.machineId === args.machineId)
399
+ if (!chosen.length) fail('MACHINE_BOT_DENIED', { tool: entry.name, machineTool: entry.machineTool })
400
+ }
401
+ const scope = chosen[0]
402
+ if (!scope) fail('MACHINE_NOT_BOUND', { tool: entry.name })
403
+ if (args.rootId !== undefined && (typeof args.rootId !== 'string' || !scope.roots.includes(args.rootId))) fail('MACHINE_ROOT_NOT_BOUND', { tool: entry.name })
404
+ const rootId = args.rootId ?? scope.roots[0]
405
+ if (typeof rootId !== 'string' || !rootId) fail('MACHINE_ROOT_NOT_BOUND', { tool: entry.name })
406
+ return { machineId: scope.machineId, rootId }
407
+ }
408
+
409
+ /** Only declared business keys cross the boundary; identity keys never do. */
410
+ const businessArgs = (entry, parsed) => {
411
+ const business = {}
412
+ for (const key of Object.keys(entry.parameters.properties)) {
413
+ if (key === 'machineId' || key === 'rootId') continue
414
+ if (Object.hasOwn(parsed, key)) business[key] = parsed[key]
415
+ }
416
+ return business
417
+ }
418
+
419
+ /**
420
+ * A continuation that names a target must name the one its own session or capture belongs to. A
421
+ * machine this Bot was never paired with is refused as such; a granted one that disagrees with the
422
+ * session is a mismatch, because the Agent bound that session to one task, attempt and root.
423
+ */
424
+ const namedTarget = (entry, parsed, target) => {
425
+ if (parsed.machineId !== undefined && parsed.machineId !== target.machineId) {
426
+ if (!scopesFor().some(scope => scope.machineId === parsed.machineId)) fail('MACHINE_NOT_BOUND', { tool: entry.name })
427
+ fail('NATIVE_REMOTE_TARGET_MISMATCH', { machineId: parsed.machineId, bound: target.machineId })
428
+ }
429
+ if (parsed.rootId !== undefined && parsed.rootId !== target.rootId) fail('NATIVE_REMOTE_TARGET_MISMATCH', { rootId: parsed.rootId, bound: target.rootId })
430
+ }
431
+
432
+ const recordOf = row => ({
433
+ operationId: row.operation_id ?? null,
434
+ taskId: row.task_id,
435
+ attemptId: row.attempt_id,
436
+ status: row.status,
437
+ value: row.value === null || row.value === undefined ? null : JSON.parse(row.value),
438
+ })
439
+
440
+ /** A dispatch this Host can no longer prove under the current grant: the record stays, the value does not travel. */
441
+ const undelivered = (row, error) => ({
442
+ operationId: row.operation_id ?? null, taskId: row.task_id, attemptId: row.attempt_id,
443
+ status: row.status, value: null, error, nextAction: NEXT_ACTION,
444
+ })
445
+
446
+ const persist = (requestKey, status, value, error, artifactId) => {
447
+ const encoded = value === null || value === undefined ? null : JSON.stringify(value)
448
+ try {
449
+ if (artifactId === undefined) q.settle.run(status, encoded, error ?? null, now(), requestKey)
450
+ else q.settleArtifact.run(status, encoded, error ?? null, artifactId, now(), requestKey)
451
+ } catch { /* a closed journal keeps its reservation; the Host reconciles it */ }
452
+ }
453
+
454
+ /** A successful open or capture is the only thing that may authorise a later continuation. */
455
+ const register = (entry, requestKey, target, taskId, attemptId, operationId, value, sessionId) => {
456
+ if (entry.machineTool === 'browser_open') {
457
+ if (!ref(value?.browserSessionId)) fail('NATIVE_REMOTE_PROTOCOL_INVALID', { tool: entry.name })
458
+ const existing = q.anySession.get(value.browserSessionId)
459
+ if (existing) {
460
+ if (existing.bot_id !== botId || existing.conversation_id !== conversationId || existing.request_key !== requestKey) fail('NATIVE_REMOTE_PROTOCOL_INVALID')
461
+ return
462
+ }
463
+ q.openSession.run(value.browserSessionId, botId, conversationId, target.machineId, target.rootId, taskId, attemptId, operationId, requestKey, now(), now())
464
+ if (q.sessionCount.get().total > SESSION_LIMIT) q.sweepSessions.run()
465
+ return
466
+ }
467
+ if (entry.machineTool === 'browser_close') {
468
+ if (ref(sessionId)) q.closeSession.run(now(), sessionId)
469
+ return
470
+ }
471
+ if (entry.machineTool === 'desktop_screenshot') {
472
+ if (!ref(value?.screenshotId) || !ref(value?.artifactId)) fail('NATIVE_REMOTE_PROTOCOL_INVALID', { tool: entry.name })
473
+ const existing = q.capture.get(value.screenshotId)
474
+ if (existing) {
475
+ if (existing.bot_id !== botId || existing.conversation_id !== conversationId || existing.request_key !== requestKey || existing.artifact_id !== value.artifactId) fail('NATIVE_REMOTE_PROTOCOL_INVALID')
476
+ return
477
+ }
478
+ q.openCapture.run(value.screenshotId, botId, conversationId, target.machineId, target.rootId, taskId, attemptId, operationId,
479
+ value.artifactId, value.sha256 ?? null, Number.isSafeInteger(value.size) ? value.size : null, value.mediaType ?? null, requestKey, now())
480
+ if (q.captureCount.get().total > CAPTURE_LIMIT) q.sweepCaptures.run()
481
+ }
482
+ }
483
+
484
+ /** The original artifact of a completed capture, over the context it was captured under. */
485
+ const downloadImage = async (row, signal) => {
486
+ const downloaded = await machineStore().download(directory.forBot(botId), {
487
+ machineId: row.machine_id, rootId: row.root_id, taskId: row.task_id, attemptId: row.attempt_id,
488
+ operationId: row.operation_id, artifactId: row.artifact_id,
489
+ }, { signal })
490
+ const image = validateImage(downloaded)
491
+ if (downloaded.artifactId !== row.artifact_id) fail('NATIVE_REMOTE_IMAGE_INVALID')
492
+ if (row.sha256 && downloaded.sha256 !== row.sha256) fail('NATIVE_REMOTE_IMAGE_INVALID')
493
+ if (row.size !== null && row.size !== undefined && downloaded.size !== row.size) fail('NATIVE_REMOTE_IMAGE_INVALID')
494
+ return image
495
+ }
496
+
497
+ /**
498
+ * What a finished row delivers now. A completed screenshot may download its original artifact
499
+ * again; it is never recaptured, and the pixels are never persisted.
500
+ */
501
+ const deliver = async (row, signal) => {
502
+ const entry = nativeRemoteToolEntry(row.name)
503
+ const target = { machineId: row.machine_id, rootId: row.root_id }
504
+ if (!assertLive(entry, target, { soft: true })) return undelivered(row, 'NATIVE_REMOTE_AUTHORITY_LOST')
505
+ const record = recordOf(row)
506
+ if (record.status !== 'succeeded') {
507
+ return { ...record, ...(row.error ? { error: row.error } : {}), ...(BLOCKING_STATUS.has(record.status) ? { nextAction: NEXT_ACTION } : {}) }
508
+ }
509
+ if (entry.machineTool === 'desktop_screenshot' && row.artifact_id) {
510
+ const image = await downloadImage(row, signal)
511
+ if (!assertLive(entry, target, { soft: true })) return undelivered(row, 'NATIVE_REMOTE_AUTHORITY_LOST')
512
+ return { ...record, image }
513
+ }
514
+ return record
515
+ }
516
+
517
+ /**
518
+ * One tool call. Pre-dispatch refusals throw with the operation identity attached; anything that
519
+ * may already have been dispatched is returned as a record, never as a success it is not.
520
+ */
521
+ const call = async (name, args = {}, context = {}) => {
522
+ alive()
523
+ const entry = nativeRemoteToolEntry(name)
524
+ if (!entry) fail('TOOL_NOT_GRANTED', { tool: String(name) })
525
+ const parsed = parseToolArgs(entry, args)
526
+ const turnId = boundedId(context.turnId, 'NATIVE_REMOTE_TURN_REQUIRED')
527
+ const callId = boundedId(context.callId, 'NATIVE_REMOTE_TURN_REQUIRED')
528
+ const signal = context.signal ?? null
529
+ try { signal?.throwIfAborted() } catch { fail('NATIVE_REMOTE_CANCELLED') }
530
+ // The Bot itself is a Host fact, resolved here rather than inside the dispatch, so an unknown
531
+ // or retired Bot is refused before a request key is reserved at all.
532
+ const subject = directory.forBot(botId)
533
+ const business = businessArgs(entry, parsed)
534
+ const observation = isObservation(entry)
535
+ const family = familyOf(entry)
536
+
537
+ // The target, and with it the Agent identity this call runs under. A browser continuation or a
538
+ // desktop action runs on the target its own session or capture was issued on; anything else
539
+ // chooses from the live bindings. A named machine or root that disagrees is a refusal, not a
540
+ // silent retarget.
541
+ let original = null, target, taskId, attemptId, operationId = null, role = 'action'
542
+ if (observation) {
543
+ original = q.findOperation.get(parsed.operationId, botId, conversationId)
544
+ if (!original) fail('NATIVE_REMOTE_OPERATION_UNKNOWN', { operationId: parsed.operationId })
545
+ target = { machineId: original.machine_id, rootId: original.root_id }
546
+ taskId = original.task_id
547
+ attemptId = original.attempt_id
548
+ operationId = original.operation_id
549
+ role = 'observation'
550
+ assertLive(entry, target)
551
+ } else if (family === 'browser' && entry.machineTool !== 'browser_open') {
552
+ const session = q.session.get(parsed.browserSessionId)
553
+ if (!session || session.bot_id !== botId || session.conversation_id !== conversationId) {
554
+ fail('NATIVE_REMOTE_BROWSER_SESSION_UNKNOWN', { browserSessionId: parsed.browserSessionId })
555
+ }
556
+ target = { machineId: session.machine_id, rootId: session.root_id }
557
+ namedTarget(entry, parsed, target)
558
+ taskId = session.task_id
559
+ attemptId = session.attempt_id
560
+ if (entry.mutating) operationId = derived('nro', [botId, conversationId, turnId, callId])
561
+ assertLive(entry, target)
562
+ } else if (family === 'desktop' && entry.machineTool !== 'desktop_screenshot') {
563
+ const capture = q.capture.get(parsed.screenshotId)
564
+ if (!capture || capture.bot_id !== botId || capture.conversation_id !== conversationId) {
565
+ fail('NATIVE_REMOTE_SCREENSHOT_UNKNOWN', { screenshotId: parsed.screenshotId })
566
+ }
567
+ target = { machineId: capture.machine_id, rootId: capture.root_id }
568
+ namedTarget(entry, parsed, target)
569
+ taskId = capture.task_id
570
+ attemptId = capture.attempt_id
571
+ if (entry.mutating) operationId = derived('nro', [botId, conversationId, turnId, callId])
572
+ assertLive(entry, target)
573
+ } else {
574
+ target = resolveTarget(entry, parsed)
575
+ taskId = derived('nrt', [botId, conversationId, target.machineId, target.rootId])
576
+ attemptId = derived('nra', [botId, conversationId, target.machineId, target.rootId])
577
+ // A read-only snapshot of a page carries no Agent operation envelope at all.
578
+ if (entry.mutating) operationId = derived('nro', [botId, conversationId, turnId, callId])
579
+ }
580
+ assertLive(entry, target)
581
+
582
+ const requestKey = derived('nrq', [botId, conversationId, turnId, callId])
583
+ const digest = hex(JSON.stringify([entry.name, target.machineId, target.rootId, business]))
584
+
585
+ // An unsettled mutation of this very context fences the next one: the Agent, not this Host,
586
+ // decides what happened to the first.
587
+ const reserved = db.transaction(() => {
588
+ const existing = q.getRequest.get(requestKey)
589
+ if (existing) return existing
590
+ if (requiresWrite(entry)) {
591
+ const fenced = q.fence.get(botId, conversationId, target.machineId, target.rootId, requestKey, ...fenceStatuses)
592
+ if (fenced) fail('NATIVE_REMOTE_OPERATION_PENDING', {
593
+ operationId: fenced.operation_id, status: fenced.status, tool: fenced.name, machineTool: fenced.machine_tool, nextAction: NEXT_ACTION,
594
+ })
595
+ }
596
+ q.reserve.run(requestKey, botId, conversationId, turnId, callId, role, entry.name, digest, entry.machineTool, operationId,
597
+ taskId, attemptId, target.machineId, target.rootId, now(), now())
598
+ if (parsed.browserSessionId) q.reserveRef.run(requestKey, parsed.browserSessionId)
599
+ return null
600
+ }).immediate()
601
+
602
+ if (reserved) {
603
+ if (reserved.name !== entry.name || reserved.input_digest !== digest) {
604
+ fail('NATIVE_REMOTE_REQUEST_CONFLICT', { requestKey, name: reserved.name, tool: entry.name })
605
+ }
606
+ if (reserved.role === 'observation') {
607
+ const record = recordOf(reserved)
608
+ return { ...record, ...(reserved.error ? { error: reserved.error } : {}), ...(BLOCKING_STATUS.has(record.status) ? { nextAction: NEXT_ACTION } : {}) }
609
+ }
610
+ // Identical request: the original record is the answer, and nothing is dispatched again.
611
+ if (TERMINAL_STATUS.has(reserved.status)) return await deliver(reserved, signal)
612
+ const running = inflight.get(reserved.request_key)
613
+ if (running) return await running
614
+ const record = recordOf(reserved)
615
+ const unsettled = PENDING_STATUS.has(record.status)
616
+ return {
617
+ ...record, status: unsettled ? 'unknown' : record.status, value: unsettled ? null : record.value,
618
+ error: reserved.error ?? 'NATIVE_REMOTE_OUTCOME_UNKNOWN', nextAction: NEXT_ACTION,
619
+ }
620
+ }
621
+
622
+ const work = async () => {
623
+ let record = null, thrown = null
624
+ // The directory is resolved before anything is marked dispatched, so a Host that lost it is a
625
+ // pre-dispatch refusal rather than an unknown outcome.
626
+ const store = machineStore()
627
+ try {
628
+ q.dispatched.run(now(), requestKey)
629
+ // A turn cancelled before dispatch never reached the machine: that is provable here, in
630
+ // this process, and it is the only kind of failure that may be called failed.
631
+ try { signal?.throwIfAborted() } catch (error) {
632
+ const code = codeOf(error)
633
+ persist(requestKey, 'failed', null, code, undefined)
634
+ return { operationId, taskId, attemptId, status: 'failed', value: null, error: code }
635
+ }
636
+ record = await store.call(subject, {
637
+ machineId: target.machineId, rootId: target.rootId, taskId, attemptId, tool: entry.machineTool, args: business,
638
+ ...(operationId ? { operationId } : {}),
639
+ }, { signal })
640
+ } catch (error) { thrown = error }
641
+ if (thrown) {
642
+ const notSent = thrown?.remoteDispatchNotSent === true
643
+ const code = codeOf(thrown)
644
+ const settled = notSent || !operationId
645
+ const status = settled ? 'failed' : 'unknown'
646
+ persist(requestKey, status, null, code, undefined)
647
+ return { operationId, taskId, attemptId, status, value: null, error: code, ...(settled ? {} : { nextAction: NEXT_ACTION }) }
648
+ }
649
+ const outcome = normalizeRecord(entry, record, target, operationId, original)
650
+ const issuedKey = observation ? original.request_key : requestKey
651
+ const issuedEntry = observation ? nativeRemoteToolEntry(original.name) : entry
652
+ if (outcome.status === 'succeeded') {
653
+ try {
654
+ register(issuedEntry, issuedKey, target, taskId, attemptId, operationId, outcome.result,
655
+ observation ? q.requestRef.get(issuedKey)?.browser_session_id : parsed.browserSessionId)
656
+ } catch {
657
+ outcome.status = 'unknown'; outcome.value = outcome.result = null; outcome.error = 'NATIVE_REMOTE_PROTOCOL_INVALID'
658
+ }
659
+ }
660
+ persist(issuedKey, outcome.status, outcome.result,
661
+ outcome.status === 'succeeded' ? null : outcome.error, outcome.result?.artifactId)
662
+ if (observation) persist(requestKey, outcome.status, outcome.value, outcome.status === 'succeeded' ? null : outcome.error, undefined)
663
+ const row = q.getRequest.get(observation ? original.request_key : requestKey)
664
+ if (!assertLive(entry, target, { soft: true })) return undelivered(row, 'NATIVE_REMOTE_AUTHORITY_LOST')
665
+ const base = {
666
+ operationId, taskId, attemptId, status: outcome.status, value: outcome.value,
667
+ ...(outcome.status === 'succeeded' ? {} : { error: outcome.error ?? 'NATIVE_REMOTE_TOOL_FAILED' }),
668
+ }
669
+ // A status the Agent itself reports as unsettled is not a terminal answer: the operation keeps
670
+ // its identity and the caller is told to reconcile it rather than to repeat it.
671
+ if (BLOCKING_STATUS.has(outcome.status)) base.nextAction = NEXT_ACTION
672
+ if (issuedEntry.machineTool === 'desktop_screenshot' && outcome.status === 'succeeded' && row.artifact_id) {
673
+ if (!assertLive(issuedEntry, target, { soft: true })) return undelivered(row, 'NATIVE_REMOTE_AUTHORITY_LOST')
674
+ const image = await downloadImage(row, signal)
675
+ if (!assertLive(issuedEntry, target, { soft: true })) return undelivered(row, 'NATIVE_REMOTE_AUTHORITY_LOST')
676
+ return { ...base, image }
677
+ }
678
+ return base
679
+ }
680
+
681
+ const pending = work()
682
+ inflight.set(requestKey, pending)
683
+ try { return await pending } finally { inflight.delete(requestKey) }
684
+ }
685
+
686
+ return Object.freeze({
687
+ botId,
688
+ conversationId,
689
+ /** The schemas this conversation may see right now, re-decided from committed state. */
690
+ schemas() {
691
+ alive()
692
+ // A grant the authority no longer holds removes the tool from the request, so a withdrawn
693
+ // capability is never something the model discovers one refusal at a time.
694
+ const granted = entry => {
695
+ let subject = null
696
+ try { subject = directory.forBot(botId) } catch { return false }
697
+ return requirementsOf(entry).every(capability => {
698
+ try { authority.assertTool(subject, capability); return true } catch { return false }
699
+ })
700
+ }
701
+ const offered = []
702
+ for (const entry of CATALOG) {
703
+ const scopes = scopesFor()
704
+ if (isObservation(entry)) {
705
+ // Reconciliation is mounted from committed grants alone. It has to be available before
706
+ // the first operation is issued, or a model could not read the one it is about to create.
707
+ if (!scopes.some(scope => scope.tools.includes(entry.machineTool)) || !granted(entry)) continue
708
+ offered.push(entry)
709
+ continue
710
+ }
711
+ if (!scopes.some(scope => scope.roots.length && pairedToolsOf(entry).every(tool => scope.tools.includes(tool))
712
+ && (!requiresWrite(entry) || scope.mode === 'workspace-write')) || !granted(entry)) continue
713
+ offered.push(entry)
714
+ }
715
+ return offered.map(entry => Object.freeze({ name: entry.name, description: entry.description, parameters: entry.parameters }))
716
+ },
717
+ call,
718
+ })
719
+ }
720
+
721
+ return Object.freeze({
722
+ /**
723
+ * One conversation's live surface. Both methods re-read committed grants, so a cached view can
724
+ * never freeze an older grant into a turn.
725
+ */
726
+ forConversation(botId, conversationId) {
727
+ const bot = boundedId(botId, 'NATIVE_REMOTE_BOT_REQUIRED')
728
+ const conversation = boundedId(conversationId, 'NATIVE_REMOTE_CONVERSATION_REQUIRED')
729
+ return view(bot, conversation)
730
+ },
731
+ /**
732
+ * Dispose this service. Nothing remote is stopped and nothing is replayed: an operation still in
733
+ * flight keeps its identity and its evidence, and the Host reconciles it.
734
+ */
735
+ close() {
736
+ if (closed) return
737
+ closed = true
738
+ inflight.clear()
739
+ try { db.close() } catch { /* already closed */ }
740
+ },
741
+ })
742
+ }