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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/.dsh-plugin/bot-product.mjs +81 -5
  2. package/.dsh-plugin/client.js +17490 -1791
  3. package/README.i18n.yaml +4 -4
  4. package/README.md +409 -364
  5. package/README.zh.md +409 -364
  6. package/apps/integration-client/README.md +31 -7
  7. package/apps/integration-client/api.mjs +14 -3
  8. package/apps/integration-client/binding-editor.jsx +308 -109
  9. package/apps/integration-client/binding-model.mjs +262 -0
  10. package/apps/integration-client/binding.css +144 -0
  11. package/apps/integration-client/bot-model-status.mjs +18 -0
  12. package/apps/integration-client/bot-profile-identity.jsx +197 -0
  13. package/apps/integration-client/bot-profile.css +57 -0
  14. package/apps/integration-client/bot-profile.mjs +97 -0
  15. package/apps/integration-client/bot-settings-dialog.css +18 -0
  16. package/apps/integration-client/bot-settings-dialog.jsx +111 -0
  17. package/apps/integration-client/bot-thread-header-bridge.css +20 -0
  18. package/apps/integration-client/bot-thread-header.jsx +278 -0
  19. package/apps/integration-client/bot-threads-panel.jsx +378 -0
  20. package/apps/integration-client/bot-threads.css +169 -0
  21. package/apps/integration-client/chat-design.mjs +250 -0
  22. package/apps/integration-client/chat-ui.jsx +175 -0
  23. package/apps/integration-client/chat.css +477 -0
  24. package/apps/integration-client/composer-draft-binding.mjs +50 -0
  25. package/apps/integration-client/composer-draft-bound.jsx +14 -0
  26. package/apps/integration-client/composer-draft-host.jsx +142 -0
  27. package/apps/integration-client/composer-draft-session.jsx +287 -0
  28. package/apps/integration-client/composer-draft-witness.mjs +115 -0
  29. package/apps/integration-client/composer-draft.css +4 -0
  30. package/apps/integration-client/composer-draft.mjs +322 -0
  31. package/apps/integration-client/contact-attention.jsx +54 -0
  32. package/apps/integration-client/data-panel.jsx +439 -0
  33. package/apps/integration-client/data.css +69 -0
  34. package/apps/integration-client/desktop-bridge.mjs +1 -0
  35. package/apps/integration-client/desktop-http-socket.mjs +49 -0
  36. package/apps/integration-client/desktop-keyboard.mjs +32 -0
  37. package/apps/integration-client/desktop-pane.jsx +99 -30
  38. package/apps/integration-client/desktop-stream.mjs +13 -3
  39. package/apps/integration-client/desktop-tools.jsx +19 -0
  40. package/apps/integration-client/desktop-touch.mjs +27 -0
  41. package/apps/integration-client/desktop.css +41 -0
  42. package/apps/integration-client/independent-chat-controller.mjs +224 -0
  43. package/apps/integration-client/independent-chat-entry.jsx +61 -0
  44. package/apps/integration-client/independent-chat-navigation.mjs +21 -0
  45. package/apps/integration-client/independent-chat-page.jsx +148 -0
  46. package/apps/integration-client/independent-chat-rich.jsx +64 -0
  47. package/apps/integration-client/independent-chat-rich.mjs +30 -0
  48. package/apps/integration-client/independent-chat.css +73 -0
  49. package/apps/integration-client/index.jsx +24 -16
  50. package/apps/integration-client/library-panel.jsx +4 -1
  51. package/apps/integration-client/licenses/jsqr-1.4.0-LICENSE.txt +202 -0
  52. package/apps/integration-client/main-route-editor.jsx +94 -0
  53. package/apps/integration-client/main-route-model.mjs +100 -0
  54. package/apps/integration-client/model-picker.css +37 -0
  55. package/apps/integration-client/model-picker.jsx +83 -0
  56. package/apps/integration-client/native-activity.css +141 -0
  57. package/apps/integration-client/native-activity.jsx +276 -0
  58. package/apps/integration-client/native-activity.mjs +384 -0
  59. package/apps/integration-client/notifications-panel.jsx +186 -0
  60. package/apps/integration-client/notifications.css +126 -0
  61. package/apps/integration-client/pairing-qr.jsx +79 -0
  62. package/apps/integration-client/panels.jsx +126 -115
  63. package/apps/integration-client/product-slots.jsx +46 -0
  64. package/apps/integration-client/routine-recorder.mjs +23 -2
  65. package/apps/integration-client/secure-pairing.jsx +84 -0
  66. package/apps/integration-client/tasks-panel.jsx +32 -10
  67. package/apps/integration-client/ui-icons.jsx +17 -0
  68. package/apps/integration-client/usage-explorer.jsx +43 -0
  69. package/apps/integration-client/usage-report.mjs +71 -0
  70. package/apps/integration-client/usage.css +34 -0
  71. package/apps/integration-client/web-panel-model.mjs +140 -0
  72. package/apps/integration-client/web-panel-page.jsx +221 -0
  73. package/apps/integration-client/web-panel-pins-live.jsx +28 -0
  74. package/apps/integration-client/web-panel.css +139 -0
  75. package/apps/integration-client/workspace-model.mjs +67 -0
  76. package/apps/integration-client/workspace-navigation.mjs +56 -0
  77. package/apps/integration-client/workspace-ui.jsx +173 -0
  78. package/apps/integration-client/workspace.css +332 -0
  79. package/modules/bot-runtime/SOURCE.json +176 -50
  80. package/modules/bot-runtime/index.mjs +169 -13
  81. package/modules/bot-runtime/product-overlay/OVERLAY.json +128 -2
  82. package/modules/bot-runtime/product-overlay/base/src/client/header.js +244 -0
  83. package/modules/bot-runtime/product-overlay/base/src/client/learning-card.js +202 -0
  84. package/modules/bot-runtime/product-overlay/base/src/client/model-menu.js +371 -0
  85. package/modules/bot-runtime/product-overlay/base/src/client/secret-card.js +363 -0
  86. package/modules/bot-runtime/product-overlay/base/src/client/sources.js +68 -0
  87. package/modules/bot-runtime/product-overlay/base/src/client/turn-error.js +260 -0
  88. package/modules/bot-runtime/product-overlay/base/src/host/channel.js +647 -0
  89. package/modules/bot-runtime/product-overlay/base/src/host/memory.js +2050 -0
  90. package/modules/bot-runtime/product-overlay/base/src/host/models.js +314 -0
  91. package/modules/bot-runtime/product-overlay/base/src/host/parts.js +1473 -0
  92. package/modules/bot-runtime/product-overlay/base/src/host/privacy.js +368 -0
  93. package/modules/bot-runtime/product-overlay/base/src/host/roster.js +776 -0
  94. package/modules/bot-runtime/product-overlay/base/src/host/store.js +512 -0
  95. package/modules/bot-runtime/product-overlay/base/src/host/usage.js +653 -0
  96. package/modules/bot-runtime/product-overlay/patches/src/client/details-drawer.js.patch +48 -2
  97. package/modules/bot-runtime/product-overlay/patches/src/client/header.js.patch +41 -0
  98. package/modules/bot-runtime/product-overlay/patches/src/client/index.js.patch +33 -1
  99. package/modules/bot-runtime/product-overlay/patches/src/client/learning-card.js.patch +20 -0
  100. package/modules/bot-runtime/product-overlay/patches/src/client/model-menu.js.patch +24 -0
  101. package/modules/bot-runtime/product-overlay/patches/src/client/secret-card.js.patch +25 -0
  102. package/modules/bot-runtime/product-overlay/patches/src/client/sources.js.patch +52 -0
  103. package/modules/bot-runtime/product-overlay/patches/src/client/turn-error.js.patch +23 -0
  104. package/modules/bot-runtime/product-overlay/patches/src/host/channel.js.patch +46 -0
  105. package/modules/bot-runtime/product-overlay/patches/src/host/memory.js.patch +11 -0
  106. package/modules/bot-runtime/product-overlay/patches/src/host/models.js.patch +16 -0
  107. package/modules/bot-runtime/product-overlay/patches/src/host/parts.js.patch +95 -0
  108. package/modules/bot-runtime/product-overlay/patches/src/host/privacy.js.patch +11 -0
  109. package/modules/bot-runtime/product-overlay/patches/src/host/roster.js.patch +145 -0
  110. package/modules/bot-runtime/product-overlay/patches/src/host/store.js.patch +68 -0
  111. package/modules/bot-runtime/product-overlay/patches/src/host/usage.js.patch +11 -0
  112. package/modules/bot-runtime/read-model.mjs +19 -4
  113. package/modules/bot-runtime/vendor/ds-bot/src/client/details-drawer.js +15 -5
  114. package/modules/bot-runtime/vendor/ds-bot/src/client/header.js +9 -2
  115. package/modules/bot-runtime/vendor/ds-bot/src/client/index.js +9 -2
  116. package/modules/bot-runtime/vendor/ds-bot/src/client/learning-card.js +2 -2
  117. package/modules/bot-runtime/vendor/ds-bot/src/client/model-menu.js +5 -3
  118. package/modules/bot-runtime/vendor/ds-bot/src/client/secret-card.js +3 -3
  119. package/modules/bot-runtime/vendor/ds-bot/src/client/sources.js +25 -2
  120. package/modules/bot-runtime/vendor/ds-bot/src/client/turn-error.js +3 -2
  121. package/modules/bot-runtime/vendor/ds-bot/src/host/channel.js +14 -2
  122. package/modules/bot-runtime/vendor/ds-bot/src/host/memory.js +1 -1
  123. package/modules/bot-runtime/vendor/ds-bot/src/host/models.js +7 -0
  124. package/modules/bot-runtime/vendor/ds-bot/src/host/parts.js +26 -9
  125. package/modules/bot-runtime/vendor/ds-bot/src/host/privacy.js +1 -1
  126. package/modules/bot-runtime/vendor/ds-bot/src/host/roster.js +112 -3
  127. package/modules/bot-runtime/vendor/ds-bot/src/host/store.js +34 -1
  128. package/modules/bot-runtime/vendor/ds-bot/src/host/usage.js +1 -1
  129. package/modules/independent-runtime/index.mjs +152 -62
  130. package/modules/independent-runtime/remote-tools.mjs +8 -5
  131. package/modules/openmaus-protocol/NOTICE +10 -0
  132. package/modules/openmaus-protocol/README.md +15 -0
  133. package/modules/openmaus-protocol/SOURCE.json +68 -0
  134. package/modules/openmaus-protocol/dist/protocol.mjs +1203 -0
  135. package/modules/openmaus-protocol/index.mjs +1 -0
  136. package/modules/openmaus-protocol/transport.mjs +77 -0
  137. package/modules/openmaus-protocol/vendor/LICENSE +202 -0
  138. package/modules/openmaus-protocol/vendor/companion/src/e2e/README.md +405 -0
  139. package/modules/openmaus-protocol/vendor/companion/src/e2e/credentials.ts +128 -0
  140. package/modules/openmaus-protocol/vendor/companion/src/e2e/descriptor.ts +84 -0
  141. package/modules/openmaus-protocol/vendor/companion/src/e2e/noise.ts +456 -0
  142. package/modules/openmaus-protocol/vendor/companion/src/e2e/phone.ts +104 -0
  143. package/modules/openmaus-protocol/vendor/companion/src/e2e/session.ts +417 -0
  144. package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.json +2565 -0
  145. package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.sha256 +1 -0
  146. package/modules/openmaus-protocol/vendor/companion/src/e2e/yamux.ts +335 -0
  147. package/package.json +20 -6
  148. package/packages/bot-acp/README.md +156 -0
  149. package/packages/bot-acp/licenses/LICENSE-OpenMausBot +202 -0
  150. package/packages/bot-acp/licenses/NOTICE.md +27 -0
  151. package/packages/bot-acp/src/acp-adapter.mjs +795 -0
  152. package/packages/bot-acp/src/acp-host.mjs +148 -0
  153. package/packages/bot-acp/src/native-launch.mjs +121 -0
  154. package/packages/bot-acp/src/permission-policy.mjs +283 -0
  155. package/packages/bot-acp/src/rpc-codec.mjs +265 -0
  156. package/packages/bot-acp/src/session-core.mjs +581 -0
  157. package/packages/bot-acp/src/stdio-rpc.mjs +329 -0
  158. package/packages/bot-engines/README.md +281 -0
  159. package/packages/bot-engines/licenses/LICENSE-OpenMausBot +202 -0
  160. package/packages/bot-engines/licenses/NOTICE.md +32 -0
  161. package/packages/bot-engines/src/claude-engine.mjs +772 -0
  162. package/packages/bot-engines/src/claude-llm-adapter.mjs +797 -0
  163. package/packages/bot-engines/src/claude-llm-turn.mjs +292 -0
  164. package/packages/bot-engines/src/claude-permission-broker.mjs +130 -0
  165. package/packages/bot-engines/src/claude-permission-proxy.mjs +87 -0
  166. package/packages/bot-engines/src/claude-protocol.mjs +177 -0
  167. package/packages/bot-engines/src/driver-catalog.mjs +334 -0
  168. package/packages/bot-engines/src/host-loop-request.mjs +95 -0
  169. package/packages/bot-engines/src/host-registrations.mjs +105 -0
  170. package/packages/capability-broker/src/cli-bootstrap.mjs +86 -16
  171. package/packages/capability-broker/src/cli-bridge-response.mjs +80 -0
  172. package/packages/capability-broker/src/cli-stdio-bridge.mjs +21 -8
  173. package/packages/cli-runtime/src/codex-session-core.mjs +4 -0
  174. package/packages/cli-runtime/src/native-cli-output.mjs +57 -0
  175. package/packages/cli-runtime/src/native-peer.ps1 +51 -3
  176. package/packages/cli-runtime/src/native-process.mjs +77 -14
  177. package/packages/model-router-plugin/README.md +3 -3
  178. package/packages/model-router-plugin/package.json +1 -1
  179. package/packages/orchestration/src/binding-product.mjs +19 -0
  180. package/packages/orchestration/src/bot-data-actions.mjs +45 -0
  181. package/packages/orchestration/src/bot-data-chart.mjs +109 -0
  182. package/packages/orchestration/src/bot-data-context.mjs +44 -0
  183. package/packages/orchestration/src/bot-data-engine.mjs +809 -0
  184. package/packages/orchestration/src/bot-data-io.mjs +186 -0
  185. package/packages/orchestration/src/bot-data-sql.mjs +195 -0
  186. package/packages/orchestration/src/bot-data-surface.mjs +144 -0
  187. package/packages/orchestration/src/bot-data-tools.mjs +68 -0
  188. package/packages/orchestration/src/bot-data-worker.mjs +228 -0
  189. package/packages/orchestration/src/bot-independent-execution.mjs +195 -0
  190. package/packages/orchestration/src/bot-independent-stream.mjs +74 -0
  191. package/packages/orchestration/src/bot-loop.mjs +152 -19
  192. package/packages/orchestration/src/bot-notifications.mjs +159 -0
  193. package/packages/orchestration/src/bot-policy.mjs +43 -4
  194. package/packages/orchestration/src/bot-profile.mjs +171 -0
  195. package/packages/orchestration/src/bot-routine.mjs +4 -4
  196. package/packages/orchestration/src/bot-runtime-assembly.mjs +255 -8
  197. package/packages/orchestration/src/bot-runtime-switch.mjs +13 -2
  198. package/packages/orchestration/src/bot-serving.mjs +164 -0
  199. package/packages/orchestration/src/bot-session-store.mjs +105 -9
  200. package/packages/orchestration/src/bot-settings.mjs +50 -11
  201. package/packages/orchestration/src/bot-thread-admission.mjs +84 -0
  202. package/packages/orchestration/src/bot-thread-host.mjs +187 -0
  203. package/packages/orchestration/src/bot-threads.mjs +790 -0
  204. package/packages/orchestration/src/data-product-access.mjs +37 -0
  205. package/packages/orchestration/src/data-tool-contract.mjs +291 -0
  206. package/packages/orchestration/src/desktop-http-carrier.mjs +120 -0
  207. package/packages/orchestration/src/desktop-proxy.mjs +311 -0
  208. package/packages/orchestration/src/desktop-sessions.mjs +43 -9
  209. package/packages/orchestration/src/ds-lifecycle.mjs +31 -4
  210. package/packages/orchestration/src/grant-refresh-queue.mjs +17 -0
  211. package/packages/orchestration/src/independent-chat-page.mjs +39 -0
  212. package/packages/orchestration/src/independent-chat-service.mjs +308 -0
  213. package/packages/orchestration/src/independent-context-state.mjs +44 -0
  214. package/packages/orchestration/src/independent-message-wire.mjs +84 -0
  215. package/packages/orchestration/src/independent-tool-catalog.mjs +175 -0
  216. package/packages/orchestration/src/independent-tool-families.mjs +73 -0
  217. package/packages/orchestration/src/independent-tool-host.mjs +344 -0
  218. package/packages/orchestration/src/main-bot-grant.mjs +15 -0
  219. package/packages/orchestration/src/main-model-routes.mjs +208 -0
  220. package/packages/orchestration/src/model-accounting.mjs +99 -35
  221. package/packages/orchestration/src/model-auxiliary-dispatch.mjs +187 -0
  222. package/packages/orchestration/src/model-loop-frame.mjs +179 -0
  223. package/packages/orchestration/src/model-prepared-generation.mjs +175 -0
  224. package/packages/orchestration/src/model-request-bounds.mjs +111 -0
  225. package/packages/orchestration/src/native-activity-contract.mjs +491 -0
  226. package/packages/orchestration/src/native-activity-service.mjs +102 -0
  227. package/packages/orchestration/src/native-activity-store.mjs +249 -0
  228. package/packages/orchestration/src/native-conversation-tools.mjs +78 -0
  229. package/packages/orchestration/src/native-data-tools.mjs +100 -0
  230. package/packages/orchestration/src/native-remote-actions.mjs +742 -0
  231. package/packages/orchestration/src/native-team-tools.mjs +73 -0
  232. package/packages/orchestration/src/pairing-invite.mjs +37 -0
  233. package/packages/orchestration/src/product-composition.mjs +1559 -1001
  234. package/packages/orchestration/src/remote-machines.mjs +82 -14
  235. package/packages/orchestration/src/routine-execution.mjs +5 -3
  236. package/packages/orchestration/src/routine-session.mjs +19 -13
  237. package/packages/orchestration/src/secure-pairing-host.mjs +112 -0
  238. package/packages/orchestration/src/task-journal.mjs +29 -0
  239. package/packages/orchestration/src/task-product.mjs +17 -0
  240. package/packages/orchestration/src/team-assignment-store.mjs +120 -0
  241. package/packages/orchestration/src/team-bridge.mjs +282 -0
  242. package/packages/orchestration/src/team-scheduler.mjs +83 -0
  243. package/packages/orchestration/src/team-sdk-tools.mjs +84 -0
  244. package/packages/orchestration/src/team-tool-contract.mjs +284 -0
  245. package/packages/orchestration/src/ui-host-namespace.mjs +23 -0
  246. package/packages/orchestration/src/web-panel-actions.mjs +103 -0
  247. package/packages/orchestration/src/web-panel-framing.mjs +227 -0
  248. package/packages/orchestration/src/web-panel-store.mjs +206 -0
  249. package/remote-computer-agent/README.md +39 -29
  250. package/remote-computer-agent/build.mjs +3 -0
  251. package/remote-computer-agent/dist/OPENMAUSBOT_LICENSE +202 -0
  252. package/remote-computer-agent/dist/OPENMAUSBOT_NOTICE +10 -0
  253. package/remote-computer-agent/dist/OPENMAUSBOT_SOURCE.json +68 -0
  254. package/remote-computer-agent/dist/cli-stdio-bridge.mjs +215 -25
  255. package/remote-computer-agent/dist/cli-stdio-bridge.mjs.map +4 -4
  256. package/remote-computer-agent/dist/cli.mjs +1688 -168
  257. package/remote-computer-agent/dist/cli.mjs.map +4 -4
  258. package/remote-computer-agent/dist/index.mjs +1579 -122
  259. package/remote-computer-agent/dist/index.mjs.map +4 -4
  260. package/remote-computer-agent/dist/native-peer.ps1 +51 -3
  261. package/remote-computer-agent/package-lock.json +308 -2
  262. package/remote-computer-agent/package.json +2 -1
  263. package/remote-computer-agent/pair-device.ps1 +20 -0
  264. package/remote-computer-agent/setup.ps1 +38 -16
  265. package/remote-computer-agent/src/cli.ts +24 -3
  266. package/remote-computer-agent/src/desktop-session.ts +11 -3
  267. package/remote-computer-agent/src/e2e-server.ts +81 -0
  268. package/remote-computer-agent/src/pairing-page.ts +35 -0
  269. package/remote-computer-agent/src/protocol.ts +25 -25
  270. package/remote-computer-agent/src/secure-pairing.ts +105 -0
  271. package/remote-computer-agent/src/store.ts +20 -5
  272. package/remote-computer-agent/src/transport.ts +39 -15
@@ -1,1001 +1,1559 @@
1
- import { createBotRoutingService } from './bot-routing-service.mjs'
2
- import { openBotRoutingSettings } from './bot-routing-settings.mjs'
3
- import { createWorkerBindingCommitter } from './bot-routing-dispatch.mjs'
4
- import { createBotTurnBackends } from './bot-routing-runtime.mjs'
5
- import { createRoutineExecutionService } from './routine-execution.mjs'
6
- import { createMachineHeartbeat } from './machine-heartbeat.mjs'
7
- import { pairMainMachine } from './pair-main-machine.mjs'
8
- import { assertHandoffAllowed, buildHandoff, createHandoffLedger } from './bot-handoff.mjs'
9
- import { listRoles } from './bot-roles.mjs'
10
- import { hostname } from 'node:os'
11
- import { join, dirname, isAbsolute } from 'node:path'
12
- import { mkdir } from 'node:fs/promises'
13
- import { createRequire } from 'node:module'
14
- import { randomUUID } from 'node:crypto'
15
- import { defineTool } from '@deepseek-ai/dsh-tools'
16
- import { defaultStatePath, resolveStateHome } from '../../../.dsh-plugin/shared/router-state.mjs'
17
- import { actualCost } from '../../../.dsh-plugin/shared/run-ledger.mjs'
18
- import { reservationSpending } from '../../../.dsh-plugin/shared/budget-reservations.mjs'
19
- import { openProductFunding } from './product-funding.mjs'
20
- import { createAuthority } from '../../capability-broker/src/authority.mjs'
21
- import { createPrincipalDirectory } from './principals.mjs'
22
- import { createBotPolicy } from './bot-policy.mjs'
23
- import { createBotProduct } from '../../../modules/bot-runtime/index.mjs'
24
- import { openRemoteMachines } from './remote-machines.mjs'
25
- import { guardMachineSecrets } from './machine-secrets.mjs'
26
- import { openBotSettings } from './bot-settings.mjs'
27
- import { openFundingAccounts } from './funding-accounts.mjs'
28
- import { createModelAccounting } from './model-accounting.mjs'
29
- import { createTaskService } from './task-service.mjs'
30
- import { inspectProductTask, projectProductTask } from './task-product.mjs'
31
- import { createDesktopActions } from './desktop-actions.mjs'
32
- import { registerTaskTools } from './task-tools.mjs'
33
- import { createMrReadBackend } from './mr-read-backend.mjs'
34
- import { createCliReadBackend } from './cli-execution.mjs'
35
- import { createRemoteWriteBackend } from './remote-execution.mjs'
36
- import { createRemoteCommandBackend } from './remote-command-execution.mjs'
37
- import { createRemoteCliReadBackend } from './remote-cli-execution.mjs'
38
- import { canonicalTaskJson, parseRemoteTaskCli, taskHash } from './task-contract.mjs'
39
- import { acquireMigrationShared } from './migration-fence.mjs'
40
- import { assertMigrationStartupAllowed } from './migration-startup-gate.mjs'
41
- import { registerRemoteComputerTools } from './remote-computer-tools.mjs'
42
-
43
- // The default Bot grant exposes the complete router through the same policy.
44
- // Exact model routes and side-effect approvals remain independently enforced.
45
- export const PRODUCT_TOOLS = Object.freeze(['model/infer',
46
- 'tool/model_router_routes', 'tool/model_router_plan', 'tool/model_router_consult', 'tool/model_router_health', 'tool/model_router_tools',
47
- 'tool/model_router_execute', 'tool/model_router_team_execute', 'tool/model_router_tool_run', 'tool/model_router_rerun_step',
48
- 'tool/model_router_tool_install', 'tool/model_router_tool_repair', 'tool/model_router_tool_uninstall', 'tool/model_router_rate',
49
- 'command/router', 'tool/bot_library_list', 'tool/bot_library_read', 'tool/bot_library_write', 'tool/bot_library_search', 'tool/bot_route_task',
50
- 'tool/bot_routine_list', 'tool/bot_routine_save', 'tool/bot_routine_run'])
51
- export const PRODUCT_OWNER_READ_METHODS = Object.freeze(['list','installSettings','status','health','ledger','boundaries','previewRun','runStatus','terminalInfo','terminalRead'])
52
- export const PRODUCT_OWNER_WRITE_METHODS = Object.freeze(['completeOnboarding','rateResult','rerunStep','startRun','cancelRun','installTool','uninstallTool','repairTool','cancel','terminalStart','terminalWrite','terminalResize','terminalStop'])
53
- // The Bot runtime reports descriptive call origins; the durable receipt schema
54
- // keeps its closed vocabulary. The adapter is explicit: an unmapped origin is
55
- // refused by the accounting layer instead of being invented.
56
- const RECEIPT_SOURCE = Object.freeze({ 'bot-session': 'bot', 'bot-auxiliary': 'aux' })
57
- const ROUTE_REFRESH_MS = 30_000
58
- const ROUTE_REFRESH_DEBOUNCE_MS = 2_000
59
- const INITIAL_ROUTE_TIMEOUT_MS = 5_000
60
- const OBSERVATION_LIMIT = 256
61
- const validPolicy = value => Boolean(value) && ['run', 'check', 'filterRoutes'].every(key => typeof value[key] === 'function')
62
- // N09: one managed Task runner over the same SQLite authority. A crashed Host
63
- // cannot release its runner lease, so the next boot waits for it to expire
64
- // instead of silently dropping every managed task.
65
- const TASK_RUNNER_TTL_MS = 10_000
66
- const TASK_RUNNER_CLAIM_MS = 12_000
67
- // Frequent enough that a stopped runner is rebuilt inside one lease period,
68
- // rare enough that a healthy Host only reads one row every five seconds.
69
- const TASK_RUNNER_WATCHDOG_MS = 5_000
70
- const TASK_TTL_MS = 120_000
71
- const TASK_BACKEND_LIMIT = 64
72
- const TASK_READ_LIMIT = 512
73
- const CLI_DEADLINE_MS = 120_000
74
- const CLI_MODEL = /^gpt-[A-Za-z0-9_.-]+$/
75
- const CLI_ENTRY_VERSION = /^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/
76
- const TASK_TOOLS = Object.freeze(['task/execute', 'task/submit', 'task/read', 'task/cancel', 'task/retry', 'task/reconcile'])
77
- const TASK_CAPABILITY = Object.freeze({ id: 'managed-tasks', label: '托管任务',
78
- tools: Object.freeze([...TASK_TOOLS, 'execution/mr-api', 'execution/mr-retry', 'execution/mr-observe',
79
- 'execution/bot-turn', 'execution/bot-turn-retry', 'execution/bot-turn-observe']) })
80
- const CLI_GRANT_TOOLS = Object.freeze(['cli/codex', 'cli/cancel', 'cli/read', 'cli/retry'])
81
- const MR_BACKEND = Object.freeze({ id: 'mr-api', version: 'mr-read-product-v1',
82
- capability: 'execution/mr-api', retryCapability: 'execution/mr-retry', reconcileCapability: 'execution/mr-observe' })
83
- const CLI_BACKEND = Object.freeze({ id: 'codex-read', version: 'cli-read-product-v1',
84
- capability: 'cli/codex', retryCapability: 'cli/retry', reconcileCapability: 'cli/read' })
85
-
86
- /**
87
- * Compile one Bot's authority from its committed configuration. A Bot whose
88
- * exact provider/model is not a configured route keeps no model authority:
89
- * the strict rule pauses it instead of silently switching models.
90
- */
91
- export function compileProductGrant(bot, { routes, hydrated }) {
92
- const key = typeof bot?.model === 'string' ? bot.model : ''
93
- const slash = key.indexOf('/')
94
- const provider = slash > 0 ? key.slice(0, slash) : ''
95
- const model = slash > 0 ? key.slice(slash + 1) : ''
96
- return { tools: [...PRODUCT_TOOLS],
97
- modelRoutes: hydrated && provider && model && routes.has(key) ? [{ provider, model }] : [] }
98
- }
99
-
100
- /**
101
- * An action that is only meaningful for a signed-in person says so at its own
102
- * entry. The authorization seam decides who may call, but a route that forgets to
103
- * declare its caller class inherits whatever the seam defaults to, and a later
104
- * change there would hand a non-owner somebody's console.
105
- */
106
- const ownerOnly = fn => (payload, context) => {
107
- if (context?.kind !== 'owner') throw Object.assign(new Error('OWNER_ONLY'), { code: 'OWNER_ONLY' })
108
- return fn(payload, context)
109
- }
110
-
111
- /** Subscription usage is not API spend; missing token evidence stays unknown. */
112
- export function projectModelCallAmount(receipt, resolution = null) {
113
- if (resolution) return resolution.costUsd
114
- if (receipt.funding === 'subscription') return 0
115
- return actualCost({ usage: receipt.usage, usageIncomplete: !receipt.usageComplete,
116
- reportedCostUsd: receipt.reportedCostUsd }, receipt.pricing).costUsd
117
- }
118
-
119
- const taskInput = input => {
120
- const value = JSON.parse(canonicalTaskJson(input))
121
- if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== 1
122
- || typeof value.task !== 'string' || !value.task.trim() || Buffer.byteLength(value.task) > 2000) {
123
- throw Object.assign(new Error('INVALID_TASK_INPUT'), { code: 'INVALID_TASK_INPUT' })
124
- }
125
- return value
126
- }
127
-
128
- /**
129
- * Host-configured official CLI entry. The executable, digest and version come
130
- * from installation verification (`pinCodexExecutable` re-pins them at probe
131
- * time); neither a Bot nor a Task input can supply an entry.
132
- */
133
- export function normalizeTaskCli(value) {
134
- if (value === undefined || value === null) return null
135
- const entry = value.entry
136
- const remote = (Array.isArray(value.remote) ? value.remote : []).map(profile => {
137
- if (!profile || typeof profile !== 'object' || Array.isArray(profile)
138
- || Object.keys(profile).some(key => !['machineId','rootId','profileId','profileRevision','entry'].includes(key))
139
- || !['machineId','rootId','profileId'].every(key => typeof profile[key] === 'string' && /^[-A-Za-z0-9_.]{1,128}$/.test(profile[key]))
140
- || !Number.isSafeInteger(profile.profileRevision) || profile.profileRevision < 1
141
- || !profile.entry || Object.keys(profile.entry).some(key => !['sha256','version'].includes(key))
142
- || !/^[a-f0-9]{64}$/.test(profile.entry.sha256 ?? '') || !CLI_ENTRY_VERSION.test(profile.entry.version ?? '')) throw new TypeError('TASK_CLI_REMOTE_PROFILE_INVALID')
143
- return Object.freeze({ machineId: profile.machineId, rootId: profile.rootId, profileId: profile.profileId, profileRevision: profile.profileRevision,
144
- entry: Object.freeze({ sha256: profile.entry.sha256, version: profile.entry.version }) })
145
- })
146
- if (new Set(remote.map(profile => `${profile.machineId}/${profile.rootId}`)).size !== remote.length) throw new TypeError('TASK_CLI_REMOTE_PROFILE_DUPLICATE')
147
- if (!entry && !remote.length) throw new TypeError('TASK_CLI_ENTRY_REQUIRED')
148
- if (entry && (typeof entry.executable !== 'string' || !isAbsolute(entry.executable)
149
- || typeof entry.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(entry.sha256)
150
- || typeof entry.version !== 'string' || !CLI_ENTRY_VERSION.test(entry.version))) throw new TypeError('TASK_CLI_ENTRY_REQUIRED')
151
- const models = (Array.isArray(value.models) ? value.models : []).map(model => {
152
- if (!model || typeof model.provider !== 'string' || typeof model.model !== 'string'
153
- || typeof model.cliModel !== 'string' || !CLI_MODEL.test(model.cliModel)) throw new TypeError('TASK_CLI_MODEL_REQUIRED')
154
- return { provider: model.provider, model: model.model, cliModel: model.cliModel }
155
- })
156
- if (new Set(models.map(model => `${model.provider}/${model.model}/${model.cliModel}`)).size !== models.length) throw new TypeError('TASK_CLI_MODEL_DUPLICATE')
157
- if (value.sessionFactory !== undefined && typeof value.sessionFactory !== 'string') throw new TypeError('TASK_CLI_SESSION_FACTORY_REQUIRED')
158
- return Object.freeze({ entry: entry ? Object.freeze({ executable: entry.executable, sha256: entry.sha256, version: entry.version }) : null,
159
- models: Object.freeze(models), remote: Object.freeze(remote), sessionFactory: value.sessionFactory ?? null })
160
- }
161
-
162
- /**
163
- * Stable managed Task backends for the default product.
164
- *
165
- * The durable target contract (id, version, effect, location, capabilities)
166
- * is fixed per backend kind, while the execution target, model and money are
167
- * resolved at run time from the submitting Bot's committed binding. A binding
168
- * change therefore never rewrites a persisted target hash: retries and
169
- * reconciliations of older tasks stay admissible, and each Bot is charged at
170
- * its own configured price. The Bot binding is the single source of truth for
171
- * both the grant and the money, so no service-level model check is needed.
172
- */
173
- export function createTaskBackends({ ctx, mr, authority, directory, store, machineId, workspaceId, workspace, runtimeRoot, bindingFor, cli = null }) {
174
- if (typeof bindingFor !== 'function' || typeof directory?.forBot !== 'function') throw new TypeError('TASK_BACKEND_SERVICES_REQUIRED')
175
- if (store?.kind !== 'sqlite-router-state') throw new TypeError('TASK_BACKEND_STORE_REQUIRED')
176
- const inners = new Map()
177
- const innerFor = (kind, botId, build) => {
178
- const key = taskHash({ kind, botId, ...build.key })
179
- const cached = inners.get(key)
180
- if (cached) return cached
181
- if (inners.size >= TASK_BACKEND_LIMIT) return null
182
- const value = build.create()
183
- inners.set(key, value)
184
- return value
185
- }
186
- const mrInnerFor = botId => {
187
- const binding = bindingFor(botId)
188
- const settings = binding?.status === 'available' ? binding.settings : null
189
- const funding = settings?.funding ?? null
190
- if (!settings?.model?.provider || !settings.model.model || funding?.mode !== 'api'
191
- || !funding.pricing || !funding.request || funding.dailyBudgetUsd === null) return null
192
- const model = { provider: settings.model.provider, model: settings.model.model }
193
- const pricing = funding.pricing, dailyBudgetUsd = funding.dailyBudgetUsd
194
- return innerFor('mr', botId, { key: { model, pricing, dailyBudgetUsd },
195
- create: () => createMrReadBackend({ ctx, mr, model, workspace, machineId, workspaceId, pricing, dailyBudgetUsd }) })
196
- }
197
- const cliInnerFor = botId => {
198
- if (!cli) return null
199
- const binding = bindingFor(botId)
200
- const settings = binding?.status === 'available' ? binding.settings : null
201
- const funding = settings?.funding ?? null
202
- const tool = settings?.officialTools?.find(item => item.toolId === 'codex' && item.mode === 'read-only')
203
- if (!settings?.model?.provider || !settings.model.model || !tool || !funding?.scopeId) return null
204
- if (funding.mode === 'api' && (!funding.pricing || !funding.request)) return null
205
- if (funding.mode === 'subscription' && !(funding.subscription?.dailyCalls >= 1)) return null
206
- const model = { provider: settings.model.provider, model: settings.model.model }
207
- const billing = funding.mode
208
- const pricing = billing === 'api' ? funding.pricing : null
209
- const estimatedCostUsd = billing === 'api'
210
- ? (funding.request.maxInputTokens * pricing.input + funding.request.maxOutputTokens * pricing.output) / 1e6 : null
211
- const dailyLimitUsd = funding.dailyBudgetUsd ?? 0, monthlyLimitUsd = funding.monthlyBudgetUsd ?? 0
212
- const subscriptionMaxCalls = billing === 'subscription' ? funding.subscription.dailyCalls : null
213
- return innerFor('cli', botId, { key: { model, cliModel: tool.cliModel, billing, pricing, dailyLimitUsd, monthlyLimitUsd, subscriptionMaxCalls },
214
- create: () => createCliReadBackend({ store, authority, directory, entry: cli.entry, runtimeRoot, workspace, machineId, workspaceId,
215
- model, requestedModel: tool.cliModel, billing, pricing, estimatedCostUsd, dailyLimitUsd, monthlyLimitUsd,
216
- subscriptionMaxCalls, deadlineMs: CLI_DEADLINE_MS, tools: [], handlers: [], sessionFactory: cli.sessionFactory }) })
217
- }
218
- const backend = (spec, resolve) => Object.freeze({
219
- id: spec.id, version: spec.version, effect: 'read', machineId, workspaceId, location: 'local',atomicAdmission:true,
220
- capability: spec.capability, retryCapability: spec.retryCapability, reconcileCapability: spec.reconcileCapability,
221
- parse: taskInput,
222
- run: context => { const inner = resolve(context.ownerBotId); return inner ? inner.run(context) : { outcome: 'failed', summary: { code: 'TASK_BINDING_UNAVAILABLE' } } },
223
- reconcile: context => { const inner = resolve(context.ownerBotId); return inner ? inner.reconcile(context) : { outcome: 'unknown', reason: 'TASK_BINDING_UNAVAILABLE' } },
224
- })
225
- const backends = [backend(MR_BACKEND, mrInnerFor)]
226
- if (cli) backends.push(backend(CLI_BACKEND, cliInnerFor))
227
- return Object.freeze({ backends: Object.freeze(backends),
228
- catalog: () => backends.map((item, index) => ({ id: item.id, version: item.version, effect: item.effect, location: item.location,
229
- capability: item.capability, label: index === 0 ? '模型路由只读任务' : '官方 CLI 只读任务' })) })
230
- }
231
-
232
- function createRemoteCliTaskBackend({machines,directory,store,authority,profiles,bindingFor}){
233
- const inners=new Map()
234
- const resolve=botId=>{
235
- const binding=bindingFor(botId),settings=binding?.status==='available'?binding.settings:null,funding=settings?.funding??null
236
- const tool=settings?.officialTools?.find(item=>item.toolId==='codex'&&item.mode==='read-only')
237
- if(!settings?.model?.provider||!settings.model.model||!tool||!funding?.scopeId)return null
238
- if(funding.mode==='api'&&(!funding.pricing||!funding.request))return null
239
- if(funding.mode==='subscription'&&!(funding.subscription?.dailyCalls>=1))return null
240
- const model={provider:settings.model.provider,model:settings.model.model},billing=funding.mode,pricing=billing==='api'?funding.pricing:null
241
- const estimatedCostUsd=billing==='api'?(funding.request.maxInputTokens*pricing.input+funding.request.maxOutputTokens*pricing.output)/1e6:null
242
- const dailyLimitUsd=funding.dailyBudgetUsd??0,monthlyLimitUsd=funding.monthlyBudgetUsd??0,subscriptionMaxCalls=billing==='subscription'?funding.subscription.dailyCalls:null
243
- const key=taskHash({botId,model,cliModel:tool.cliModel,billing,pricing,dailyLimitUsd,monthlyLimitUsd,subscriptionMaxCalls})
244
- if(inners.has(key))return inners.get(key)
245
- if(inners.size>=TASK_BACKEND_LIMIT)return null
246
- const inner=createRemoteCliReadBackend({machines,directory,store,authority,profiles,model,requestedModel:tool.cliModel,billing,pricing,estimatedCostUsd,dailyLimitUsd,monthlyLimitUsd,subscriptionMaxCalls,deadlineMs:CLI_DEADLINE_MS})
247
- inners.set(key,inner);return inner
248
- }
249
- const forSubject=subject=>resolve(authority.describe(subject).botId)
250
- const unavailable=()=>{throw Object.assign(new Error('TASK_BINDING_UNAVAILABLE'),{code:'TASK_BINDING_UNAVAILABLE'})}
251
- return Object.freeze({id:'remote-codex-read',version:'remote-cli-read-product-v1',effect:'read',location:'remote',atomicAdmission:true,requiresExecutionInput:true,
252
- machineId:'remote-target',workspaceId:'remote-root',capability:'remote/cli_task_v1',retryCapability:'remote/cli_task_v1',reconcileCapability:'remote/operation_status',parse:parseRemoteTaskCli,
253
- targetFor(subject,input){return (forSubject(subject)??unavailable()).targetFor(subject,input)},
254
- canObserve(subject,target,input){return forSubject(subject)?.canObserve(subject,target,input)??false},
255
- run(context){const inner=resolve(context.ownerBotId);return inner?inner.run(context):{outcome:'failed',summary:{code:'TASK_BINDING_UNAVAILABLE'}}},
256
- reconcile(context){const inner=resolve(context.ownerBotId);return inner?inner.reconcile(context):{outcome:'unknown',reason:'TASK_BINDING_UNAVAILABLE'}},
257
- probe(subject,input){const inner=forSubject(subject);if(!inner)unavailable();return inner.probe(subject,input)},
258
- async close(){await Promise.allSettled([...inners.values()].map(inner=>inner.close()))},
259
- })
260
- }
261
-
262
- /**
263
- * Compose the frozen DS Bot runtime with the MR plugin body inside one Host.
264
- * The synchronous segment creates the shared authority/directory/policy and
265
- * provides `botIntegrationPolicy` before this function first returns, so every
266
- * legacy MR tool, command and RPC receiver resolves the same policy at call
267
- * time. The DS runtime itself is created after a bounded initial route
268
- * snapshot, because grant compilation is synchronous.
269
- *
270
- * When another trusted Host component already provides a valid
271
- * `botIntegrationPolicy` (isolated test fixtures), this composition defers to
272
- * it instead of installing a second runtime or a duplicate service.
273
- *
274
- * `accountStream` is the shared money decorator. M1 passes a clearly labeled
275
- * diagnostic decorator: it records bounded identity/source observations and
276
- * never claims a monetary amount. Per-source receipts and budget admission
277
- * arrive with the unified SQLite money authority.
278
- */
279
- export function startBotProduct(ctx, options = {}) {
280
- const { discoverRoutes, accountStream, config = {}, actions = {}, machineId = `local-${hostname()}`, workspaceId = 'default',
281
- runtimeConfig, onBind, refreshIntervalMs = ROUTE_REFRESH_MS, now = Date.now, failClosed = true,
282
- deferredRouterState = null, legacyFile = defaultStatePath(), pricingFor = async () => null, mr = null, taskCli = null,
283
- Database = createRequire(import.meta.url)('better-sqlite3'), storageFile = join(resolveStateHome(), 'model-router', 'orchestration.sqlite') } = options
284
- if (discoverRoutes !== undefined && typeof discoverRoutes !== 'function') throw new TypeError('BOT_PRODUCT_ROUTES_REQUIRED')
285
- if (typeof accountStream !== 'function') throw new TypeError('BOT_PRODUCT_ACCOUNTING_REQUIRED')
286
- // A misconfigured official CLI entry must fail loudly at composition time
287
- // instead of silently degrading the managed Task surface.
288
- const cliConfig = normalizeTaskCli(taskCli)
289
- // Grant compilation needs the raw configured-route catalog. The plugin's
290
- // discoverConfiguredRoutes applies the per-subject policy filter, which
291
- // refuses outside an execution context; a filtered catalog would leave every
292
- // Bot without model authority.
293
- const catalog = async () => {
294
- if (typeof discoverRoutes === 'function') return discoverRoutes()
295
- const providers = ctx.llm?.listProviders?.() ?? []
296
- const routes = []
297
- for (const entry of Array.isArray(providers) ? providers : []) {
298
- const provider = typeof entry === 'string' ? entry : entry?.id
299
- if (!provider) continue
300
- let models
301
- try { models = await ctx.llm.listModels(provider) } catch { continue }
302
- for (const listed of Array.isArray(models) ? models : []) {
303
- const model = listed?.id ?? listed?.model
304
- if (model) routes.push({ provider, model })
305
- }
306
- }
307
- return routes
308
- }
309
- let provided
310
- try { provided = ctx.get?.('botIntegrationPolicy') } catch { /* absent optional service */ }
311
- if (provided !== undefined) {
312
- if (!validPolicy(provided)) throw new Error('BOT_PRODUCT_POLICY_CONFLICT: an invalid botIntegrationPolicy is already provided')
313
- return null
314
- }
315
- const authority = createAuthority()
316
- const directory = createPrincipalDirectory(authority)
317
- ctx.provide('botIdForAgent', agent => {
318
- try { return authority.describe(directory.forAgent(agent)).botId } catch { return null }
319
- })
320
- const ownerTools=[...PRODUCT_OWNER_READ_METHODS,...PRODUCT_OWNER_WRITE_METHODS].map(name=>`remote/${name}`)
321
- const policy = createBotPolicy({ authority, directory, machineId, workspaceId,ownerGrant:{tools:ownerTools} })
322
- ctx.provide('botIntegrationPolicy', policy)
323
- // Bot routing reads the same committed state everything else obeys: the Bot list, each Bot's
324
- // saved settings, and machine reachability from health. It never invents a candidate.
325
- // Routines live beside the router state; the plugin owns that directory and provides the store.
326
- // Handing work between Bots: the ledger makes loops impossible, the plan carries the sender.
327
- const handoffLedger = createHandoffLedger()
328
- const handoffTask = async (args, context) => {
329
- if (context?.kind !== 'bot' || context.botId !== args.fromBotId
330
- || !product?.lifecycle.committedMainBotIds().includes(context.botId)) throw new Error('BOT_ROUTING_HANDOFF_DENIED')
331
- assertHandoffAllowed({ taskId: args.taskId, fromBotId: args.fromBotId, toBotId: args.toBotId, ledger: handoffLedger })
332
- const { plan } = buildHandoff({ taskId: args.taskId, fromBotId: args.fromBotId, toBotId: args.toBotId, objective: args.objective })
333
- if (!taskService) throw new Error('TASK_SERVICE_UNAVAILABLE')
334
- plan.origin.sessionId = context.sessionId
335
- for (const item of plan.packages) item.execution = { backend: 'bot-turn-read', input: { task: item.objective } }
336
- const submitted = await taskService.submitPlan(taskOwner({ botId: args.fromBotId }), { requestKey: `handoff-${args.taskId}-${args.toBotId}`, plan }, { sessionId: context.sessionId })
337
- turnBackends?.watchPlan({ planId: submitted.planId, botId: args.toBotId, originBotId: args.fromBotId })
338
- handoffLedger.held(args.taskId, args.fromBotId)
339
- handoffLedger.held(args.taskId, args.toBotId)
340
- return { plan, submitted }
341
- }
342
- const routingSettingsFor = botId => {
343
- try { const row = botSettings?.get(botId, { publishChange: false }); return row ? { ...row.settings, grant: row.grant, status: row.status } : null }
344
- catch { return null }
345
- }
346
- const commitWorkerBinding = createWorkerBindingCommitter({ machines: () => machines, settingsFor: routingSettingsFor,
347
- mainBotIds: () => product?.rt.state.mainBotIds ?? [] })
348
- const botRouting = createBotRoutingService({
349
- bots: () => product?.rt?.state?.bots ?? [],
350
- settingsFor: routingSettingsFor,
351
- machineHealth: machineId => {
352
- try { const row = machines?.readModel?.().items?.find(item => item.id === machineId); return { online: row?.status === 'online' } } catch { return null }
353
- },
354
- routing: () => routingSettings?.get().settings ?? config.botRouting,
355
- mainBotIds: () => product?.rt.state.mainBotIds ?? [],
356
- subjectFor: botId => directory.forBot(botId),
357
- submitPlan: (subject, request, caller) => {
358
- if (!taskService) throw new Error('TASK_SERVICE_UNAVAILABLE')
359
- return taskService.submitPlan(subject, request, caller)
360
- },
361
- readSubmittedPlan: (originBotId, planId) => {
362
- if (!journal) throw Object.assign(new Error('BOT_ROUTING_STATUS_UNAVAILABLE'), { code: 'BOT_ROUTING_STATUS_UNAVAILABLE' })
363
- // Authenticated owner observation reads durable identities even if the Bot was paused.
364
- return journal.getPlan(originBotId, planId)
365
- },
366
- bindWorker: (binding, context) => commitWorkerBinding(binding, context),
367
- executionBackendFor: effect => effect === 'read' ? 'bot-turn-read' : 'bot-turn-write',
368
- onAccepted: report => turnBackends?.watchPlan(report),
369
- busyFor: botId => product?.rt.live?.get(product.rt.chatOf(botId))?.turn !== undefined,
370
- priceFor: (route, botId) => {
371
- // Funding prices belong to the Bot binding, even when another Bot uses the same model.
372
- const settings = routingSettingsFor(botId)
373
- if (settings?.model?.provider !== route.provider || settings.model.model !== route.model) return null
374
- const price = settings.funding?.pricing
375
- if (!Number.isFinite(price?.input) || price.input < 0 || !Number.isFinite(price?.output) || price.output < 0) return null
376
- return (price.input + price.output) / 2
377
- },
378
- })
379
- ctx.provide('botRouting', botRouting)
380
- const describeBot = subject => { try { return authority.describe(subject).botId } catch { return null } }
381
- // Operator passthrough: a session that is not a Bot (the operator's own agent session) would
382
- // otherwise be refused as UNKNOWN_SUBJECT by every Bot-shaped tool. When the operator turns this
383
- // on, such a session acts with the Main Bot's authority — the same grants, the same audit trail
384
- // — instead of being handed a second, parallel set of permissions.
385
- const operatorSubject = () => {
386
- const settings = config?.operatorPassthrough
387
- // On by default: the operator's own session is the person who owns the machines.
388
- if (settings && settings.enabled === false) return null
389
- const mainId = (product?.rt.state?.mainBotIds ?? [])[0]
390
- if (!mainId) return null
391
- try { return directory.forBot(mainId) ?? null } catch { return null }
392
- }
393
- const subjectOrOperator = subject => subject ?? operatorSubject()
394
- ctx.provide('operatorSubject', operatorSubject)
395
- // Owner task management acts on the owning Bot's own subject: the Bot's
396
- // committed grant authorizes the operation, never the browser session.
397
- const taskOwner = payload => {
398
- const botId = payload?.botId
399
- if (typeof botId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(botId)) throw new Error('INVALID_TASK_OWNER')
400
- return directory.forBot(botId)
401
- }
402
-
403
- const grants = new Map()
404
- let machines, product, releaseMachineSecrets, funding, botSettings, fundingAccounts, modelAccounting, routingSettings, turnBackends, routineExecution, machineHeartbeat
405
- let journal, taskService, taskBackends, releaseTaskTools, cliSessionFactory, remoteCliBackend, sharedAdmission, taskRunnerWatchdog
406
- const committedBots = new Map()
407
- let ownerCatalogStamp=''
408
- let routes = new Set(), hydrated = false, refreshing = false, lastRefresh = 0, timer = null
409
- const refresh = async () => {
410
- if (refreshing) return
411
- refreshing = true
412
- try {
413
- routes = new Set((await catalog()).map(route => `${route.provider}/${route.model}`)); hydrated = true
414
- const ownerRoutes=[...routes].sort().map(key=>{const slash=key.indexOf('/');return {provider:key.slice(0,slash),model:key.slice(slash+1)}}).filter(route=>route.provider&&route.model)
415
- const stamp=JSON.stringify(ownerRoutes)
416
- if(stamp!==ownerCatalogStamp){policy.replaceOwnerGrant({tools:ownerTools,modelRoutes:ownerRoutes});ownerCatalogStamp=stamp}
417
- if(product)await product.lifecycle.refreshGrants()
418
- }
419
- catch { /* keep the last snapshot; the interval retries */ }
420
- finally { refreshing = false; lastRefresh = now() }
421
- }
422
- const scheduleRefresh = () => {
423
- if (refreshing || now() - lastRefresh < ROUTE_REFRESH_DEBOUNCE_MS) return
424
- void refresh()
425
- }
426
- const grantFor = bot => {
427
- // The DS lifecycle compiles grants from committed state; recording here keeps
428
- // bot-settings' botExists honest during that same synchronous compilation.
429
- committedBots.set(bot.id, bot)
430
- scheduleRefresh()
431
- let grant = botSettings ? botSettings.grantFor(bot) : compileProductGrant(bot, { routes, hydrated })
432
- // The Main Bot is the operator's own assistant: it may switch model in its panel at any time,
433
- // so its grant carries every hydrated route instead of only the one saved at that moment.
434
- if ((product?.rt.state?.mainBotIds ?? []).includes(bot.id) && hydrated && routes.size > 0) {
435
- grant = { ...grant, tools: [...grant.tools, 'tool/bot_dispatch', 'tool/bot_handoff_task'],
436
- modelRoutes: [...routes.keys()].map(key => { const at = key.indexOf('/'); return { provider: key.slice(0, at), model: key.slice(at + 1) } }) }
437
- }
438
- const names = {machine_ping:'remote_computer_ping',fs_list:'remote_computer_list',fs_read:'remote_computer_read',fs_write:'remote_computer_write'}
439
- // Grant compilation is synchronous and runs on every DS save/load: an
440
- // unavailable machine directory (not yet opened, or closed after a failed
441
- // boot) grants no machine access instead of suspending the lifecycle.
442
- let scopes = [],cliOfficial=false
443
- // Authorisation comes from committed state; `scopes()` only adds capabilities that are live
444
- // right now. Relying on scopes() alone meant a restart granted the Main Bot no remote tools
445
- // at all, because the directory is not open yet when grants compile.
446
- try { scopes = machines?.bindingsFor(bot.id) ?? [] } catch { scopes = [] }
447
- if (scopes.length === 0) { try { scopes = machines?.scopes(bot.id) ?? [] } catch { /* no machine access */ } }
448
- try { cliOfficial=botSettings?.get(bot.id,{publishChange:false}).settings?.officialTools?.some(item=>item.toolId==='codex'&&item.mode==='read-only')===true } catch {}
449
- for (const scope of scopes) for (const tool of scope.tools) {
450
- if(['operation_status','operation_cancel'].includes(tool)){grant.tools.push(`remote/${tool}`,'tool/remote_computer_operation');continue}
451
- if(tool==='artifact_stat'||tool==='artifact_read'){grant.tools.push(`remote/${tool}`);continue}
452
- if(['cli_probe_v1','cli_task_v1'].includes(tool)){
453
- const profile=cliConfig?.remote.find(item=>item.machineId===scope.machineId&&scope.roots.includes(item.rootId))
454
- if(cliOfficial&&profile&&['cli_probe_v1','cli_task_v1','operation_status','operation_cancel'].every(name=>scope.tools.includes(name)))grant.tools.push(`remote/${tool}`,...(tool==='cli_probe_v1'?['tool/remote_computer_cli_probe']:[]))
455
- continue
456
- }
457
- if(tool.startsWith('browser_')){if(scope.mode==='workspace-write')grant.tools.push(`remote/${tool}`,'tool/remote_computer_browser');continue}
458
- if(tool.startsWith('desktop_')){
459
- if(scope.mode==='workspace-write'&&['artifact_stat','artifact_read','operation_status','operation_cancel'].every(name=>scope.tools.includes(name)))grant.tools.push(`remote/${tool}`,'tool/remote_computer_desktop')
460
- continue
461
- }
462
- if(tool==='container_command_v1'){
463
- if(scope.mode==='workspace-write'&&['container_command_v1','operation_status','operation_cancel'].every(name=>scope.tools.includes(name)))grant.tools.push(`remote/${tool}`,'tool/remote_computer_command')
464
- continue
465
- }
466
- if (!names[tool]) continue
467
- if(tool==='fs_write'&&(scope.mode!=='workspace-write'||!['fs_write','operation_status','operation_cancel'].every(name=>scope.tools.includes(name))))continue
468
- grant.tools.push(`remote/${tool}`, `tool/${names[tool]}`)
469
- }
470
- grant.tools = [...new Set(grant.tools)]
471
- grants.set(bot.id, grant)
472
- return grant
473
- }
474
- const bindings = async ({ runtime, signal }) => {
475
- signal?.throwIfAborted()
476
- await runtime.load()
477
- const state = runtime.state ?? {}
478
- const items = Object.values(state.bots ?? {}).map(bot => {
479
- const grant = grants.get(bot.id)
480
- return { botId: bot.id, name: bot.name ?? '', role: bot.role ?? '', model: bot.model ?? null,
481
- sessionId: runtime.chatOf(bot.id), main: (state.mainBotIds ?? []).includes(bot.id),
482
- tools: grant?.tools ?? [], modelRoutes: grant?.modelRoutes ?? [], workspaceId, machineId }
483
- })
484
- return { status: 'available', items }
485
- }
486
- // The parent Host authenticates the browser session; absence never enables a route.
487
- const authorizeRequest = async request => {
488
- const admission = ctx.connection?.admit(request)
489
- if (!admission || 'rejection' in admission || admission.peer?.principal?.kind !== 'owner') return null
490
- return { kind: 'owner', peer: admission.peer }
491
- }
492
- // Trusted catalog for Bot binding validation: exact route models, the local
493
- // machine entry (an enabled binding requires a workspaceRef) and enabled
494
- // funding scopes. Tools and capability catalogs arrive with their facades.
495
- const settingsCatalog = () => {
496
- const models = [...routes].map(key => { const slash = key.indexOf('/'); return { provider: key.slice(0, slash), model: key.slice(slash + 1), label: key, cliModels: [] } })
497
- const localMachineId = machineId.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 100) || 'local-host'
498
- // A CLI identity is only offered once the host verified it against the
499
- // installed official tool; an unverified route keeps no cliModels entry.
500
- for (const model of models) {
501
- for (const mapping of cliConfig?.models ?? []) {
502
- if (mapping.provider === model.provider && mapping.model === model.model) model.cliModels.push({ toolId: 'codex', model: mapping.cliModel })
503
- }
504
- }
505
- const remoteMachines=new Map()
506
- for(const profile of cliConfig?.remote??[]){
507
- if(!remoteMachines.has(profile.machineId))remoteMachines.set(profile.machineId,{machineId:profile.machineId,label:profile.machineId,location:'remote',status:'configured',roots:[]})
508
- remoteMachines.get(profile.machineId).roots.push({rootId:profile.rootId,label:profile.rootId,modes:['read-only'],capabilities:[]})
509
- }
510
- for(const machine of machines?.readModel().items??[]){
511
- if(machine.status==='revoked')continue
512
- const roots=machine.roots.map(rootId=>({rootId,label:rootId,modes:['read-only','workspace-write'],capabilities:[...machine.tools]}))
513
- remoteMachines.set(machine.id,{machineId:machine.id,label:machine.name,location:'remote',status:machine.status,roots})
514
- }
515
- return { models, tools: cliConfig ? [{ toolId: 'codex', modes: ['read-only'], grantTools: [...CLI_GRANT_TOOLS] }] : [],
516
- machines: [{ machineId: localMachineId, label: '本机', location: 'local', status: 'ready',
517
- roots: [{ rootId: 'default', label: '默认工作区', modes: ['read-only', 'workspace-write'], capabilities: [] }] },...remoteMachines.values()],
518
- capabilities: [
519
- { id: 'model-inference', label: '模型推理', tools: ['model/infer'] },
520
- { id: 'mr-routing', label: '模型路由', tools: PRODUCT_TOOLS.filter(name=>name!=='model/infer') },
521
- { ...TASK_CAPABILITY },
522
- ...[...new Set((machines?.readModel().items ?? []).filter(machine => machine.status !== 'revoked').flatMap(machine => machine.tools))]
523
- .map(tool => ({ id: tool, label: tool, tools: [`remote/${tool}`] })),
524
- ],
525
- taskBackends: taskBackends ? taskBackends.catalog() : [],
526
- fundingScopes: fundingAccounts ? fundingAccounts.catalog().filter(scope => scope.enabled).map(scope => ({ id: scope.id, label: scope.provider, modes: ['api','subscription'] })) : [] }
527
- }
528
- // N06b: the Bot binding is the funding source of truth for its own model
529
- // calls. An unconfigured, mismatched or incomplete binding yields null and
530
- // the accounting layer refuses the call; there is no silent fallback route.
531
- const bindingFunding = async ({ botId, provider, model }) => {
532
- if (!botSettings) return null
533
- const value = botSettings.get(botId, { publishChange: false })
534
- if (value.status !== 'available') return null
535
- const s = value.settings
536
- const route = s.model
537
- if (!route?.provider || !route?.model) return null
538
- // An explicit route must match the binding's exact model; an unspecified
539
- // route (the MR funding preparation) resolves from the binding itself.
540
- if (provider !== undefined && model !== undefined && (route.provider !== provider || route.model !== model)) return null
541
- if (s.funding.mode === 'subscription') {
542
- return { funding: 'subscription', route: { ...route }, scopeId: s.funding.scopeId,
543
- subscriptionMaxCalls: s.funding.subscription?.dailyCalls ?? null,
544
- subscriptionMonthlyCalls: s.funding.subscription?.monthlyCalls ?? null }
545
- }
546
- if (!s.funding.pricing || !s.funding.request) return null
547
- const account = s.funding.scopeId && fundingAccounts ? fundingAccounts.get(s.funding.scopeId) : null
548
- return { funding: 'api', route: { ...route }, scopeId: s.funding.scopeId, pricing: s.funding.pricing,
549
- maxInputTokens: s.funding.request.maxInputTokens, maxOutputTokens: s.funding.request.maxOutputTokens,
550
- botDailyLimitUsd: s.funding.dailyBudgetUsd, botMonthlyLimitUsd: s.funding.monthlyBudgetUsd,
551
- ...(account ? { accountDailyLimitUsd: account.dailyLimitUsd, accountMonthlyLimitUsd: account.monthlyLimitUsd } : {}) }
552
- }
553
- const boot = (async () => {
554
- try{
555
- await Promise.race([refresh(), new Promise(resolve => { const pending = setTimeout(resolve, INITIAL_ROUTE_TIMEOUT_MS); pending.unref() })])
556
- if(deferredRouterState){
557
- sharedAdmission=await acquireMigrationShared(resolveStateHome())
558
- assertMigrationStartupAllowed({home:resolveStateHome()})
559
- funding=await openProductFunding({Database,file:storageFile,legacyFile,policy,authority,pricingFor:bindingFunding,now})
560
- }
561
-
562
- // The independent Bot runtime may call a remote tool, but only through this: the Bot's own
563
- // subject, its first live machine binding, and a fresh task/attempt per call. Nothing here
564
- // accepts a machine, root, endpoint or operation id from the caller's arguments.
565
- ctx.provide?.('botRemoteToolInvoker', Object.freeze({
566
- async invoke(botId, tool, args = {}) {
567
- const subject = directory.forBot(botId)
568
- if (!subject) throw new Error('UNKNOWN_BOT')
569
- const model = machines.readModel()
570
- const candidates = (model?.items ?? []).filter(item => item.status !== 'revoked'
571
- && Array.isArray(item.bindings) && item.bindings.some(binding => binding.botId === botId && !binding.revoked))
572
- // A caller may name one machine; it must be one of this Bot's own live bindings.
573
- const wanted = typeof args?.machineId === 'string' ? args.machineId : null
574
- const machine = wanted ? candidates.find(item => item.id === wanted) : candidates[0]
575
- if (!machine) throw new Error('MACHINE_NOT_BOUND')
576
- const binding = machine.bindings.find(item => item.botId === botId && !item.revoked)
577
- const rootId = (binding.roots ?? [])[0]
578
- if (typeof rootId !== 'string' || !rootId) throw new Error('MACHINE_ROOT_NOT_BOUND')
579
- if (Array.isArray(binding.tools) && !binding.tools.includes(tool)) throw new Error('MACHINE_BOT_DENIED')
580
- const calls = { ...args }
581
- delete calls.machineId
582
- const stamp = String(Date.now()) + Math.random().toString(16).slice(2, 8)
583
- return machines.call(subject, {
584
- machineId: machine.id, rootId, taskId: 'bot-' + stamp, attemptId: 'bot-a-' + stamp, tool, args: calls,
585
- })
586
- },
587
- }))
588
- routingSettings = openBotRoutingSettings({ Database, file: storageFile, initial: () => config.botRouting, now })
589
- machines = openRemoteMachines({ Database, file:storageFile, authority, cliProfiles:cliConfig?.remote??[],
590
- botExists:id=>Boolean(product&&Object.hasOwn(product.rt.state?.bots??{},id)), now,
591
- resolveSecret:async secretRef=>{
592
- if(!product)throw new Error('MACHINE_SECRET_UNAVAILABLE')
593
- await product.rt.loadSecrets()
594
- const name=secretRef.slice('ds-secret/'.length)
595
- const view=product.rt.secretsView().find(secret=>secret.name===name)
596
- // A machine control-plane key must never be offered to Bot shells.
597
- if(!view||!Array.isArray(view.scope)||view.scope.length)throw new Error('MACHINE_SECRET_MUST_BE_HOST_ONLY')
598
- return product.rt.secretValue(name)
599
- },
600
- onChanged:async()=>{if(product)await product.lifecycle.refreshGrants()},
601
- })
602
- // N06b: the components own grants and admission. A published compiled grant
603
- // takes effect immediately and a suspension revokes it; an account change
604
- // re-evaluates every binding because the scope catalog follows it.
605
- if (deferredRouterState) {
606
- fundingAccounts = openFundingAccounts({ Database, file: storageFile,
607
- providers: () => [...new Set([...routes].map(key => key.slice(0, key.indexOf('/'))))],
608
- onCommitted: () => { try { botSettings?.refresh() } catch { /* the interval retries */ } },
609
- onSuspended: () => { try { botSettings?.refresh() } catch { /* the interval retries */ } }, now })
610
- botSettings = openBotSettings({ Database, file: storageFile,
611
- catalogFor: settingsCatalog,
612
- botExists: id => committedBots.has(id),
613
- onCommitted: value => {
614
- try { directory.replaceGrant(value.botId, value.grant) } catch { /* the lifecycle recompiles */ }
615
- if (product) void product.lifecycle.refreshGrants()
616
- },
617
- onSuspended: value => {
618
- try { directory.replaceGrant(value.botId, { tools: [], modelRoutes: [] }) } catch { /* the lifecycle recompiles */ }
619
- if (product) void product.lifecycle.refreshGrants()
620
- }, now })
621
- modelAccounting = createModelAccounting({ store: funding.store, authority,
622
- pricingFor: bindingFunding,
623
- // The configured request allowance is the Host-declared trusted bound;
624
- // deriving it from the pinned model's context window is a follow-up.
625
- inputBoundFor: async ({ config }) => config.maxInputTokens,
626
- machineId, workspaceId, now,
627
- // The scoped admission (Bot/account/project budgets) is authoritative;
628
- // the ledger limit stays open so no hidden global cap shadows it.
629
- limits: { dailyLimitUsd: 0, monthlyLimitUsd: 0 } })
630
- // N09: one managed Task runner over the same SQLite authority. The
631
- // journal, funding store and component tables share orchestration.sqlite;
632
- // backends stay stable per kind while each Bot's binding supplies the
633
- // execution target, model and money at run time.
634
- if (typeof mr?.executeConfiguredAssignment !== 'function' || typeof mr?.rerunRecordedStep !== 'function'
635
- || typeof mr?.routerStateStore !== 'function') throw new TypeError('BOT_PRODUCT_MR_SEAM_REQUIRED')
636
- const managedRoot = join(dirname(storageFile), 'managed')
637
- const managedWorkspace = join(managedRoot, 'read-workspace')
638
- await mkdir(managedWorkspace, { recursive: true })
639
- if (cliConfig?.entry&&cliConfig.sessionFactory) {
640
- const factory = await import(cliConfig.sessionFactory).then(module => module.createCliSession ?? module.default, () => null)
641
- if (typeof factory !== 'function') throw new TypeError('TASK_CLI_SESSION_FACTORY_REQUIRED')
642
- cliSessionFactory = factory
643
- }
644
- journal = funding.store.openTaskJournal({leaseClock:'db'})
645
- const modelBackends = createTaskBackends({ ctx, mr, authority, directory, store: funding.store,
646
- machineId, workspaceId, workspace: managedWorkspace, runtimeRoot: join(managedRoot, 'cli-runtime'),
647
- bindingFor: botId => botSettings.get(botId, { publishChange: false }),
648
- cli: cliConfig?.entry ? { entry: cliConfig.entry, sessionFactory: cliSessionFactory ?? (await import('../../cli-runtime/src/codex-runtime.mjs')).createCodexSession } : null })
649
- const remoteWrite=createRemoteWriteBackend({machines:()=>machines,directory})
650
- const remoteCommand=createRemoteCommandBackend({machines:()=>machines,directory})
651
- turnBackends = createBotTurnBackends({ ctx, runtime: () => product?.rt, directory, authority, Database, storeFile: storageFile,
652
- bindingFor: botId => botSettings.get(botId, { publishChange: false }), machines: () => machines,
653
- readPlan: report => taskService.getPlan(directory.forBot(report.originBotId), { planId: report.planId }), now })
654
- if(cliConfig?.remote.length)remoteCliBackend=createRemoteCliReadBackend({machines:()=>machines,directory,store:funding.store,authority,profiles:cliConfig.remote,bindingFor:botId=>botSettings.get(botId,{publishChange:false})})
655
- const remoteBackends=[remoteWrite,remoteCommand,...(remoteCliBackend?[remoteCliBackend]:[])]
656
- taskBackends=Object.freeze({backends:Object.freeze([...modelBackends.backends,...remoteBackends]),catalog:()=>[...modelBackends.catalog(),...remoteBackends.map(backend=>({id:backend.id,version:backend.version,effect:backend.effect,location:'remote',capability:backend.capability,label:backend.id==='remote-codex-read'?'已授权远程官方 CLI 只读任务':backend.id==='remote-write'?'已授权远程文件写入任务':'已授权容器命令任务(仅别名argv)'}))]})
657
- taskService = createTaskService({ journal, authority, directory, backends: [...turnBackends.backends, ...taskBackends.backends],
658
- within: (subject, signal, work) => policy.executionContext(subject, signal, work),
659
- planTargetsFor: (subject,plan,caller) => {
660
- const id=authority.describe(subject).botId
661
- const bot=product?.lifecycle.committedBot(id)
662
- const privateSession=product?.rt.chatOf(id)??bot?.sessionId??bot?.id
663
- const privateCall=caller.owner===true||bot&&caller.sessionId===privateSession
664
- if(caller.sessionId!==undefined&&plan.origin.kind==='private'&&!privateCall)throw Object.assign(new Error('TASK_PLAN_ORIGIN_MISMATCH'),{code:'TASK_PLAN_ORIGIN_MISMATCH'})
665
- if(caller.owner===true&&plan.origin.kind==='private'&&plan.origin.sessionId!==null&&plan.origin.sessionId!==privateSession)throw Object.assign(new Error('TASK_PLAN_ORIGIN_MISMATCH'),{code:'TASK_PLAN_ORIGIN_MISMATCH'})
666
- return privateCall&&['private','handoff'].includes(plan.origin.kind)&&product?.lifecycle.committedMainBotIds().includes(id)?product.lifecycle.committedBots().map(bot=>bot.id):[id]
667
- },
668
- runnerTtlMs: TASK_RUNNER_TTL_MS, taskTtlMs: TASK_TTL_MS,
669
- // Lease expiry reads the ledger's clock, not this process's: a Host
670
- // whose wall clock drifted must not be able to declare its own live
671
- // lease expired and let a second runner take the same tasks.
672
- leaseClock: 'db',
673
- // The deployment refuses a writer that cannot prove a stop, so a new
674
- // backend cannot be added without also teaching it how to say so.
675
- requireStopProof: true,
676
- // The committed binding's maxConcurrentTasks is the per-Bot limit; an
677
- // unconfigured Bot keeps the conservative single-task default.
678
- concurrencyFor: botId => {
679
- try {
680
- const value = botSettings?.get(botId, { publishChange: false })
681
- return value?.status === 'available' ? value.settings.maxConcurrentTasks ?? 1 : 1
682
- } catch { return 1 }
683
- } })
684
- // A previous Host that died without releasing its runner lease blocks the
685
- // claim until the lease expires; waiting is safer than dropping tasks.
686
- const claimDeadline = now() + TASK_RUNNER_CLAIM_MS
687
- for (;;) {
688
- try { taskService.start(); break } catch (error) {
689
- if (error?.code !== 'TASK_RUNNER_BUSY' || now() >= claimDeadline) throw error
690
- await new Promise(resolve => { const pending = setTimeout(resolve, 250); pending.unref() })
691
- }
692
- }
693
- // A runner that stops (a lease lost to a clock jump, a journal write that
694
- // never settled) must come back on its own, or the Host keeps accepting
695
- // work nobody will claim. The watchdog reports what it did instead of
696
- // hiding a permanent stop behind a quiet 5-second tick.
697
- taskRunnerWatchdog = setInterval(() => {
698
- try {
699
- const state = taskService.state()
700
- if (state.closed || state.running) return
701
- const outcome = taskService.restart({ reason: state.stopReason ?? 'TASK_RUNNER_WATCHDOG' })
702
- if (!outcome.accepted) ctx.logger?.('task-runner-restart-deferred', { code: outcome.code, reason: state.stopReason })
703
- } catch (error) { ctx.logger?.('task-runner-watchdog-failed', { code: error?.code ?? 'TASK_RUNNER_WATCHDOG_FAILED' }) }
704
- }, TASK_RUNNER_WATCHDOG_MS)
705
- taskRunnerWatchdog.unref?.()
706
- releaseTaskTools = registerTaskTools(ctx, defineTool, { service: taskService, directory })
707
- }
708
- const machineActions=Object.fromEntries(['pair','bind','probe','revoke','reconcile'].map(name=>[`machine.${name}`,(payload,context)=>machines[name](payload,context)]))
709
- machineActions['machine.pair'] = (payload, context) => pairMainMachine({ machines,
710
- mainBotId: () => product?.rt.state?.mainBotIds?.[0] }, payload, context)
711
- machineActions['machine.artifact']=(payload,context)=>machines.download(taskOwner(payload),payload,context)
712
- // Desktop control: a person watches one frame and acts on it. Both calls run as that
713
- // person's Bot, so the Bot grant and the machine ceiling decide, and the operations land
714
- // under that Bot instead of an anonymous console.
715
- const desktopActions = createDesktopActions({ machines, directory })
716
- for (const [action, method] of [['open', 'desktopSession'], ['renew', 'renewDesktopSession'], ['close', 'closeDesktopSession']]) {
717
- machineActions[`machine.desktop.${action}`] = (payload, context) => {
718
- if (context?.kind !== 'owner') throw new Error('REMOTE_OWNER_REQUIRED')
719
- if (!payload || typeof payload.botId !== 'string') throw new Error('INVALID_DESKTOP_REQUEST')
720
- const { botId, ...request } = payload
721
- return machines[method](taskOwner({ botId }), request, context)
722
- }
723
- }
724
- machineActions['machine.desktop.view'] = (payload, context) => {
725
- if (context?.kind !== 'owner') throw new Error('REMOTE_OWNER_REQUIRED')
726
- return desktopActions.view(payload)
727
- }
728
- machineActions['machine.desktop.act'] = (payload, context) => {
729
- if (context?.kind !== 'owner') throw new Error('REMOTE_OWNER_REQUIRED')
730
- return desktopActions.act(payload)
731
- }
732
- machineActions['machine.secret']=async(payload,context)=>{
733
- if(context?.kind!=='owner')throw new Error('REMOTE_OWNER_REQUIRED')
734
- if(!payload||Object.keys(payload).some(key=>!['name','value'].includes(key))||!/^REMOTE_AGENT_[A-Z0-9_]{1,50}$/.test(payload.name)||typeof payload.value!=='string'||!/^[A-Za-z0-9_-]{43}$/.test(payload.value))throw new Error('INVALID_MACHINE_SECRET')
735
- const secretRef=`ds-secret/${payload.name}`
736
- for(const machine of machines.readModel().items.filter(item=>item.status!=='revoked'&&item.secretRef===secretRef))await machines.revoke({machineId:machine.id,expectedRevision:machine.revision},context)
737
- await product.rt.secretEndpoint('secret-set',{name:payload.name,value:payload.value,scope:[],purpose:'远程电脑控制面凭据,仅供宿主使用;不得授予 Bot shell。'})
738
- return {secretRef,stored:true}
739
- }
740
- const botLibrary = (() => { try { return ctx.get?.('botLibrary') ?? null } catch { return null } })()
741
- const botRuntimeAdapter = (() => { try { return ctx.get?.('botRuntimeAdapter') ?? null } catch { return null } })()
742
- const routineStore = (() => { try { return ctx.get?.('routineStore') ?? null } catch { return null } })()
743
- if (routineStore) routineExecution = createRoutineExecutionService({ machines, store: routineStore, botIdForSubject: describeBot })
744
- product = createBotProduct(ctx, { authority, directory, policy, grantFor,
745
- accountStream: (streamOptions, next, meta) => {
746
- turnBackends?.tagModelRequest(streamOptions)
747
- const enriched = meta?.subject ? { ...meta, botId: describeBot(meta.subject) } : meta
748
- // N06b: the unified admission owns every Bot model call; the diagnostic
749
- // decorator only remains for lightweight contexts without the store.
750
- if (!modelAccounting) return accountStream(streamOptions, next, enriched)
751
- // A direct MR call inside an admitted execution frame references that
752
- // run's invoice instead of opening a second Bot-side one.
753
- funding?.funded?.bindModelRequest?.(streamOptions, { agentLoop: meta?.agentLoop === true, ownRequest: meta?.ownRequest === true })
754
- return modelAccounting.stream(streamOptions, next, { ...enriched, source: RECEIPT_SOURCE[enriched?.source] ?? enriched?.source })
755
- },
756
- onBind, runtimeConfig,
757
- readModels: { bindings, machines:()=>machines.readModel(),
758
- botBindings: async ({ runtime, signal }) => {
759
- signal?.throwIfAborted(); await runtime.load()
760
- const catalog = settingsCatalog()
761
- return { ...botSettings.readModel(Object.values(runtime.state?.bots ?? {})),
762
- machines: catalog.machines.map(m => ({ machineId: m.machineId, label: m.label, location: m.location, roots: m.roots.map(r => ({ rootId: r.rootId, label: r.label, modes: r.modes })) })),
763
- fundingScopes: catalog.fundingScopes }
764
- },
765
- fundingAccounts: async () => ({ status: 'available', items: fundingAccounts.catalog() }),
766
- // N09: the managed Task projection reads the durable journal through the
767
- // same single authority; a section without a runner stays blocked.
768
- ...(taskService ? { tasks: async ({ runtime, signal }) => {
769
- signal?.throwIfAborted(); await runtime.load()
770
- const loaded = journal.productList(Object.values(runtime.state?.bots ?? {}).map(bot => bot.id), { limit: TASK_READ_LIMIT })
771
- const items = loaded.items.map(projectProductTask)
772
- const plans=Object.values(runtime.state?.bots??{}).flatMap(bot=>journal.listPlans(bot.id))
773
- return { status: 'available', items, total: loaded.total, truncated: loaded.truncated, plans, backends: taskBackends.catalog() }
774
- } } : {}),
775
- // N07: the usage projection reads the durable source receipts and the
776
- // funding totals from the single authority; an unknown amount stays
777
- // null instead of being rendered as zero.
778
- ...(funding ? { usage: async ({ runtime, signal }) => {
779
- signal?.throwIfAborted(); await runtime.load()
780
- const bots = Object.values(runtime.state?.bots ?? {})
781
- const names = new Map(bots.map(bot => [bot.id, bot.name]))
782
- // An owner resolution lives on the run's package, so the receipts and
783
- // the spend total agree after a waive or an attested amount.
784
- const ledger = await funding.store.read()
785
- const resolutions = new Map()
786
- const unresolved = []
787
- for (const run of ledger.runs) {
788
- const botId = [run.modelCall, run.cliExecution, run.fundingExecution].filter(Boolean).map(value => value.botId).find(Boolean) ?? null
789
- for (const item of [...(run.packages ?? []), ...(run.reviews ?? [])]) {
790
- if (!item || item.ran !== true) continue
791
- const key = `${run.id}/${item.id}`
792
- if (item.ownerResolution) { resolutions.set(key, item.ownerResolution); continue }
793
- if (!botId || typeof item.costUsd === 'number' && Number.isFinite(item.costUsd)) continue
794
- unresolved.push({ botId, runId: run.id, packageId: item.id, provider: item.provider ?? null,
795
- model: item.model ?? null, billing: item.billing ?? null, usage: item.usage ?? null,
796
- finishedAt: item.finishedAt ?? null })
797
- }
798
- }
799
- const items = []
800
- for (const bot of bots) {
801
- let after = 0
802
- for (;;) {
803
- const page = await funding.store.modelCalls({ botId: bot.id, after, limit: 100 })
804
- for (const receipt of page.items) {
805
- const resolution = resolutions.get(`${receipt.runId}/${receipt.packageId}`) ?? null
806
- items.push({ id: receipt.id, botId: receipt.botId, botName: names.get(receipt.botId) ?? null,
807
- sessionId: receipt.sessionId, taskId: receipt.taskId, runId: receipt.runId,
808
- source: receipt.source, provider: receipt.provider, model: receipt.model, billing: receipt.funding,
809
- amountUsd: projectModelCallAmount(receipt, resolution), status: receipt.state, sequence: receipt.sequence,
810
- resolution: resolution ? { kind: resolution.kind, costUsd: resolution.costUsd, note: resolution.note, resolvedAt: resolution.resolvedAt } : null,
811
- inputTokens: receipt.usage?.inputTokens ?? null, outputTokens: receipt.usage?.outputTokens ?? null })
812
- }
813
- if (!page.next) break
814
- after = page.next
815
- }
816
- }
817
- const totals = funding.store.fundingTotals({ at: now() })
818
- const reserved = reservationSpending(ledger)
819
- const subscription = funding.store.fundingSubscriptionUsage({ at: now() })
820
- return { status: 'available', items, unresolved,
821
- summary: { apiUsd: totals.today, reservedUsd: reserved.reservedUsd, unknownCount: totals.unknownToday + totals.unknownMonth,
822
- subscriptionSummary: { callsToday: subscription.today.calls, callsMonth: subscription.month.calls, usdToday: totals.subscriptionToday, usdMonth: totals.subscriptionMonth } } }
823
- } } : {}) },
824
- actions: { ...actions, ...machineActions,
825
- // The library panel reads and writes one Bot's files. The directory is chosen by the
826
- // library itself (the caller's Bot, or the operator's choice), never by the panel.
827
- // Saved routines: list what a Bot has, and run one on a machine the caller may use.
828
- // The role catalog is a read-only convenience: it describes jobs, it grants nothing.
829
- 'roles.list': async () => listRoles(),
830
- ...(routineStore ? {
831
- 'routine.list': async payload => routineStore.inspect(payload?.botId),
832
- 'routine.save': async payload => routineStore.save(payload?.botId, payload?.routine),
833
- 'routine.remove': async payload => routineStore.remove(payload?.botId, payload?.routineId),
834
- 'routine.history': async payload => routineExecution.history(payload?.botId),
835
- 'routine.cancel': async payload => routineExecution.cancel(payload?.botId, payload?.runId),
836
- 'routine.resolve': async (payload, context) => routineExecution.resolve(payload?.botId, payload?.runId, payload?.note, context),
837
- 'routine.run': async (payload, context) => routineExecution.run({ subject: taskOwner(payload),
838
- botId: payload?.botId, routineId: payload?.routineId, machineId: payload?.machineId, rootId: payload?.rootId,
839
- context, signal: context.signal }),
840
- } : {}),
841
- ...(botLibrary ? {
842
- 'library.list': async payload => botLibrary.list(payload?.botId, payload?.relativePath ?? ''),
843
- 'library.read': async payload => ({ content: botLibrary.read(payload?.botId, payload?.relativePath ?? '') }),
844
- 'library.search': async payload => ({ results: botLibrary.search(payload?.botId, payload?.query) }),
845
- 'library.write': async payload => botLibrary.write(payload?.botId, payload?.relativePath ?? '', payload?.content ?? ''),
846
- 'library.upload': async payload => botLibrary.upload(payload?.botId, payload?.relativePath, payload?.contentBase64, payload?.overwrite),
847
- 'library.download': async payload => botLibrary.download(payload?.botId, payload?.relativePath),
848
- 'library.remove': async payload => botLibrary.remove(payload?.botId, payload?.relativePath),
849
- 'library.mkdir': async payload => botLibrary.mkdir(payload?.botId, payload?.relativePath),
850
- } : {}),
851
- // The Bot runtime card is a panel: its buttons dispatch on this channel, so the same four
852
- // operations the owner RPC exposes must exist here as well.
853
- ...(botRuntimeAdapter ? {
854
- 'botRuntimeStatus': async () => botRuntimeAdapter.list(),
855
- 'botRuntimeMove': async payload => botRuntimeAdapter.move(payload?.botId, payload?.mode),
856
- 'botRuntimeHistory': async payload => botRuntimeAdapter.history(payload?.botId, { limit: payload?.limit }),
857
- 'botRuntimeMessage': async payload => {
858
- if (config.independentRuntime?.fullTextHistory !== true) throw new Error('HISTORY_FULLTEXT_DISABLED')
859
- return botRuntimeAdapter.readMessage(payload?.botId, payload?.index)
860
- },
861
- } : {}),
862
- // The workbench reads Bot routing through the same product channel as every other panel
863
- // action. Registering it here is what makes the routing card work at runtime; the owner
864
- // RPC methods are a second door onto the same service.
865
- 'botRoutingInspect': async () => ({ ...botRouting.inspect(), settingsRevision: routingSettings.get().revision }),
866
- 'botRouting.status': (payload, context) => botRouting.submissionStatus(payload, context),
867
- 'botRouting.save': async (payload, context) => {
868
- const saved = routingSettings.save(payload, context)
869
- return { ...botRouting.inspect(), settingsRevision: saved.revision }
870
- },
871
- 'botRoutingDecide': async payload => botRouting.decide({
872
- text: payload?.text,
873
- pinnedBotId: payload?.pinnedBotId ?? '',
874
- requiresMachine: payload?.requiresMachine === true,
875
- machineId: payload?.machineId ?? '',
876
- rootId: payload?.rootId ?? '',
877
- needs: payload?.needs ?? {},
878
- requiredTools: Array.isArray(payload?.requiredTools) ? payload.requiredTools : [],
879
- requesterBotId: typeof payload?.requesterBotId === 'string' ? payload.requesterBotId : '',
880
- }),
881
- ...(botSettings ? { 'binding.save': async (payload, context) => {
882
- // A Bot holds its greeting until its model is picked, and the model change releases it
883
- // immediately. Saving a binding changes both the model and its authority, so hold the
884
- // greeting across that change: otherwise the greeting turn runs before the compiled
885
- // grant carries the binding's model route and is refused as MODEL_DENIED.
886
- const bot = product.rt.botOf(payload.botId)
887
- const held = bot?.kickoff
888
- if (bot && held) delete bot.kickoff
889
- let result
890
- try { result = await botSettings.save(payload, context) }
891
- catch (error) { if (bot && held) bot.kickoff = held; throw error }
892
- const model = result.settings?.model
893
- const ref = model ? `${model.provider}/${model.model}` : null
894
- if (result.status === 'available' && bot && ref && bot.model !== ref) await product.rt.updateBot(payload.botId, { model: ref })
895
- await product.lifecycle.refreshGrants()
896
- if (bot && held) { bot.kickoff = held; await product.rt.updateBot(payload.botId, { model: bot.model }).catch(() => {}) }
897
- return result
898
- } } : {}),
899
- ...(fundingAccounts ? { 'funding.account.save': (payload, context) => fundingAccounts.save(payload, context) } : {}),
900
- // N09: owner task management runs on the owning Bot's own subject, so
901
- // the Bot's committed grant authorizes every cancel/retry/reconcile.
902
- ...(taskService ? {
903
- // Task management is the person's own console, not a Bot surface. Every
904
- // action here names its caller class instead of inheriting the default,
905
- // so a change at the authorization seam cannot quietly hand a non-owner
906
- // the ability to cancel, retry or reconcile somebody's work.
907
- 'task.inspect': ownerOnly(payload => inspectProductTask({ service: taskService, journal, subject: taskOwner(payload), taskId: payload?.taskId })),
908
- // Bot routing turns a preview into a real package: the decision names the Bot, the
909
- // worker binding is committed first, and only then is the plan submitted. A failure in
910
- // either step leaves no half-dispatched task behind.
911
- 'botRouting.dispatch': (payload, context) => botRouting.dispatch(payload, context),
912
- 'task.plan.submit': ownerOnly(payload => taskService.submitPlan(taskOwner(payload),{requestKey:payload?.requestKey,plan:payload?.plan},{owner:true})),
913
- 'task.plan.cancel': ownerOnly(payload => taskService.cancelPlan(taskOwner(payload),{planId:payload?.planId})),
914
- 'task.cancel': ownerOnly(payload => taskService.cancel(taskOwner(payload), { taskId: payload?.taskId })),
915
- 'task.retry': ownerOnly(payload => taskService.retry(taskOwner(payload), { taskId: payload?.taskId, requestKey: payload?.requestKey })),
916
- 'task.reconcile': ownerOnly(payload => taskService.reconcile(taskOwner(payload), { taskId: payload?.taskId, requestKey: payload?.requestKey })),
917
- } : {}),
918
- // An unconfirmed receipt fail-closes its Bot until an owner resolves it:
919
- // attest the vendor-confirmed amount or waive it, with a required note.
920
- ...(funding ? { 'usage.resolve': (payload, context) => {
921
- if (context?.kind !== 'owner') throw new Error('REMOTE_OWNER_REQUIRED')
922
- if (!payload || typeof payload !== 'object' || Object.keys(payload).some(key => !['botId', 'runId', 'packageId', 'kind', 'costUsd', 'note'].includes(key))) {
923
- throw new Error('INVALID_RESOLUTION_REQUEST')
924
- }
925
- return funding.store.resolveUnknownPackage(payload)
926
- } } : {}) },
927
- authorizeRequest })
928
- releaseMachineSecrets=guardMachineSecrets(ctx,product.rt,machines)
929
- ctx.provide('botRemoteMachines', machines)
930
- registerRemoteComputerTools(ctx,{runtime:product.rt,directory,machines,taskService,remoteCliBackend,operatorSubject,botRouting,routineStore,routineExecution,handoffTask,
931
- routedExecution: turnBackends, botIdFor: exec => describeBot(directory.forAgent(exec?.agent)), routingBotIds: () => product.rt.state.mainBotIds ?? [] })
932
- // The MR execution paths project their routes through the same Bot
933
- // binding, so a consult run carries the binding price instead of an
934
- // unknown-cost package.
935
- if (funding) ctx.provide('botFunding', funding.funded)
936
- deferredRouterState?.resolve(funding.state)
937
- machineHeartbeat = createMachineHeartbeat({ machines: () => machines, preferredBotIds: () => product.rt.state.mainBotIds ?? [] })
938
- machineHeartbeat.start()
939
- timer = setInterval(() => { void refresh() }, refreshIntervalMs)
940
- timer.unref()
941
- return product
942
- }catch(error){
943
- await machineHeartbeat?.close?.()
944
- await routineExecution?.close?.()
945
- await machines?.closeDesktopSessions?.()
946
- for(const cleanup of [()=>product?.dispose(),()=>releaseMachineSecrets?.(),()=>releaseTaskTools?.(),()=>machines?.close(),()=>botSettings?.close(),()=>fundingAccounts?.close(),()=>modelAccounting?.close(),()=>routingSettings?.close()])try{cleanup()}catch{}
947
- if(taskRunnerWatchdog)clearInterval(taskRunnerWatchdog)
948
- if(taskService)await taskService.close().catch(()=>{})
949
- else journal?.close()
950
- turnBackends?.close()
951
- if(remoteCliBackend)await remoteCliBackend.close().catch(()=>{})
952
- if(funding)await funding.close().catch(()=>{})
953
- if(deferredRouterState)try{deferredRouterState.reject(error)}catch{}
954
- if(sharedAdmission)await sharedAdmission.release().catch(()=>{})
955
- throw error
956
- }
957
- })()
958
- if (failClosed) {
959
- // A half-composed product (no Bot runtime) must never look like a working Host.
960
- boot.catch(error => {
961
- process.stderr.write(`Bot product composition failed: ${error?.stack ?? error}\n`)
962
- setTimeout(() => { throw error }, 0)
963
- })
964
- }
965
- return Object.freeze({
966
- machineId, workspaceId, grants, grantFor,
967
- observations: () => accountStream.observations?.() ?? [],
968
- routeStatus: () => ({ hydrated, routes: [...routes].sort() }),
969
- boot,
970
- async dispose() {
971
- if (timer) clearInterval(timer)
972
- const product = await boot.then(value => value, () => null)
973
- try{
974
- await machineHeartbeat?.close?.()
975
- await routineExecution?.close?.()
976
- await machines?.closeDesktopSessions?.()
977
- for(const cleanup of [()=>product?.dispose(),()=>releaseMachineSecrets?.(),()=>releaseTaskTools?.(),()=>machines?.close(),()=>botSettings?.close(),()=>fundingAccounts?.close(),()=>modelAccounting?.close(),()=>routingSettings?.close()])try{cleanup()}catch{}
978
- if(taskRunnerWatchdog)clearInterval(taskRunnerWatchdog)
979
- if(taskService)await taskService.close().catch(()=>{})
980
- else journal?.close()
981
- turnBackends?.close()
982
- if(remoteCliBackend)await remoteCliBackend.close().catch(()=>{})
983
- if(funding)await funding.close()
984
- }finally{authority.dispose();if(sharedAdmission)await sharedAdmission.release()}
985
- },
986
- })
987
- }
988
-
989
- /** Bounded diagnostic decorator: identity and source only, never a monetary claim. */
990
- export function createDiagnosticAccountStream({ limit = OBSERVATION_LIMIT, now = Date.now } = {}) {
991
- const ring = []
992
- const stream = (options, next, meta) => {
993
- if (ring.length >= limit) ring.shift()
994
- ring.push({ at: now(), source: meta?.source ?? null, sessionId: meta?.sessionId ?? null,
995
- botId: meta?.botId ?? null, provider: options?.provider ?? null, model: options?.model ?? null,
996
- resolved: Boolean(meta?.subject) })
997
- return next()
998
- }
999
- stream.observations = () => ring.slice()
1000
- return stream
1001
- }
1
+ import { createBotRoutingService } from './bot-routing-service.mjs'
2
+ import { openBotRoutingSettings } from './bot-routing-settings.mjs'
3
+ import { createWorkerBindingCommitter } from './bot-routing-dispatch.mjs'
4
+ import { createBotTurnBackends } from './bot-routing-runtime.mjs'
5
+ import { createRoutineExecutionService } from './routine-execution.mjs'
6
+ import { createMachineHeartbeat } from './machine-heartbeat.mjs'
7
+ import { pairMainMachine } from './pair-main-machine.mjs'
8
+ import { assertHandoffAllowed, buildHandoff, createHandoffLedger } from './bot-handoff.mjs'
9
+ import { listRoles } from './bot-roles.mjs'
10
+ import { hostname } from 'node:os'
11
+ import { join, dirname, isAbsolute } from 'node:path'
12
+ import { mkdir } from 'node:fs/promises'
13
+ import { createRequire } from 'node:module'
14
+ import { randomUUID, createHash } from 'node:crypto'
15
+ import { normalizeConversationEngines, mountConversationEngines, validateClaudeProfileCredential } from '../../bot-engines/src/host-registrations.mjs'
16
+ import { projectHostLoopTextRequest, hostLoopRouteContext, hostSessionPersona } from '../../bot-engines/src/host-loop-request.mjs'
17
+ import { independentSystemMessage } from './independent-message-wire.mjs'
18
+ import { defineTool } from '@deepseek-ai/dsh-tools'
19
+ import { defaultStatePath, resolveStateHome } from '../../../.dsh-plugin/shared/router-state.mjs'
20
+ import { actualCost } from '../../../.dsh-plugin/shared/run-ledger.mjs'
21
+ import { reservationSpending } from '../../../.dsh-plugin/shared/budget-reservations.mjs'
22
+ import { openProductFunding } from './product-funding.mjs'
23
+ import { createAuthority } from '../../capability-broker/src/authority.mjs'
24
+ import { createPrincipalDirectory } from './principals.mjs'
25
+ import { createBotPolicy } from './bot-policy.mjs'
26
+ import { createBotProduct, isAgentLoopRequest } from '../../../modules/bot-runtime/index.mjs'
27
+ import { createSessionLoopFrameResolver } from './model-loop-frame.mjs'
28
+ import { createAuxiliaryModelStream } from './model-auxiliary-dispatch.mjs'
29
+ import { compileMainBotGrant } from './main-bot-grant.mjs'
30
+ import { openRemoteMachines } from './remote-machines.mjs'
31
+ import { openSecurePairingHost } from './secure-pairing-host.mjs'
32
+ import { createDesktopProxy } from './desktop-proxy.mjs'
33
+ import { createDesktopHttpCarrier } from './desktop-http-carrier.mjs'
34
+ import { guardMachineSecrets } from './machine-secrets.mjs'
35
+ import { checkMainRouteFunding, openBotSettings } from './bot-settings.mjs'
36
+ import { fundingForMainRoute } from './main-model-routes.mjs'
37
+ import { parseModelProfilesJson } from '../../../.dsh-plugin/shared/model-profiles.mjs'
38
+ import { openFundingAccounts } from './funding-accounts.mjs'
39
+ import { createModelAccounting } from './model-accounting.mjs'
40
+ import { createModelBoundResolver } from './model-request-bounds.mjs'
41
+ import { createModelFrameStore } from './model-prepared-generation.mjs'
42
+ import { createTaskService } from './task-service.mjs'
43
+ import { inspectProductTask, projectProductTask, readProductAttention } from './task-product.mjs'
44
+ import { createBotNotifications } from './bot-notifications.mjs'
45
+ import { createBotThreadHost } from './bot-thread-host.mjs'
46
+ import { createIndependentToolHost } from './independent-tool-host.mjs'
47
+ import { extendIndependentToolHost } from './independent-tool-families.mjs'
48
+ import { createNativeConversationTools, renderNativeRemoteResult } from './native-conversation-tools.mjs'
49
+ import { openNativeRemoteActions, nativeRemoteToolEntry } from './native-remote-actions.mjs'
50
+ import { createIndependentChatService } from './independent-chat-service.mjs'
51
+ import { createGrantRefreshQueue } from './grant-refresh-queue.mjs'
52
+ import { openWebPanelStore } from './web-panel-store.mjs'
53
+ import { createWebPanelActions } from './web-panel-actions.mjs'
54
+ import { createDataEngine } from './bot-data-engine.mjs'
55
+ import { createProductDataAccess } from './data-product-access.mjs'
56
+ import { inspectProductBinding } from './binding-product.mjs'
57
+ import { inspectProductBotProfile, saveProductBotProfile } from './bot-profile.mjs'
58
+ import { openUiHostNamespace } from './ui-host-namespace.mjs'
59
+ import { registerDataTools } from './bot-data-tools.mjs'
60
+ import { createNativeDataTools } from './native-data-tools.mjs'
61
+ import { createNativeActivityService } from './native-activity-service.mjs'
62
+ import { createTeamCoordination } from './team-bridge.mjs'
63
+ import { createNativeTeamTools } from './native-team-tools.mjs'
64
+ import { registerTeamTools } from './team-sdk-tools.mjs'
65
+ import { createDesktopActions } from './desktop-actions.mjs'
66
+ import { registerTaskTools } from './task-tools.mjs'
67
+ import { createMrReadBackend } from './mr-read-backend.mjs'
68
+ import { createCliReadBackend } from './cli-execution.mjs'
69
+ import { createRemoteWriteBackend } from './remote-execution.mjs'
70
+ import { createRemoteCommandBackend } from './remote-command-execution.mjs'
71
+ import { createRemoteCliReadBackend } from './remote-cli-execution.mjs'
72
+ import { canonicalTaskJson, parseRemoteTaskCli, taskHash } from './task-contract.mjs'
73
+ import { acquireMigrationShared } from './migration-fence.mjs'
74
+ import { assertMigrationStartupAllowed } from './migration-startup-gate.mjs'
75
+ import { registerRemoteComputerTools } from './remote-computer-tools.mjs'
76
+
77
+ // The default Bot grant exposes the complete router through the same policy.
78
+ // Exact model routes and side-effect approvals remain independently enforced.
79
+ export const PRODUCT_TOOLS = Object.freeze(['model/infer',
80
+ 'tool/model_router_routes', 'tool/model_router_plan', 'tool/model_router_consult', 'tool/model_router_health', 'tool/model_router_tools',
81
+ 'tool/model_router_execute', 'tool/model_router_team_execute', 'tool/model_router_tool_run', 'tool/model_router_rerun_step',
82
+ 'tool/model_router_tool_install', 'tool/model_router_tool_repair', 'tool/model_router_tool_uninstall', 'tool/model_router_rate',
83
+ 'command/router', 'tool/bot_library_list', 'tool/bot_library_read', 'tool/bot_library_write', 'tool/bot_library_search', 'tool/bot_route_task',
84
+ 'tool/bot_routine_list', 'tool/bot_routine_save', 'tool/bot_routine_run'])
85
+ export const PRODUCT_OWNER_READ_METHODS = Object.freeze(['list','installSettings','status','health','ledger','boundaries','previewRun','runStatus','terminalInfo','terminalRead'])
86
+ export const PRODUCT_OWNER_WRITE_METHODS = Object.freeze(['completeOnboarding','rateResult','rerunStep','startRun','cancelRun','installTool','uninstallTool','repairTool','cancel','terminalStart','terminalWrite','terminalResize','terminalStop'])
87
+ // The Bot runtime reports descriptive call origins; the durable receipt schema
88
+ // keeps its closed vocabulary. The adapter is explicit: an unmapped origin is
89
+ // refused by the accounting layer instead of being invented.
90
+ const RECEIPT_SOURCE = Object.freeze({ 'bot-session': 'bot', 'bot-auxiliary': 'aux' })
91
+ const ROUTE_REFRESH_MS = 30_000
92
+ const ROUTE_REFRESH_DEBOUNCE_MS = 2_000
93
+ const INITIAL_ROUTE_TIMEOUT_MS = 5_000
94
+ const OBSERVATION_LIMIT = 256
95
+ const validPolicy = value => Boolean(value) && ['run', 'check', 'filterRoutes'].every(key => typeof value[key] === 'function')
96
+ // N09: one managed Task runner over the same SQLite authority. A crashed Host
97
+ // cannot release its runner lease, so the next boot waits for it to expire
98
+ // instead of silently dropping every managed task.
99
+ const TASK_RUNNER_TTL_MS = 10_000
100
+ const TASK_RUNNER_CLAIM_MS = 12_000
101
+ // Frequent enough that a stopped runner is rebuilt inside one lease period,
102
+ // rare enough that a healthy Host only reads one row every five seconds.
103
+ const TASK_RUNNER_WATCHDOG_MS = 5_000
104
+ const TASK_TTL_MS = 120_000
105
+ const TASK_BACKEND_LIMIT = 64
106
+ const TASK_READ_LIMIT = 512
107
+ const CLI_DEADLINE_MS = 120_000
108
+ const CLI_MODEL = /^gpt-[A-Za-z0-9_.-]+$/
109
+ const CLI_ENTRY_VERSION = /^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/
110
+ const TASK_TOOLS = Object.freeze(['task/execute', 'task/submit', 'task/read', 'task/cancel', 'task/retry', 'task/reconcile'])
111
+ const TASK_CAPABILITY = Object.freeze({ id: 'managed-tasks', label: '托管任务',
112
+ tools: Object.freeze([...TASK_TOOLS, 'execution/mr-api', 'execution/mr-retry', 'execution/mr-observe',
113
+ 'execution/bot-turn', 'execution/bot-turn-retry', 'execution/bot-turn-observe']) })
114
+ const CLI_GRANT_TOOLS = Object.freeze(['cli/codex', 'cli/cancel', 'cli/read', 'cli/retry'])
115
+ const MR_BACKEND = Object.freeze({ id: 'mr-api', version: 'mr-read-product-v1',
116
+ capability: 'execution/mr-api', retryCapability: 'execution/mr-retry', reconcileCapability: 'execution/mr-observe' })
117
+ const CLI_BACKEND = Object.freeze({ id: 'codex-read', version: 'cli-read-product-v1',
118
+ capability: 'cli/codex', retryCapability: 'cli/retry', reconcileCapability: 'cli/read' })
119
+
120
+ /**
121
+ * Compile one Bot's authority from its committed configuration. A Bot whose
122
+ * exact provider/model is not a configured route keeps no model authority:
123
+ * the strict rule pauses it instead of silently switching models.
124
+ */
125
+ export function compileProductGrant(bot, { routes, hydrated }) {
126
+ const key = typeof bot?.model === 'string' ? bot.model : ''
127
+ const slash = key.indexOf('/')
128
+ const provider = slash > 0 ? key.slice(0, slash) : ''
129
+ const model = slash > 0 ? key.slice(slash + 1) : ''
130
+ return { tools: [...PRODUCT_TOOLS],
131
+ modelRoutes: hydrated && provider && model && routes.has(key) ? [{ provider, model }] : [] }
132
+ }
133
+
134
+ /**
135
+ * An action that is only meaningful for a signed-in person says so at its own
136
+ * entry. The authorization seam decides who may call, but a route that forgets to
137
+ * declare its caller class inherits whatever the seam defaults to, and a later
138
+ * change there would hand a non-owner somebody's console.
139
+ */
140
+ const ownerOnly = fn => (payload, context) => {
141
+ if (context?.kind !== 'owner') throw Object.assign(new Error('OWNER_ONLY'), { code: 'OWNER_ONLY' })
142
+ return fn(payload, context)
143
+ }
144
+
145
+ /** Subscription usage is not API spend; missing token evidence stays unknown. */
146
+ export function projectModelCallAmount(receipt, resolution = null) {
147
+ if (resolution) return resolution.costUsd
148
+ if (receipt.funding === 'subscription') return 0
149
+ return actualCost({ usage: receipt.usage, usageIncomplete: !receipt.usageComplete,
150
+ reportedCostUsd: receipt.reportedCostUsd }, receipt.pricing).costUsd
151
+ }
152
+
153
+ const taskInput = input => {
154
+ const value = JSON.parse(canonicalTaskJson(input))
155
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== 1
156
+ || typeof value.task !== 'string' || !value.task.trim() || Buffer.byteLength(value.task) > 2000) {
157
+ throw Object.assign(new Error('INVALID_TASK_INPUT'), { code: 'INVALID_TASK_INPUT' })
158
+ }
159
+ return value
160
+ }
161
+
162
+ /**
163
+ * Host-configured official CLI entry. The executable, digest and version come
164
+ * from installation verification (`pinCodexExecutable` re-pins them at probe
165
+ * time); neither a Bot nor a Task input can supply an entry.
166
+ */
167
+ export function normalizeTaskCli(value) {
168
+ if (value === undefined || value === null) return null
169
+ const entry = value.entry
170
+ const remote = (Array.isArray(value.remote) ? value.remote : []).map(profile => {
171
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)
172
+ || Object.keys(profile).some(key => !['machineId','rootId','profileId','profileRevision','entry'].includes(key))
173
+ || !['machineId','rootId','profileId'].every(key => typeof profile[key] === 'string' && /^[-A-Za-z0-9_.]{1,128}$/.test(profile[key]))
174
+ || !Number.isSafeInteger(profile.profileRevision) || profile.profileRevision < 1
175
+ || !profile.entry || Object.keys(profile.entry).some(key => !['sha256','version'].includes(key))
176
+ || !/^[a-f0-9]{64}$/.test(profile.entry.sha256 ?? '') || !CLI_ENTRY_VERSION.test(profile.entry.version ?? '')) throw new TypeError('TASK_CLI_REMOTE_PROFILE_INVALID')
177
+ return Object.freeze({ machineId: profile.machineId, rootId: profile.rootId, profileId: profile.profileId, profileRevision: profile.profileRevision,
178
+ entry: Object.freeze({ sha256: profile.entry.sha256, version: profile.entry.version }) })
179
+ })
180
+ if (new Set(remote.map(profile => `${profile.machineId}/${profile.rootId}`)).size !== remote.length) throw new TypeError('TASK_CLI_REMOTE_PROFILE_DUPLICATE')
181
+ if (!entry && !remote.length) throw new TypeError('TASK_CLI_ENTRY_REQUIRED')
182
+ if (entry && (typeof entry.executable !== 'string' || !isAbsolute(entry.executable)
183
+ || typeof entry.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(entry.sha256)
184
+ || typeof entry.version !== 'string' || !CLI_ENTRY_VERSION.test(entry.version))) throw new TypeError('TASK_CLI_ENTRY_REQUIRED')
185
+ const models = (Array.isArray(value.models) ? value.models : []).map(model => {
186
+ if (!model || typeof model.provider !== 'string' || typeof model.model !== 'string'
187
+ || typeof model.cliModel !== 'string' || !CLI_MODEL.test(model.cliModel)) throw new TypeError('TASK_CLI_MODEL_REQUIRED')
188
+ return { provider: model.provider, model: model.model, cliModel: model.cliModel }
189
+ })
190
+ if (new Set(models.map(model => `${model.provider}/${model.model}/${model.cliModel}`)).size !== models.length) throw new TypeError('TASK_CLI_MODEL_DUPLICATE')
191
+ if (value.sessionFactory !== undefined && typeof value.sessionFactory !== 'string') throw new TypeError('TASK_CLI_SESSION_FACTORY_REQUIRED')
192
+ return Object.freeze({ entry: entry ? Object.freeze({ executable: entry.executable, sha256: entry.sha256, version: entry.version }) : null,
193
+ models: Object.freeze(models), remote: Object.freeze(remote), sessionFactory: value.sessionFactory ?? null })
194
+ }
195
+
196
+ /**
197
+ * Stable managed Task backends for the default product.
198
+ *
199
+ * The durable target contract (id, version, effect, location, capabilities)
200
+ * is fixed per backend kind, while the execution target, model and money are
201
+ * resolved at run time from the submitting Bot's committed binding. A binding
202
+ * change therefore never rewrites a persisted target hash: retries and
203
+ * reconciliations of older tasks stay admissible, and each Bot is charged at
204
+ * its own configured price. The Bot binding is the single source of truth for
205
+ * both the grant and the money, so no service-level model check is needed.
206
+ */
207
+ export function createTaskBackends({ ctx, mr, authority, directory, store, machineId, workspaceId, workspace, runtimeRoot, bindingFor, cli = null }) {
208
+ if (typeof bindingFor !== 'function' || typeof directory?.forBot !== 'function') throw new TypeError('TASK_BACKEND_SERVICES_REQUIRED')
209
+ if (store?.kind !== 'sqlite-router-state') throw new TypeError('TASK_BACKEND_STORE_REQUIRED')
210
+ const inners = new Map()
211
+ const innerFor = (kind, botId, build) => {
212
+ const key = taskHash({ kind, botId, ...build.key })
213
+ const cached = inners.get(key)
214
+ if (cached) return cached
215
+ if (inners.size >= TASK_BACKEND_LIMIT) return null
216
+ const value = build.create()
217
+ inners.set(key, value)
218
+ return value
219
+ }
220
+ const mrInnerFor = botId => {
221
+ const binding = bindingFor(botId)
222
+ const settings = binding?.status === 'available' ? binding.settings : null
223
+ const funding = settings?.funding ?? null
224
+ if (!settings?.model?.provider || !settings.model.model || funding?.mode !== 'api'
225
+ || !funding.pricing || !funding.request || funding.dailyBudgetUsd === null) return null
226
+ const model = { provider: settings.model.provider, model: settings.model.model }
227
+ const pricing = funding.pricing, dailyBudgetUsd = funding.dailyBudgetUsd
228
+ return innerFor('mr', botId, { key: { model, pricing, dailyBudgetUsd },
229
+ create: () => createMrReadBackend({ ctx, mr, model, workspace, machineId, workspaceId, pricing, dailyBudgetUsd }) })
230
+ }
231
+ const cliInnerFor = botId => {
232
+ if (!cli) return null
233
+ const binding = bindingFor(botId)
234
+ const settings = binding?.status === 'available' ? binding.settings : null
235
+ const funding = settings?.funding ?? null
236
+ const tool = settings?.officialTools?.find(item => item.toolId === 'codex' && item.mode === 'read-only')
237
+ if (!settings?.model?.provider || !settings.model.model || !tool || !funding?.scopeId) return null
238
+ if (funding.mode === 'api' && (!funding.pricing || !funding.request)) return null
239
+ if (funding.mode === 'subscription' && !(funding.subscription?.dailyCalls >= 1)) return null
240
+ const model = { provider: settings.model.provider, model: settings.model.model }
241
+ const billing = funding.mode
242
+ const pricing = billing === 'api' ? funding.pricing : null
243
+ const estimatedCostUsd = billing === 'api'
244
+ ? (funding.request.maxInputTokens * pricing.input + funding.request.maxOutputTokens * pricing.output) / 1e6 : null
245
+ const dailyLimitUsd = funding.dailyBudgetUsd ?? 0, monthlyLimitUsd = funding.monthlyBudgetUsd ?? 0
246
+ const subscriptionMaxCalls = billing === 'subscription' ? funding.subscription.dailyCalls : null
247
+ return innerFor('cli', botId, { key: { model, cliModel: tool.cliModel, billing, pricing, dailyLimitUsd, monthlyLimitUsd, subscriptionMaxCalls },
248
+ create: () => createCliReadBackend({ store, authority, directory, entry: cli.entry, runtimeRoot, workspace, machineId, workspaceId,
249
+ model, requestedModel: tool.cliModel, billing, pricing, estimatedCostUsd, dailyLimitUsd, monthlyLimitUsd,
250
+ subscriptionMaxCalls, deadlineMs: CLI_DEADLINE_MS, tools: [], handlers: [], sessionFactory: cli.sessionFactory }) })
251
+ }
252
+ const backend = (spec, resolve) => Object.freeze({
253
+ id: spec.id, version: spec.version, effect: 'read', machineId, workspaceId, location: 'local',atomicAdmission:true,
254
+ capability: spec.capability, retryCapability: spec.retryCapability, reconcileCapability: spec.reconcileCapability,
255
+ parse: taskInput,
256
+ run: context => { const inner = resolve(context.ownerBotId); return inner ? inner.run(context) : { outcome: 'failed', summary: { code: 'TASK_BINDING_UNAVAILABLE' } } },
257
+ reconcile: context => { const inner = resolve(context.ownerBotId); return inner ? inner.reconcile(context) : { outcome: 'unknown', reason: 'TASK_BINDING_UNAVAILABLE' } },
258
+ })
259
+ const backends = [backend(MR_BACKEND, mrInnerFor)]
260
+ if (cli) backends.push(backend(CLI_BACKEND, cliInnerFor))
261
+ return Object.freeze({ backends: Object.freeze(backends),
262
+ catalog: () => backends.map((item, index) => ({ id: item.id, version: item.version, effect: item.effect, location: item.location,
263
+ capability: item.capability, label: index === 0 ? '模型路由只读任务' : '官方 CLI 只读任务' })) })
264
+ }
265
+
266
+ function createRemoteCliTaskBackend({machines,directory,store,authority,profiles,bindingFor}){
267
+ const inners=new Map()
268
+ const resolve=botId=>{
269
+ const binding=bindingFor(botId),settings=binding?.status==='available'?binding.settings:null,funding=settings?.funding??null
270
+ const tool=settings?.officialTools?.find(item=>item.toolId==='codex'&&item.mode==='read-only')
271
+ if(!settings?.model?.provider||!settings.model.model||!tool||!funding?.scopeId)return null
272
+ if(funding.mode==='api'&&(!funding.pricing||!funding.request))return null
273
+ if(funding.mode==='subscription'&&!(funding.subscription?.dailyCalls>=1))return null
274
+ const model={provider:settings.model.provider,model:settings.model.model},billing=funding.mode,pricing=billing==='api'?funding.pricing:null
275
+ const estimatedCostUsd=billing==='api'?(funding.request.maxInputTokens*pricing.input+funding.request.maxOutputTokens*pricing.output)/1e6:null
276
+ const dailyLimitUsd=funding.dailyBudgetUsd??0,monthlyLimitUsd=funding.monthlyBudgetUsd??0,subscriptionMaxCalls=billing==='subscription'?funding.subscription.dailyCalls:null
277
+ const key=taskHash({botId,model,cliModel:tool.cliModel,billing,pricing,dailyLimitUsd,monthlyLimitUsd,subscriptionMaxCalls})
278
+ if(inners.has(key))return inners.get(key)
279
+ if(inners.size>=TASK_BACKEND_LIMIT)return null
280
+ const inner=createRemoteCliReadBackend({machines,directory,store,authority,profiles,model,requestedModel:tool.cliModel,billing,pricing,estimatedCostUsd,dailyLimitUsd,monthlyLimitUsd,subscriptionMaxCalls,deadlineMs:CLI_DEADLINE_MS})
281
+ inners.set(key,inner);return inner
282
+ }
283
+ const forSubject=subject=>resolve(authority.describe(subject).botId)
284
+ const unavailable=()=>{throw Object.assign(new Error('TASK_BINDING_UNAVAILABLE'),{code:'TASK_BINDING_UNAVAILABLE'})}
285
+ return Object.freeze({id:'remote-codex-read',version:'remote-cli-read-product-v1',effect:'read',location:'remote',atomicAdmission:true,requiresExecutionInput:true,
286
+ machineId:'remote-target',workspaceId:'remote-root',capability:'remote/cli_task_v1',retryCapability:'remote/cli_task_v1',reconcileCapability:'remote/operation_status',parse:parseRemoteTaskCli,
287
+ targetFor(subject,input){return (forSubject(subject)??unavailable()).targetFor(subject,input)},
288
+ canObserve(subject,target,input){return forSubject(subject)?.canObserve(subject,target,input)??false},
289
+ run(context){const inner=resolve(context.ownerBotId);return inner?inner.run(context):{outcome:'failed',summary:{code:'TASK_BINDING_UNAVAILABLE'}}},
290
+ reconcile(context){const inner=resolve(context.ownerBotId);return inner?inner.reconcile(context):{outcome:'unknown',reason:'TASK_BINDING_UNAVAILABLE'}},
291
+ probe(subject,input){const inner=forSubject(subject);if(!inner)unavailable();return inner.probe(subject,input)},
292
+ async close(){await Promise.allSettled([...inners.values()].map(inner=>inner.close()))},
293
+ })
294
+ }
295
+
296
+ /**
297
+ * Compose the frozen DS Bot runtime with the MR plugin body inside one Host.
298
+ * The synchronous segment creates the shared authority/directory/policy and
299
+ * provides `botIntegrationPolicy` before this function first returns, so every
300
+ * legacy MR tool, command and RPC receiver resolves the same policy at call
301
+ * time. The DS runtime itself is created after a bounded initial route
302
+ * snapshot, because grant compilation is synchronous.
303
+ *
304
+ * When another trusted Host component already provides a valid
305
+ * `botIntegrationPolicy` (isolated test fixtures), this composition defers to
306
+ * it instead of installing a second runtime or a duplicate service.
307
+ *
308
+ * `accountStream` is the shared money decorator. M1 passes a clearly labeled
309
+ * diagnostic decorator: it records bounded identity/source observations and
310
+ * never claims a monetary amount. Per-source receipts and budget admission
311
+ * arrive with the unified SQLite money authority.
312
+ */
313
+ export function startBotProduct(ctx, options = {}) {
314
+ const { discoverRoutes, accountStream, config = {}, actions = {}, machineId = `local-${hostname()}`, workspaceId = 'default',
315
+ runtimeConfig, onBind, refreshIntervalMs = ROUTE_REFRESH_MS, now = Date.now, failClosed = true,
316
+ deferredRouterState = null, legacyFile = defaultStatePath(), pricingFor = async () => null, mr = null, taskCli = null,
317
+ Database = createRequire(import.meta.url)('better-sqlite3'), storageFile = join(resolveStateHome(), 'model-router', 'orchestration.sqlite') } = options
318
+ if (discoverRoutes !== undefined && typeof discoverRoutes !== 'function') throw new TypeError('BOT_PRODUCT_ROUTES_REQUIRED')
319
+ if (typeof accountStream !== 'function') throw new TypeError('BOT_PRODUCT_ACCOUNTING_REQUIRED')
320
+ // A misconfigured official CLI entry must fail loudly at composition time
321
+ // instead of silently degrading the managed Task surface.
322
+ const cliConfig = normalizeTaskCli(taskCli)
323
+ const engineProfiles = normalizeConversationEngines(options.conversationEngines ?? [])
324
+ // Grant compilation needs the raw configured-route catalog. The plugin's
325
+ // discoverConfiguredRoutes applies the per-subject policy filter, which
326
+ // refuses outside an execution context; a filtered catalog would leave every
327
+ // Bot without model authority.
328
+ const catalog = async () => {
329
+ if (typeof discoverRoutes === 'function') return discoverRoutes()
330
+ const providers = ctx.llm?.listProviders?.() ?? []
331
+ const routes = []
332
+ for (const entry of Array.isArray(providers) ? providers : []) {
333
+ const provider = typeof entry === 'string' ? entry : entry?.id
334
+ if (!provider) continue
335
+ let models
336
+ try { models = await ctx.llm.listModels(provider) } catch { continue }
337
+ for (const listed of Array.isArray(models) ? models : []) {
338
+ const model = listed?.id ?? listed?.model
339
+ if (model) routes.push({ provider, model })
340
+ }
341
+ }
342
+ return routes
343
+ }
344
+ let provided
345
+ try { provided = ctx.get?.('botIntegrationPolicy') } catch { /* absent optional service */ }
346
+ if (provided !== undefined) {
347
+ if (!validPolicy(provided)) throw new Error('BOT_PRODUCT_POLICY_CONFLICT: an invalid botIntegrationPolicy is already provided')
348
+ return null
349
+ }
350
+ const authority = createAuthority()
351
+ const directory = createPrincipalDirectory(authority)
352
+ ctx.provide('botIdForAgent', agent => {
353
+ try { return authority.describe(directory.forAgent(agent)).botId } catch { return null }
354
+ })
355
+ const ownerTools=[...PRODUCT_OWNER_READ_METHODS,...PRODUCT_OWNER_WRITE_METHODS].map(name=>`remote/${name}`)
356
+ const policy = createBotPolicy({ authority, directory, machineId, workspaceId,ownerGrant:{tools:ownerTools} })
357
+ ctx.provide('botIntegrationPolicy', policy)
358
+ // Bot routing reads the same committed state everything else obeys: the Bot list, each Bot's
359
+ // saved settings, and machine reachability from health. It never invents a candidate.
360
+ // Routines live beside the router state; the plugin owns that directory and provides the store.
361
+ // Handing work between Bots: the ledger makes loops impossible, the plan carries the sender.
362
+ const handoffLedger = createHandoffLedger()
363
+ const handoffTask = async (args, context) => {
364
+ if (context?.kind !== 'bot' || context.botId !== args.fromBotId
365
+ || !product?.lifecycle.committedMainBotIds().includes(context.botId)) throw new Error('BOT_ROUTING_HANDOFF_DENIED')
366
+ assertHandoffAllowed({ taskId: args.taskId, fromBotId: args.fromBotId, toBotId: args.toBotId, ledger: handoffLedger })
367
+ const { plan } = buildHandoff({ taskId: args.taskId, fromBotId: args.fromBotId, toBotId: args.toBotId, objective: args.objective })
368
+ if (!taskService) throw new Error('TASK_SERVICE_UNAVAILABLE')
369
+ plan.origin.sessionId = context.sessionId
370
+ for (const item of plan.packages) item.execution = { backend: 'bot-turn-read', input: { task: item.objective } }
371
+ const submitted = await taskService.submitPlan(taskOwner({ botId: args.fromBotId }), { requestKey: `handoff-${args.taskId}-${args.toBotId}`, plan }, { sessionId: context.sessionId })
372
+ turnBackends?.watchPlan({ planId: submitted.planId, botId: args.toBotId, originBotId: args.fromBotId })
373
+ handoffLedger.held(args.taskId, args.fromBotId)
374
+ handoffLedger.held(args.taskId, args.toBotId)
375
+ return { plan, submitted }
376
+ }
377
+ const routingSettingsFor = botId => {
378
+ try {
379
+ const row = botSettings?.get(botId, { publishChange: false })
380
+ if(!row)return null
381
+ const main=product?.lifecycle.committedMainBotIds().includes(botId)===true
382
+ const scopes=main?(machines?.scopes(botId)??[]):[]
383
+ const remoteIds=new Set((machines?.readModel().items??[]).map(machine=>machine.id))
384
+ const machineGrants=main?[...row.settings.machineGrants.filter(grant=>!remoteIds.has(grant.machineId)),
385
+ ...scopes.flatMap(scope=>scope.roots.map(rootId=>({machineId:scope.machineId,rootId,mode:scope.mode,capabilities:[...scope.tools]})))]:row.settings.machineGrants
386
+ return {...row.settings,machineGrants,grant:main?(product?.lifecycle.committedGrant(botId)??row.grant):row.grant,status:row.status}
387
+ }
388
+ catch { return null }
389
+ }
390
+ const commitWorkerBinding = createWorkerBindingCommitter({ machines: () => machines, settingsFor: routingSettingsFor,
391
+ mainBotIds: () => product?.lifecycle.committedMainBotIds() ?? [] })
392
+ const botRouting = createBotRoutingService({
393
+ bots: () => (product?.lifecycle.committedBots() ?? []).map(bot=>({...bot,main:product.lifecycle.committedMainBotIds().includes(bot.id)})),
394
+ settingsFor: routingSettingsFor,
395
+ machineHealth: machineId => {
396
+ try { const row = machines?.readModel?.().items?.find(item => item.id === machineId); return { online: row?.status === 'online' } } catch { return null }
397
+ },
398
+ routing: () => routingSettings?.get().settings ?? config.botRouting,
399
+ mainBotIds: () => product?.lifecycle.committedMainBotIds() ?? [],
400
+ subjectFor: botId => directory.forBot(botId),
401
+ submitPlan: (subject, request, caller) => {
402
+ if (!taskService) throw new Error('TASK_SERVICE_UNAVAILABLE')
403
+ return taskService.submitPlan(subject, request, caller)
404
+ },
405
+ readSubmittedPlan: (originBotId, planId) => {
406
+ if (!journal) throw Object.assign(new Error('BOT_ROUTING_STATUS_UNAVAILABLE'), { code: 'BOT_ROUTING_STATUS_UNAVAILABLE' })
407
+ // Authenticated owner observation reads durable identities even if the Bot was paused.
408
+ return journal.getPlan(originBotId, planId)
409
+ },
410
+ bindWorker: (binding, context) => commitWorkerBinding(binding, context),
411
+ executionBackendFor: effect => effect === 'read' ? 'bot-turn-read' : 'bot-turn-write',
412
+ onAccepted: report => turnBackends?.watchPlan(report),
413
+ busyFor: botId => product?.rt.live?.get(product.rt.chatOf(botId))?.turn !== undefined,
414
+ priceFor: (route, botId) => {
415
+ // Funding prices belong to the Bot binding, even when another Bot uses the same model.
416
+ const settings = routingSettingsFor(botId)
417
+ if (settings?.model?.provider !== route.provider || settings.model.model !== route.model) return null
418
+ const price = settings.funding?.pricing
419
+ if (!Number.isFinite(price?.input) || price.input < 0 || !Number.isFinite(price?.output) || price.output < 0) return null
420
+ return (price.input + price.output) / 2
421
+ },
422
+ })
423
+ ctx.provide('botRouting', botRouting)
424
+ const describeBot = subject => { try { return authority.describe(subject).botId } catch { return null } }
425
+ // Operator passthrough: a session that is not a Bot (the operator's own agent session) would
426
+ // otherwise be refused as UNKNOWN_SUBJECT by every Bot-shaped tool. When the operator turns this
427
+ // on, such a session acts with the Main Bot's authority — the same grants, the same audit trail
428
+ // — instead of being handed a second, parallel set of permissions.
429
+ const operatorSubject = () => {
430
+ const settings = config?.operatorPassthrough
431
+ // On by default: the operator's own session is the person who owns the machines.
432
+ if (settings && settings.enabled === false) return null
433
+ const mainId = product?.lifecycle.committedMainBotIds()[0]
434
+ if (!mainId) return null
435
+ try { return directory.forBot(mainId) ?? null } catch { return null }
436
+ }
437
+ const subjectOrOperator = subject => subject ?? operatorSubject()
438
+ ctx.provide('operatorSubject', operatorSubject)
439
+ // Owner task management acts on the owning Bot's own subject: the Bot's
440
+ // committed grant authorizes the operation, never the browser session.
441
+ const taskOwner = payload => {
442
+ const botId = payload?.botId
443
+ if (typeof botId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(botId)) throw new Error('INVALID_TASK_OWNER')
444
+ return directory.forBot(botId)
445
+ }
446
+
447
+ const grants = new Map()
448
+ const modelFrames = createModelFrameStore()
449
+ const loopFunding = new WeakMap()
450
+ const sessionLoopFrame = createSessionLoopFrameResolver({ sessionFor: id => ctx.get('sessions')?.get?.(id), isLoopRequest: isAgentLoopRequest,
451
+ allowUnbounded: request => engineProfiles.some(profile => profile.provider === request.provider && profile.model === request.model) })
452
+ let conversationEngines
453
+ const engineCredentialStamps = new Map()
454
+ const engineLeaseId = `native-${randomUUID()}`
455
+ let machines, nativeRemoteActions, product, releaseMachineSecrets, funding, botSettings, fundingAccounts, modelAccounting, routingSettings, turnBackends, routineExecution, machineHeartbeat, securePairing, desktopProxy, desktopHttp
456
+ let journal, taskService, taskBackends, releaseTaskTools, cliSessionFactory, remoteCliBackend, sharedAdmission, taskRunnerWatchdog, notifications, dataEngine, dataAccess, releaseDataTools, uiNamespace, nativeActivity, botThreads, webPanelStore, webPanelActions, independentChat, teamCoordination, releaseTeamTools, releaseTeamGuard, releaseTeamStop
457
+ const grantRefresh = createGrantRefreshQueue({ lifecycle: () => product?.lifecycle,
458
+ onError: () => ctx.logger?.warn?.('Bot grant refresh failed; grants remain suspended until a successful committed-state refresh.') })
459
+ // The service resolves live dependencies at each call. It is available before
460
+ // async boot, so connect cannot capture an absent tool service forever.
461
+ const ordinaryIndependentTools = createIndependentToolHost({ directory, authority,
462
+ machines: () => machines, taskService: () => taskService,
463
+ supportsToolsFor: (botId, nativeRoute) => nativeRoute
464
+ ? engineProfiles.some(profile => profile.hostTools && profile.provider === nativeRoute.provider && profile.model === nativeRoute.model)
465
+ : !engineProfiles.some(profile => {
466
+ const bot=product?.lifecycle.committedBot(botId)
467
+ return bot?.model===`${profile.provider}/${profile.model}`
468
+ }),
469
+ backends: () => taskBackends ? taskBackends.catalog().map(entry => entry.id) : [], now })
470
+ ctx.provide('botIndependentToolHost', extendIndependentToolHost({ base: ordinaryIndependentTools,
471
+ validateCall: (botId, context) => teamCoordination?.assertCanAct(botId, { conversationId: context.conversationId }),
472
+ familiesFor: (botId, context) => {
473
+ const bot = product?.lifecycle.committedBot(botId)
474
+ if (!bot || !dataAccess || typeof bot.model !== 'string') return []
475
+ const modelRef = bot.model, slash = modelRef.indexOf('/')
476
+ if (slash < 1) return []
477
+ const route = { provider: modelRef.slice(0, slash), model: modelRef.slice(slash + 1) }
478
+ if (engineProfiles.some(profile => profile.provider === route.provider && profile.model === route.model)) return []
479
+ const bindingStamp = () => {
480
+ const binding = bindingRoute({ botId, ...route })
481
+ return binding ? JSON.stringify([binding.revision, binding.account?.revision ?? null]) : null
482
+ }
483
+ const subject = directory.forBot(botId), revision = bindingStamp()
484
+ if (!revision) return []
485
+ const validate = () => {
486
+ if (product.lifecycle.committedBot(botId)?.model !== modelRef || directory.forBot(botId) !== subject) throw Object.assign(new Error('Bot 配置已变化,本次工具结果暂不释放。'), { code: 'SUBJECT_MISMATCH' })
487
+ product.serving.assertMode(botId, 'independent')
488
+ authority.assertTool(subject, 'model/infer')
489
+ authority.assertModel(subject, route.provider, route.model)
490
+ if (bindingStamp() !== revision) throw Object.assign(new Error('模型资金配置已变化。'), { code: 'MODEL_FUNDING_CHANGED' })
491
+ }
492
+ try { validate() } catch { return [] }
493
+ const data = createNativeDataTools({ access: dataAccess, subject, botId, validate,
494
+ redact: product.rt.redactSecrets })
495
+ const entries = data.handlers.map(handler => ({ handler, validate, readonly: handler.definition.name === 'data_describe' }))
496
+ if (teamCoordination?.canUse(botId)) {
497
+ const conversationId = context?.conversationId ?? policy.activeIdentity()?.conversationId ?? ctx.get('botRuntimeAdapter')?.contextOf?.(botId)?.contextId
498
+ const team = createNativeTeamTools({ service: teamCoordination, callerBotId: botId, conversationId,
499
+ validate: () => { validate(); teamCoordination.assertCanAct(botId, { conversationId }) }, redact: product.rt.redactSecrets })
500
+ entries.push(...team.handlers.map(handler => ({ handler, validate, readonly: handler.definition.name !== 'coordinate_bots' })))
501
+ }
502
+ return entries
503
+ } }))
504
+ const threadAction = (name, payload) => {
505
+ if (!botThreads?.ready) throw Object.assign(new Error('当前宿主未提供完整线程会话能力。'), { code: 'BOT_THREADS_UNAVAILABLE' })
506
+ return botThreads.actions[name](payload)
507
+ }
508
+ const independentChatAction = (name, payload, context) => {
509
+ if (!independentChat?.ready || typeof independentChat[name] !== 'function') throw Object.assign(new Error('独立聊天尚未就绪。'), { code: 'INDEPENDENT_CHAT_UNAVAILABLE' })
510
+ const value = independentChat[name](payload, context)
511
+ if (['accepted', 'running'].includes(value?.state) && (name === 'newConversation' || name === 'cancel' && value.cancelRequested)) teamCoordination?.stop(payload.botId)
512
+ return value
513
+ }
514
+ const committedBots = new Map()
515
+ let ownerCatalogStamp=''
516
+ let routes = new Set(), hydrated = false, refreshing = false, lastRefresh = 0, timer = null
517
+ const refresh = async () => {
518
+ if (refreshing) return
519
+ refreshing = true
520
+ try {
521
+ routes = new Set((await catalog()).map(route => `${route.provider}/${route.model}`)); hydrated = true
522
+ const ownerRoutes=[...routes].sort().map(key=>{const slash=key.indexOf('/');return {provider:key.slice(0,slash),model:key.slice(slash+1)}}).filter(route=>route.provider&&route.model)
523
+ const stamp=JSON.stringify(ownerRoutes)
524
+ if(stamp!==ownerCatalogStamp){policy.replaceOwnerGrant({tools:ownerTools,modelRoutes:ownerRoutes});ownerCatalogStamp=stamp}
525
+ if(product)await product.lifecycle.refreshGrants()
526
+ }
527
+ catch { /* keep the last snapshot; the interval retries */ }
528
+ finally { refreshing = false; lastRefresh = now() }
529
+ }
530
+ const scheduleRefresh = () => {
531
+ if (refreshing || now() - lastRefresh < ROUTE_REFRESH_DEBOUNCE_MS) return
532
+ void refresh()
533
+ }
534
+ const grantFor = (bot, { main = false } = {}) => {
535
+ // The DS lifecycle compiles grants from committed state; recording here keeps
536
+ // bot-settings' botExists honest during that same synchronous compilation.
537
+ committedBots.set(bot.id, bot)
538
+ scheduleRefresh()
539
+ let grant = botSettings ? botSettings.grantFor(bot) : compileProductGrant(bot, { routes, hydrated })
540
+ // Main is full-control, compiled only from the saved role and Host catalogs.
541
+ // A worker's checkbox grant cannot silently narrow the owner's Main Bot.
542
+ if (main) grant = compileMainBotGrant({ base: grant, catalog: settingsCatalog(),
543
+ routes: hydrated ? [...routes.keys()].map(key => { const at = key.indexOf('/'); return { provider: key.slice(0, at), model: key.slice(at + 1) } }) : [],
544
+ toolNames: ctx.tools.schemas().map(tool => tool.name), teamToolNames: (product?.rt.teamTools ?? []).map(tool => tool.name) })
545
+ const names = {machine_ping:'remote_computer_ping',fs_list:'remote_computer_list',fs_read:'remote_computer_read',fs_write:'remote_computer_write'}
546
+ // Grant compilation is synchronous and runs on every DS save/load: an
547
+ // unavailable machine directory (not yet opened, or closed after a failed
548
+ // boot) grants no machine access instead of suspending the lifecycle.
549
+ let scopes = [],cliOfficial=false
550
+ // Authorisation comes from committed state; `scopes()` only adds capabilities that are live
551
+ // right now. Relying on scopes() alone meant a restart granted the Main Bot no remote tools
552
+ // at all, because the directory is not open yet when grants compile.
553
+ try { scopes = machines?.bindingsFor(bot.id,{main}) ?? [] } catch { scopes = [] }
554
+ cliOfficial=main
555
+ if(!main)try { cliOfficial=botSettings?.get(bot.id,{publishChange:false}).settings?.officialTools?.some(item=>item.toolId==='codex'&&item.mode==='read-only')===true } catch {}
556
+ for (const scope of scopes) for (const tool of scope.tools) {
557
+ if(['operation_status','operation_cancel'].includes(tool)){grant.tools.push(`remote/${tool}`,'tool/remote_computer_operation');continue}
558
+ if(tool==='artifact_stat'||tool==='artifact_read'){grant.tools.push(`remote/${tool}`);continue}
559
+ if(['cli_probe_v1','cli_task_v1'].includes(tool)){
560
+ const profile=cliConfig?.remote.find(item=>item.machineId===scope.machineId&&scope.roots.includes(item.rootId))
561
+ if(cliOfficial&&profile&&['cli_probe_v1','cli_task_v1','operation_status','operation_cancel'].every(name=>scope.tools.includes(name)))grant.tools.push(`remote/${tool}`,...(tool==='cli_probe_v1'?['tool/remote_computer_cli_probe']:[]))
562
+ continue
563
+ }
564
+ if(tool.startsWith('browser_')){if(scope.mode==='workspace-write')grant.tools.push(`remote/${tool}`,'tool/remote_computer_browser');continue}
565
+ if(tool.startsWith('desktop_')){
566
+ if(scope.mode==='workspace-write'&&['artifact_stat','artifact_read','operation_status','operation_cancel'].every(name=>scope.tools.includes(name)))grant.tools.push(`remote/${tool}`,'tool/remote_computer_desktop')
567
+ continue
568
+ }
569
+ if(tool==='container_command_v1'){
570
+ if(scope.mode==='workspace-write'&&['container_command_v1','operation_status','operation_cancel'].every(name=>scope.tools.includes(name)))grant.tools.push(`remote/${tool}`,'tool/remote_computer_command')
571
+ continue
572
+ }
573
+ if (!names[tool]) continue
574
+ if(tool==='fs_write'&&(scope.mode!=='workspace-write'||!['fs_write','operation_status','operation_cancel'].every(name=>scope.tools.includes(name))))continue
575
+ grant.tools.push(`remote/${tool}`, `tool/${names[tool]}`)
576
+ }
577
+ grant.tools = [...new Set(grant.tools)]
578
+ grants.set(bot.id, grant)
579
+ return grant
580
+ }
581
+ const bindings = async ({ runtime, signal }) => {
582
+ signal?.throwIfAborted()
583
+ await runtime.load()
584
+ const state = runtime.state ?? {}
585
+ const items = (product?.lifecycle.committedBots()??[]).map(bot => {
586
+ const grant = grants.get(bot.id)
587
+ return { botId: bot.id, name: bot.name ?? '', role: bot.role ?? '', model: bot.model ?? null,
588
+ sessionId: runtime.chatOf(bot.id), main: product.lifecycle.committedMainBotIds().includes(bot.id),
589
+ tools: grant?.tools ?? [], modelRoutes: grant?.modelRoutes ?? [], workspaceId, machineId }
590
+ })
591
+ return { status: 'available', items }
592
+ }
593
+ // The parent Host authenticates the browser session; absence never enables a route.
594
+ const authorizeRequest = async request => {
595
+ const admission = ctx.connection?.admit(request)
596
+ if (!admission || 'rejection' in admission || admission.peer?.principal?.kind !== 'owner') return null
597
+ return { kind: 'owner', peer: admission.peer }
598
+ }
599
+ // Trusted catalog for Bot binding validation: exact route models, the local
600
+ // machine entry (an enabled binding requires a workspaceRef) and enabled
601
+ // funding scopes. Tools and capability catalogs arrive with their facades.
602
+ const settingsCatalog = () => {
603
+ const models = [...routes].map(key => { const slash = key.indexOf('/'); return { provider: key.slice(0, slash), model: key.slice(slash + 1), label: key, cliModels: [] } })
604
+ for (const model of models) {
605
+ const profile = engineProfiles.find(item => item.provider === model.provider && item.model === model.model)
606
+ if (profile) { model.label = `${profile.name} · 仅文字 · ${model.model}`; model.fundingModes = ['subscription'] }
607
+ }
608
+ const localMachineId = machineId.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 100) || 'local-host'
609
+ // A CLI identity is only offered once the host verified it against the
610
+ // installed official tool; an unverified route keeps no cliModels entry.
611
+ for (const model of models) {
612
+ for (const mapping of cliConfig?.models ?? []) {
613
+ if (mapping.provider === model.provider && mapping.model === model.model) model.cliModels.push({ toolId: 'codex', model: mapping.cliModel })
614
+ }
615
+ }
616
+ const remoteMachines=new Map()
617
+ for(const profile of cliConfig?.remote??[]){
618
+ if(!remoteMachines.has(profile.machineId))remoteMachines.set(profile.machineId,{machineId:profile.machineId,label:profile.machineId,location:'remote',status:'configured',roots:[]})
619
+ remoteMachines.get(profile.machineId).roots.push({rootId:profile.rootId,label:profile.rootId,modes:['read-only'],capabilities:[]})
620
+ }
621
+ for(const machine of machines?.readModel().items??[]){
622
+ if(machine.status==='revoked')continue
623
+ const roots=machine.roots.map(rootId=>({rootId,label:rootId,modes:['read-only','workspace-write'],capabilities:[...machine.tools]}))
624
+ remoteMachines.set(machine.id,{machineId:machine.id,label:machine.name,location:'remote',status:machine.status,roots})
625
+ }
626
+ let hostProfiles = []
627
+ try {
628
+ const value = typeof config.modelProfilesJson === 'function' ? config.modelProfilesJson() : config.modelProfilesJson
629
+ hostProfiles = parseModelProfilesJson(value ?? '[]')
630
+ } catch { /* An invalid price catalog contributes no suggested prices. */ }
631
+ for (const model of models) {
632
+ const quote = hostProfiles.find(item => item.provider === model.provider && item.model === model.model)?.pricing
633
+ if (quote) {
634
+ const { currency, ...pricing } = quote
635
+ model.pricing = pricing
636
+ }
637
+ }
638
+ return { models, tools: cliConfig ? [{ toolId: 'codex', modes: ['read-only'], grantTools: [...CLI_GRANT_TOOLS] }] : [],
639
+ machines: [{ machineId: localMachineId, label: '本机', location: 'local', status: 'ready',
640
+ roots: [{ rootId: 'default', label: '默认工作区', modes: ['read-only', 'workspace-write'], capabilities: [] }] },...remoteMachines.values()],
641
+ capabilities: [
642
+ { id: 'model-inference', label: '模型推理', tools: ['model/infer'] },
643
+ { id: 'mr-routing', label: '模型路由', tools: PRODUCT_TOOLS.filter(name=>name!=='model/infer') },
644
+ { id: 'data-analysis', label: '数据分析与图表', tools: ['tool/data'] },
645
+ { id: 'team-work', label: '团队协作与委派', tools: ['tool/team', 'tool/coordinate_bots', 'tool/assignment_status', 'tool/list_bots', 'tool/list_threads'] },
646
+ { ...TASK_CAPABILITY },
647
+ ...[...new Set((machines?.readModel().items ?? []).filter(machine => machine.status !== 'revoked').flatMap(machine => machine.tools))]
648
+ .map(tool => ({ id: tool, label: tool, tools: [`remote/${tool}`] })),
649
+ ],
650
+ taskBackends: taskBackends ? taskBackends.catalog() : [],
651
+ fundingScopes: fundingAccounts ? fundingAccounts.catalog().filter(scope => scope.enabled).map(scope => ({
652
+ id: scope.id, label: scope.provider, modes: ['api','subscription'],
653
+ models: models.filter(model => model.provider === scope.provider).map(({ provider, model }) => ({ provider, model })),
654
+ })) : [] }
655
+ }
656
+ // N06b: the Bot binding is the funding source of truth for its own model
657
+ // calls. An unconfigured, mismatched or incomplete binding yields null and
658
+ // the accounting layer refuses the call; there is no silent fallback route.
659
+ const bindingRoute = ({ botId, provider, model }) => {
660
+ if (!botSettings) return null
661
+ const value = botSettings.get(botId, { publishChange: false })
662
+ const settings = value.settings
663
+ const route = provider === undefined && model === undefined ? settings.model : { provider, model }
664
+ if (!route?.provider || !route?.model) return null
665
+ const multi = Boolean(settings.mainRoutes && product?.lifecycle.committedMainBotIds().includes(botId))
666
+ let selected = settings.funding
667
+ if (multi) {
668
+ if (!settings.enabled || value.retired || value.errorCode === 'BOT_SETTINGS_SUSPENDED') return null
669
+ try { selected = checkMainRouteFunding(route, fundingForMainRoute(settings.mainRoutes, route), value.catalog) }
670
+ catch { return null }
671
+ } else if (value.status !== 'available' || settings.model?.provider !== route.provider || settings.model?.model !== route.model) {
672
+ return null
673
+ }
674
+ const account = selected.scopeId && fundingAccounts ? fundingAccounts.get(selected.scopeId) : null
675
+ if (multi && (!account?.enabled || account.provider !== route.provider)) return null
676
+ return { route, funding: selected, account, multi, revision: value.revision }
677
+ }
678
+ const bindingFunding = query => {
679
+ const binding = bindingRoute(query)
680
+ if (!binding) return null
681
+ const { route, funding: selected, account, multi, revision } = binding
682
+ const profile = multi ? { fundingRevision: `${revision}:${account.revision}` } : {}
683
+ if (selected.mode === 'subscription') {
684
+ return { funding: 'subscription', route: { ...route }, scopeId: selected.scopeId,
685
+ subscriptionMaxCalls: selected.subscription?.dailyCalls ?? null,
686
+ subscriptionMonthlyCalls: selected.subscription?.monthlyCalls ?? null, ...profile }
687
+ }
688
+ if (!selected.pricing || !selected.request) return null
689
+ return { funding: 'api', route: { ...route }, scopeId: selected.scopeId, pricing: selected.pricing,
690
+ maxInputTokens: selected.request.maxInputTokens, maxOutputTokens: selected.request.maxOutputTokens,
691
+ botDailyLimitUsd: selected.dailyBudgetUsd, botMonthlyLimitUsd: selected.monthlyBudgetUsd, ...profile,
692
+ ...(account ? { accountDailyLimitUsd: account.dailyLimitUsd, accountMonthlyLimitUsd: account.monthlyLimitUsd } : {}) }
693
+ }
694
+ const boot = (async () => {
695
+ try{
696
+ const nativeHostRequests = new WeakMap()
697
+ const engineContext = (profile, route, expected = null) => {
698
+ const refuse = code => { throw Object.assign(new Error(code), { code }) }
699
+ const active = policy.activeIdentity()
700
+ if (active?.owner) refuse('CONVERSATION_ENGINE_BOT_REQUIRED')
701
+ const independent = expected ? expected.independent === true : active?.independent === true
702
+ const session = independent ? null : ctx.get('sessions')?.get?.(route.sessionId)
703
+ if (!independent && (!session || session.id !== route.sessionId || !product?.rt.isBotSession(route.sessionId))) refuse('CONVERSATION_ENGINE_HOST_SESSION_REQUIRED')
704
+ const frame = independent ? expected ?? active : policy.modelIdentity(route)
705
+ if (!frame?.botId) refuse('CONVERSATION_ENGINE_BOT_REQUIRED')
706
+ const bot = product?.lifecycle.committedBot(frame.botId)
707
+ if (!bot || profile.provider !== route.provider || profile.model !== route.model) refuse('CONVERSATION_ENGINE_BINDING_CHANGED')
708
+ const binding = bindingRoute({ botId: bot.id, provider: route.provider, model: route.model })
709
+ if (!binding || ((!binding.multi || independent) && bot.model !== `${route.provider}/${route.model}`)) refuse('CONVERSATION_ENGINE_BINDING_CHANGED')
710
+ if (binding.funding.mode !== 'subscription') refuse('CONVERSATION_ENGINE_SUBSCRIPTION_REQUIRED')
711
+ const subject = directory.forBot(bot.id)
712
+ if (subject !== frame.subject) refuse('SUBJECT_MISMATCH')
713
+ product.serving.assertMode(bot.id, independent ? 'independent' : 'integration')
714
+ authority.assertTool(subject, 'model/infer'); authority.assertModel(subject, route.provider, route.model)
715
+ return { bot, subject, independent, session, multi: binding.multi,
716
+ fundingRevision: binding.multi ? `${binding.revision}:${binding.account.revision}` : null }
717
+ }
718
+ const projectNativeRequest = (request, current) => {
719
+ if (current.independent) return request
720
+ const projected = projectHostLoopTextRequest(request, {
721
+ session: current.session, isLoopRequest: isAgentLoopRequest, allowRouteContext: current.multi,
722
+ })
723
+ const bootstrap = current.multi ? hostLoopRouteContext(projected, {
724
+ session: current.session, redact: product.rt.redactSecrets,
725
+ }) : null
726
+ return { request: projected, bootstrap }
727
+ }
728
+ const engineSecret = async secretRef => {
729
+ if (!product) throw Object.assign(new Error('CONVERSATION_ENGINE_CREDENTIAL_UNAVAILABLE'), { code: 'CONVERSATION_ENGINE_CREDENTIAL_UNAVAILABLE' })
730
+ await product.rt.loadSecrets()
731
+ const name = secretRef.slice('ds-secret/'.length)
732
+ const view = product.rt.secretsView().find(secret => secret.name === name)
733
+ if (!view || !Array.isArray(view.scope) || view.scope.length) throw Object.assign(new Error('CONVERSATION_ENGINE_CREDENTIAL_MUST_BE_HOST_ONLY'), { code: 'CONVERSATION_ENGINE_CREDENTIAL_MUST_BE_HOST_ONLY' })
734
+ return product.rt.secretValue(name)
735
+ }
736
+ const checkEngineCredential = async (profile, botId) => {
737
+ let value
738
+ try { value = validateClaudeProfileCredential(await engineSecret(profile.credentialRef)) }
739
+ catch (error) { await conversationEngines?.invalidateBot(profile.id, botId, 'credential-unavailable'); throw error }
740
+ if (typeof value !== 'string' || !value) {
741
+ await conversationEngines?.invalidateBot(profile.id, botId, 'credential-unavailable')
742
+ throw Object.assign(new Error('CONVERSATION_ENGINE_CREDENTIAL_UNAVAILABLE'), { code: 'CONVERSATION_ENGINE_CREDENTIAL_UNAVAILABLE' })
743
+ }
744
+ const key = `${profile.id}\0${botId}`, stamp = createHash('sha256').update(value).digest('hex')
745
+ const previous = engineCredentialStamps.get(key)
746
+ engineCredentialStamps.set(key, stamp)
747
+ if (previous && previous !== stamp) {
748
+ await conversationEngines?.invalidateBot(profile.id, botId, 'credential-changed')
749
+ throw Object.assign(new Error('CONVERSATION_ENGINE_CREDENTIAL_CHANGED'), { code: 'CONVERSATION_ENGINE_CREDENTIAL_CHANGED' })
750
+ }
751
+ return value
752
+ }
753
+ conversationEngines = await mountConversationEngines({ llm: ctx.llm, profiles: engineProfiles,
754
+ runtimeRoot: join(resolveStateHome(), 'model-router', 'conversation-engines'),
755
+ onActivity: (_profile, event) => nativeActivity?.observe(event),
756
+ // Discovery/preparation may run before a Bot frame exists. The route
757
+ // class is subscription-only; identityFor rechecks the Bot's actual
758
+ // committed funding at dispatch, and the one accounting admits it.
759
+ fundingFor: async (profile, route) => {
760
+ if (policy.activeIdentity()?.independent) { const { bot } = engineContext(profile, route); await checkEngineCredential(profile, bot.id) }
761
+ return 'subscription'
762
+ },
763
+ identityFor: async (profile, route) => {
764
+ const { bot, subject, independent, session, fundingRevision } = engineContext(profile, route)
765
+ await checkEngineCredential(profile, bot.id)
766
+ const savedPersona = independentSystemMessage(bot).content.map(part => part.text).join('')
767
+ const persona = independent ? savedPersona : hostSessionPersona(session, savedPersona)
768
+ const conversationId = independent ? policy.activeIdentity()?.conversationId ?? ctx.get?.('botRuntimeAdapter')?.contextOf?.(bot.id)?.contextId ?? null
769
+ : `host-${createHash('sha256').update(session.id).digest('hex')}`
770
+ return { botId: bot.id, subject, authority, fundingRevision, independent, ...(conversationId ? { conversationId } : {}),
771
+ ...(session ? { hostSessionId: session.id } : {}),
772
+ // The native process lease has Host-owned local correlation. Individual
773
+ // MCP calls obtain the paired target's own scoped capability lease.
774
+ binding: { taskId: `chat-${createHash('sha256').update(bot.id).digest('hex').slice(0, 24)}`,
775
+ attemptId: engineLeaseId, machineId, workspaceId, location: 'local' },
776
+ persona: product.rt.redactSecrets?.(persona) ?? persona }
777
+ },
778
+ toolsFor: async (profile, identity) => {
779
+ const route = { provider: profile.provider, model: profile.model, sessionId: identity.hostSessionId }
780
+ const validate = async () => {
781
+ const current = engineContext(profile, route, identity)
782
+ if (current.subject !== identity.subject || current.bot.id !== identity.botId) throw Object.assign(new Error('SUBJECT_MISMATCH'), { code: 'SUBJECT_MISMATCH' })
783
+ if (current.fundingRevision !== identity.fundingRevision) throw Object.assign(new Error('MODEL_FUNDING_CHANGED'), { code: 'MODEL_FUNDING_CHANGED' })
784
+ await checkEngineCredential(profile, current.bot.id)
785
+ if (engineContext(profile, route, identity).fundingRevision !== identity.fundingRevision) throw Object.assign(new Error('MODEL_FUNDING_CHANGED'), { code: 'MODEL_FUNDING_CHANGED' })
786
+ }
787
+ await validate()
788
+ // Discovery must remain available to a consult turn. The execution
789
+ // boundary enforces its read-only task after ordinary Host admission.
790
+ const actValidate = async () => {
791
+ await validate()
792
+ teamCoordination?.assertCanAct(identity.botId, { sessionId: identity.hostSessionId, conversationId: identity.conversationId })
793
+ }
794
+ const view = ctx.get('botIndependentToolHost').forBot(identity.botId, { nativeRoute: route })
795
+ const ordinary = createNativeConversationTools({ view, validate: actValidate, redact: product.rt.redactSecrets })
796
+ const remote = createNativeConversationTools({
797
+ view: nativeRemoteActions.forConversation(identity.botId, identity.conversationId), validate: actValidate,
798
+ entryFor: nativeRemoteToolEntry, render: renderNativeRemoteResult, redact: product.rt.redactSecrets,
799
+ })
800
+ const data = createNativeDataTools({ access: dataAccess, subject: identity.subject,
801
+ botId: identity.botId, validate: actValidate, redact: product.rt.redactSecrets })
802
+ const team = teamCoordination?.canUse(identity.botId) ? createNativeTeamTools({ service: teamCoordination,
803
+ callerBotId: identity.botId, sessionId: identity.hostSessionId, conversationId: identity.conversationId,
804
+ validate: actValidate, redact: product.rt.redactSecrets }) : { tools: [], handlers: [] }
805
+ return { tools: [...ordinary.tools, ...remote.tools, ...data.tools, ...team.tools], handlers: [...ordinary.handlers, ...remote.handlers, ...data.handlers, ...team.handlers] }
806
+ },
807
+ requestFor: (profile, request, identity) => {
808
+ const current = engineContext(profile, request)
809
+ if (current.independent) return request
810
+ // The SDK may project tool-update messages away before it invokes
811
+ // the adapter. Use the original loop request admitted by this Host.
812
+ const admitted = nativeHostRequests.get(request.signal)
813
+ nativeHostRequests.delete(request.signal)
814
+ const original = admitted?.request
815
+ if (!original || original.sessionId !== request.sessionId || original.provider !== request.provider || original.model !== request.model) {
816
+ throw Object.assign(new Error('CLAUDE_HOST_LOOP_REQUEST_REQUIRED'), { code: 'CLAUDE_HOST_LOOP_REQUEST_REQUIRED' })
817
+ }
818
+ if (admitted.revision !== current.fundingRevision || identity.fundingRevision !== current.fundingRevision) {
819
+ throw Object.assign(new Error('MODEL_FUNDING_CHANGED'), { code: 'MODEL_FUNDING_CHANGED' })
820
+ }
821
+ return projectNativeRequest(original, current)
822
+ },
823
+ resolveSecret: async (_secretRef, profile, identity) => {
824
+ const verify = () => {
825
+ const current = engineContext(profile, { provider: profile.provider, model: profile.model, sessionId: identity?.hostSessionId })
826
+ if (current.subject !== identity?.subject) throw Object.assign(new Error('SUBJECT_MISMATCH'), { code: 'SUBJECT_MISMATCH' })
827
+ if (current.fundingRevision !== identity.fundingRevision) throw Object.assign(new Error('MODEL_FUNDING_CHANGED'), { code: 'MODEL_FUNDING_CHANGED' })
828
+ return current.bot.id
829
+ }
830
+ const value = await checkEngineCredential(profile, verify())
831
+ verify()
832
+ return value
833
+ } })
834
+ ctx.provide?.('botConversationEngines', Object.freeze({ catalog: conversationEngines.catalog }))
835
+ ctx.provide?.('botIndependentConversationRetirement', async (botId, { signal, reason = 'new-conversation' } = {}) => {
836
+ signal?.throwIfAborted()
837
+ if (!product?.lifecycle.committedBot(botId)) throw Object.assign(new Error('BOT_NOT_COMMITTED'), { code: 'BOT_NOT_COMMITTED' })
838
+ product.serving.assertMode(botId, 'independent')
839
+ teamCoordination?.stop(botId)
840
+ return conversationEngines.retireBot(botId, reason)
841
+ })
842
+ await Promise.race([refresh(), new Promise(resolve => { const pending = setTimeout(resolve, INITIAL_ROUTE_TIMEOUT_MS); pending.unref() })])
843
+ if(deferredRouterState){
844
+ sharedAdmission=await acquireMigrationShared(resolveStateHome())
845
+ assertMigrationStartupAllowed({home:resolveStateHome()})
846
+ funding=await openProductFunding({Database,file:storageFile,legacyFile,policy,authority,pricingFor:bindingFunding,now})
847
+ }
848
+
849
+ // The independent Bot runtime may call a remote tool, but only through this: the Bot's own
850
+ // subject, its first live machine binding, and a fresh task/attempt per call. Nothing here
851
+ // accepts a machine, root, endpoint or operation id from the caller's arguments.
852
+ ctx.provide?.('botRemoteToolInvoker', Object.freeze({
853
+ async invoke(botId, tool, args = {}, { signal } = {}) {
854
+ signal?.throwIfAborted()
855
+ const subject = directory.forBot(botId)
856
+ if (!subject) throw new Error('UNKNOWN_BOT')
857
+ const model = machines.readModel()
858
+ const candidates = (model?.items ?? []).filter(item => item.status !== 'revoked'
859
+ && Array.isArray(item.bindings) && item.bindings.some(binding => binding.botId === botId && !binding.revoked))
860
+ // A caller may name one machine; it must be one of this Bot's own live bindings.
861
+ const wanted = typeof args?.machineId === 'string' ? args.machineId : null
862
+ const machine = wanted ? candidates.find(item => item.id === wanted) : candidates[0]
863
+ if (!machine) throw new Error('MACHINE_NOT_BOUND')
864
+ const binding = machine.bindings.find(item => item.botId === botId && !item.revoked)
865
+ const rootId = (binding.roots ?? [])[0]
866
+ if (typeof rootId !== 'string' || !rootId) throw new Error('MACHINE_ROOT_NOT_BOUND')
867
+ if (Array.isArray(binding.tools) && !binding.tools.includes(tool)) throw new Error('MACHINE_BOT_DENIED')
868
+ const calls = { ...args }
869
+ delete calls.machineId
870
+ const stamp = String(Date.now()) + Math.random().toString(16).slice(2, 8)
871
+ return machines.call(subject, {
872
+ machineId: machine.id, rootId, taskId: 'bot-' + stamp, attemptId: 'bot-a-' + stamp, tool, args: calls,
873
+ }, { signal })
874
+ },
875
+ }))
876
+ routingSettings = openBotRoutingSettings({ Database, file: storageFile, initial: () => config.botRouting, now })
877
+ uiNamespace = openUiHostNamespace({ Database, file: storageFile })
878
+ nativeActivity = createNativeActivityService({ Database, file: storageFile, namespace: uiNamespace.read().hostId,
879
+ committedBot: botId => product?.lifecycle.committedBot(botId) ?? null,
880
+ nativeRouteForBot: botId => engineProfiles.some(profile => Boolean(bindingRoute({ botId, provider: profile.provider, model: profile.model }))),
881
+ redactToolName: value => product?.rt.redactSecrets?.(value) ?? value, now })
882
+ ctx.provide('botNativeActivity', nativeActivity)
883
+ const webServer=(()=>{try{return ctx.get?.('webServer')}catch{return null}})()
884
+ if(webServer?.registerUpgrade&&ctx.connection?.admitConnection)desktopProxy=createDesktopProxy({webServer,connection:ctx.connection,now})
885
+ desktopHttp=createDesktopHttpCarrier({now})
886
+ machines = openRemoteMachines({ Database, file:storageFile, authority, desktopProxy, desktopHttp, cliProfiles:cliConfig?.remote??[],
887
+ botExists:id=>Boolean(product?.lifecycle.committedBot(id)), mainBotIds: () => product?.lifecycle.committedMainBotIds() ?? [], now,
888
+ resolveSecret:async secretRef=>{
889
+ if(!product)throw new Error('MACHINE_SECRET_UNAVAILABLE')
890
+ await product.rt.loadSecrets()
891
+ const name=secretRef.slice('ds-secret/'.length)
892
+ const view=product.rt.secretsView().find(secret=>secret.name===name)
893
+ // A machine control-plane key must never be offered to Bot shells.
894
+ if(!view||!Array.isArray(view.scope)||view.scope.length)throw new Error('MACHINE_SECRET_MUST_BE_HOST_ONLY')
895
+ return product.rt.secretValue(name)
896
+ },
897
+ onChanged:async()=>{if(product)await product.lifecycle.refreshGrants()},
898
+ })
899
+ nativeRemoteActions = openNativeRemoteActions({ Database, file: storageFile, directory, authority, machines: () => machines, now })
900
+ ctx.provide('botNativeRemoteActions', nativeRemoteActions)
901
+ // N06b: the components own grants and admission. A published compiled grant
902
+ // takes effect immediately and a suspension revokes it; an account change
903
+ // re-evaluates every binding because the scope catalog follows it.
904
+ if (deferredRouterState) {
905
+ fundingAccounts = openFundingAccounts({ Database, file: storageFile,
906
+ providers: () => [...new Set([...routes].map(key => key.slice(0, key.indexOf('/'))))],
907
+ onCommitted: () => { try { botSettings?.refresh() } catch { /* the interval retries */ } },
908
+ onSuspended: () => { try { botSettings?.refresh() } catch { /* the interval retries */ } }, now })
909
+ botSettings = openBotSettings({ Database, file: storageFile,
910
+ catalogFor: settingsCatalog,
911
+ botExists: id => committedBots.has(id),
912
+ isMainBot: id => product?.lifecycle.committedMainBotIds().includes(id) === true,
913
+ onCommitted: value => {
914
+ if (!product?.lifecycle.committedMainBotIds().includes(value.botId)) {
915
+ try { directory.replaceGrant(value.botId, value.grant) } catch { /* the lifecycle recompiles */ }
916
+ }
917
+ grantRefresh.request()
918
+ },
919
+ onSuspended: value => {
920
+ if (!product?.lifecycle.committedMainBotIds().includes(value.botId)) {
921
+ try { directory.replaceGrant(value.botId, { tools: [], modelRoutes: [] }) } catch { /* the lifecycle recompiles */ }
922
+ }
923
+ grantRefresh.request()
924
+ }, now })
925
+ const modelBounds = createModelBoundResolver({ llm: () => ctx.llm })
926
+ modelAccounting = createModelAccounting({ store: funding.store, authority,
927
+ pricingFor: bindingFunding,
928
+ fundingRevisionFor: query => bindingFunding(query)?.fundingRevision ?? null,
929
+ preparedFrameFor: async request => {
930
+ const captured = modelFrames.read(request.signal)
931
+ if (captured) return captured
932
+ const verdict = sessionLoopFrame(request)
933
+ if (verdict.kind === 'frame') {
934
+ const stamp = loopFunding.get(request.signal)
935
+ return stamp?.sessionId === request.sessionId && stamp.provider === request.provider && stamp.model === request.model
936
+ ? { ...verdict.frame, fundingRevision: stamp.revision }
937
+ : verdict.frame
938
+ }
939
+ if (verdict.kind === 'aux') return null
940
+ if (verdict.kind === 'unsupportedHost') {
941
+ const identity = policy.modelIdentity(request)
942
+ const funding = await bindingFunding({ botId: identity.botId, provider: request.provider, model: request.model })
943
+ if (funding?.funding === 'subscription') return null
944
+ }
945
+ throw Object.assign(new Error('本次调用无法确认已绑定的模型容量,请核对宿主会话记录。'), {
946
+ code: verdict.kind === 'missing' ? 'MODEL_LOOP_FRAME_MISSING' : verdict.kind === 'unsupportedHost' ? 'MODEL_LOOP_FRAME_UNSUPPORTED' : 'MODEL_LOOP_FRAME_INVALID',
947
+ reason: verdict.reason, outcome: 'refused' })
948
+ },
949
+ modelBoundsFor: async ({ options, signal }) => {
950
+ const bounds = await modelBounds.bounds({ provider: options.provider, model: options.model, signal })
951
+ return { inputBound: bounds.contextWindow, outputDefault: bounds.defaultMaxTokens }
952
+ },
953
+ machineId, workspaceId, now,
954
+ // The scoped admission (Bot/account/project budgets) is authoritative;
955
+ // the ledger limit stays open so no hidden global cap shadows it.
956
+ limits: { dailyLimitUsd: 0, monthlyLimitUsd: 0 } })
957
+ // N09: one managed Task runner over the same SQLite authority. The
958
+ // journal, funding store and component tables share orchestration.sqlite;
959
+ // backends stay stable per kind while each Bot's binding supplies the
960
+ // execution target, model and money at run time.
961
+ if (typeof mr?.executeConfiguredAssignment !== 'function' || typeof mr?.rerunRecordedStep !== 'function'
962
+ || typeof mr?.routerStateStore !== 'function') throw new TypeError('BOT_PRODUCT_MR_SEAM_REQUIRED')
963
+ const managedRoot = join(dirname(storageFile), 'managed')
964
+ const managedWorkspace = join(managedRoot, 'read-workspace')
965
+ await mkdir(managedWorkspace, { recursive: true })
966
+ if (cliConfig?.entry&&cliConfig.sessionFactory) {
967
+ const factory = await import(cliConfig.sessionFactory).then(module => module.createCliSession ?? module.default, () => null)
968
+ if (typeof factory !== 'function') throw new TypeError('TASK_CLI_SESSION_FACTORY_REQUIRED')
969
+ cliSessionFactory = factory
970
+ }
971
+ journal = funding.store.openTaskJournal({leaseClock:'db'})
972
+ notifications = createBotNotifications({ Database, file: join(dirname(storageFile), 'notifications.sqlite'), journal,
973
+ botIds: () => product?.lifecycle.committedBots().map(bot => bot.id) ?? [],
974
+ botNames: () => Object.fromEntries((product?.lifecycle.committedBots() ?? []).map(bot => [bot.id, bot.name ?? bot.id])) })
975
+ const modelBackends = createTaskBackends({ ctx, mr, authority, directory, store: funding.store,
976
+ machineId, workspaceId, workspace: managedWorkspace, runtimeRoot: join(managedRoot, 'cli-runtime'),
977
+ bindingFor: botId => botSettings.get(botId, { publishChange: false }),
978
+ cli: cliConfig?.entry ? { entry: cliConfig.entry, sessionFactory: cliSessionFactory ?? (await import('../../cli-runtime/src/codex-runtime.mjs')).createCodexSession } : null })
979
+ const remoteWrite=createRemoteWriteBackend({machines:()=>machines,directory})
980
+ const remoteCommand=createRemoteCommandBackend({machines:()=>machines,directory})
981
+ turnBackends = createBotTurnBackends({ ctx, runtime: () => product?.rt, directory, authority, Database, storeFile: storageFile,
982
+ bindingFor: botId => botSettings.get(botId, { publishChange: false }), machines: () => machines,
983
+ readPlan: report => taskService.getPlan(directory.forBot(report.originBotId), { planId: report.planId }), now })
984
+ if(cliConfig?.remote.length)remoteCliBackend=createRemoteCliReadBackend({machines:()=>machines,directory,store:funding.store,authority,profiles:cliConfig.remote,bindingFor:botId=>botSettings.get(botId,{publishChange:false})})
985
+ const remoteBackends=[remoteWrite,remoteCommand,...(remoteCliBackend?[remoteCliBackend]:[])]
986
+ taskBackends=Object.freeze({backends:Object.freeze([...modelBackends.backends,...remoteBackends]),catalog:()=>[...modelBackends.catalog(),...remoteBackends.map(backend=>({id:backend.id,version:backend.version,effect:backend.effect,location:'remote',capability:backend.capability,label:backend.id==='remote-codex-read'?'已授权远程官方 CLI 只读任务':backend.id==='remote-write'?'已授权远程文件写入任务':'已授权容器命令任务(仅别名argv)'}))]})
987
+ taskService = createTaskService({ journal, authority, directory, backends: [...turnBackends.backends, ...taskBackends.backends],
988
+ within: (subject, signal, work) => policy.executionContext(subject, signal, work),
989
+ planTargetsFor: (subject,plan,caller) => {
990
+ const id=authority.describe(subject).botId
991
+ const bot=product?.lifecycle.committedBot(id)
992
+ const privateSession=product?.rt.chatOf(id)??bot?.sessionId??bot?.id
993
+ const privateCall=caller.owner===true||bot&&caller.sessionId===privateSession
994
+ if(caller.sessionId!==undefined&&plan.origin.kind==='private'&&!privateCall)throw Object.assign(new Error('TASK_PLAN_ORIGIN_MISMATCH'),{code:'TASK_PLAN_ORIGIN_MISMATCH'})
995
+ if(caller.owner===true&&plan.origin.kind==='private'&&plan.origin.sessionId!==null&&plan.origin.sessionId!==privateSession)throw Object.assign(new Error('TASK_PLAN_ORIGIN_MISMATCH'),{code:'TASK_PLAN_ORIGIN_MISMATCH'})
996
+ return privateCall&&['private','handoff'].includes(plan.origin.kind)&&product?.lifecycle.committedMainBotIds().includes(id)?product.lifecycle.committedBots().map(bot=>bot.id):[id]
997
+ },
998
+ runnerTtlMs: TASK_RUNNER_TTL_MS, taskTtlMs: TASK_TTL_MS,
999
+ // Lease expiry reads the ledger's clock, not this process's: a Host
1000
+ // whose wall clock drifted must not be able to declare its own live
1001
+ // lease expired and let a second runner take the same tasks.
1002
+ leaseClock: 'db',
1003
+ // The deployment refuses a writer that cannot prove a stop, so a new
1004
+ // backend cannot be added without also teaching it how to say so.
1005
+ requireStopProof: true,
1006
+ // The committed binding's maxConcurrentTasks is the per-Bot limit; an
1007
+ // unconfigured Bot keeps the conservative single-task default.
1008
+ concurrencyFor: botId => {
1009
+ try {
1010
+ const value = botSettings?.get(botId, { publishChange: false })
1011
+ return value?.status === 'available' ? value.settings.maxConcurrentTasks ?? 1 : 1
1012
+ } catch { return 1 }
1013
+ } })
1014
+ // A previous Host that died without releasing its runner lease blocks the
1015
+ // claim until the lease expires; waiting is safer than dropping tasks.
1016
+ const claimDeadline = now() + TASK_RUNNER_CLAIM_MS
1017
+ for (;;) {
1018
+ try { taskService.start(); break } catch (error) {
1019
+ if (error?.code !== 'TASK_RUNNER_BUSY' || now() >= claimDeadline) throw error
1020
+ await new Promise(resolve => { const pending = setTimeout(resolve, 250); pending.unref() })
1021
+ }
1022
+ }
1023
+ // A runner that stops (a lease lost to a clock jump, a journal write that
1024
+ // never settled) must come back on its own, or the Host keeps accepting
1025
+ // work nobody will claim. The watchdog reports what it did instead of
1026
+ // hiding a permanent stop behind a quiet 5-second tick.
1027
+ taskRunnerWatchdog = setInterval(() => {
1028
+ try {
1029
+ const state = taskService.state()
1030
+ if (state.closed || state.running) return
1031
+ const outcome = taskService.restart({ reason: state.stopReason ?? 'TASK_RUNNER_WATCHDOG' })
1032
+ if (!outcome.accepted) ctx.logger?.('task-runner-restart-deferred', { code: outcome.code, reason: state.stopReason })
1033
+ } catch (error) { ctx.logger?.('task-runner-watchdog-failed', { code: error?.code ?? 'TASK_RUNNER_WATCHDOG_FAILED' }) }
1034
+ }, TASK_RUNNER_WATCHDOG_MS)
1035
+ taskRunnerWatchdog.unref?.()
1036
+ releaseTaskTools = registerTaskTools(ctx, defineTool, { service: taskService, directory })
1037
+ }
1038
+ const machineActions=Object.fromEntries(['pair','bind','probe','revoke','reconcile'].map(name=>[`machine.${name}`,(payload,context)=>machines[name](payload,context)]))
1039
+ machineActions['machine.pair'] = (payload, context) => pairMainMachine({ machines,
1040
+ mainBotId: () => product?.lifecycle.committedMainBotIds()[0] }, payload, context)
1041
+ // Pinned web panels: the owner's own short list of pages, in its own component tables
1042
+ // beside the other authorities in the same SQLite file. A Bot has no path to it —
1043
+ // nothing here is registered as a model tool, and the probe reads a stored pin.
1044
+ webPanelStore = openWebPanelStore({ Database, file: storageFile })
1045
+ webPanelActions = createWebPanelActions({ store: webPanelStore })
1046
+ securePairing = openSecurePairingHost({ Database, file: storageFile, machines, now,
1047
+ botExists: id => typeof id === 'string' && Boolean(product && Object.hasOwn(product.rt.state?.bots ?? {},id)),
1048
+ saveSecret: (name,value) => product.rt.secretEndpoint('secret-set',{name,value,scope:[],purpose:'远程电脑端到端连接凭据,仅供宿主使用。'}),
1049
+ resolveSecret: async reference => {
1050
+ await product.rt.loadSecrets()
1051
+ const name=reference.slice('ds-secret/'.length),view=product.rt.secretsView().find(item=>item.name===name)
1052
+ if(!view)return undefined
1053
+ if(!Array.isArray(view.scope)||view.scope.length)throw new Error('MACHINE_SECRET_MUST_BE_HOST_ONLY')
1054
+ return product.rt.secretValue(name)
1055
+ },
1056
+ pair: (payload,context) => pairMainMachine({ machines, mainBotId: () => product?.lifecycle.committedMainBotIds()[0] },payload,context),
1057
+ })
1058
+ Object.assign(machineActions, { 'machine.pair.invite': (payload,context) => {
1059
+ if(context?.kind!=='owner')throw new Error('REMOTE_OWNER_REQUIRED')
1060
+ return securePairing.redeem(payload,context)
1061
+ } })
1062
+ machineActions['machine.artifact']=(payload,context)=>machines.download(taskOwner(payload),payload,context)
1063
+ // Desktop control: a person watches one frame and acts on it. Both calls run as that
1064
+ // person's Bot, so the Bot grant and the machine ceiling decide, and the operations land
1065
+ // under that Bot instead of an anonymous console.
1066
+ const desktopActions = createDesktopActions({ machines, directory })
1067
+ Object.assign(machineActions, {
1068
+ 'machine.desktop.poll': (payload,context) => {
1069
+ if(context?.kind!=='owner')throw new Error('REMOTE_OWNER_REQUIRED')
1070
+ if(!payload||typeof payload.botId!=='string')throw new Error('INVALID_DESKTOP_REQUEST')
1071
+ const {botId,...request}=payload;taskOwner({botId})
1072
+ return desktopHttp.poll(request,{...context,botId})
1073
+ },
1074
+ 'machine.desktop.send': (payload,context) => {
1075
+ if(context?.kind!=='owner')throw new Error('REMOTE_OWNER_REQUIRED')
1076
+ if(!payload||typeof payload.botId!=='string')throw new Error('INVALID_DESKTOP_REQUEST')
1077
+ const {botId,...request}=payload;taskOwner({botId})
1078
+ return desktopHttp.send(request,{...context,botId})
1079
+ },
1080
+ })
1081
+ for (const [action, method] of [['open', 'desktopSession'], ['renew', 'renewDesktopSession'], ['close', 'closeDesktopSession']]) {
1082
+ machineActions[`machine.desktop.${action}`] = (payload, context) => {
1083
+ if (context?.kind !== 'owner') throw new Error('REMOTE_OWNER_REQUIRED')
1084
+ if (!payload || typeof payload.botId !== 'string') throw new Error('INVALID_DESKTOP_REQUEST')
1085
+ const { botId, ...request } = payload
1086
+ return machines[method](taskOwner({ botId }), request, context)
1087
+ }
1088
+ }
1089
+ machineActions['machine.desktop.view'] = (payload, context) => {
1090
+ if (context?.kind !== 'owner') throw new Error('REMOTE_OWNER_REQUIRED')
1091
+ return desktopActions.view(payload)
1092
+ }
1093
+ machineActions['machine.desktop.act'] = (payload, context) => {
1094
+ if (context?.kind !== 'owner') throw new Error('REMOTE_OWNER_REQUIRED')
1095
+ return desktopActions.act(payload)
1096
+ }
1097
+ machineActions['machine.secret']=async(payload,context)=>{
1098
+ if(context?.kind!=='owner')throw new Error('REMOTE_OWNER_REQUIRED')
1099
+ if(!payload||Object.keys(payload).some(key=>!['name','value'].includes(key))||!/^REMOTE_AGENT_[A-Z0-9_]{1,50}$/.test(payload.name)||typeof payload.value!=='string'||!/^[A-Za-z0-9_-]{43}$/.test(payload.value))throw new Error('INVALID_MACHINE_SECRET')
1100
+ const secretRef=`ds-secret/${payload.name}`
1101
+ for(const machine of machines.readModel().items.filter(item=>item.status!=='revoked'&&item.secretRef===secretRef))await machines.revoke({machineId:machine.id,expectedRevision:machine.revision},context)
1102
+ await product.rt.secretEndpoint('secret-set',{name:payload.name,value:payload.value,scope:[],purpose:'远程电脑控制面凭据,仅供宿主使用;不得授予 Bot shell。'})
1103
+ return {secretRef,stored:true}
1104
+ }
1105
+ const botLibrary = (() => { try { return ctx.get?.('botLibrary') ?? null } catch { return null } })()
1106
+ dataEngine = createDataEngine({ dataDir: join(dirname(storageFile), 'data'),
1107
+ ...(botLibrary ? { libraryReader: (botId, relativePath) => botLibrary.read(botId, relativePath),
1108
+ libraryWriter: (botId, relativePath, content) => botLibrary.write(botId, relativePath, content) } : {}) })
1109
+ dataAccess = createProductDataAccess({ engine: dataEngine, authority, directory,
1110
+ lifecycle: { committedBot: botId => product?.lifecycle.committedBot(botId) } })
1111
+ const dataAction = (name, payload) => dataAccess.ownerAction(name, payload)
1112
+ const botRuntimeAdapter = (() => { try { return ctx.get?.('botRuntimeAdapter') ?? null } catch { return null } })()
1113
+ const routineStore = (() => { try { return ctx.get?.('routineStore') ?? null } catch { return null } })()
1114
+ if (routineStore) routineExecution = createRoutineExecutionService({ machines, store: routineStore, botIdForSubject: describeBot })
1115
+ product = createBotProduct(ctx, { authority, directory, policy, grantFor, threadHost: () => botThreads,
1116
+ modelFrames, fundingFor: bindingFunding,
1117
+ independentReadyFor: (botId, route) => Boolean(bindingRoute({ botId, ...route })),
1118
+ prepareLoopFunding: ({ botId, sessionId, signal, config, previousConfig }) => {
1119
+ if (!signal || !product.lifecycle.committedMainBotIds().includes(botId)) return config
1120
+ const { provider, model } = config
1121
+ const quote = bindingFunding({ botId, provider, model })
1122
+ loopFunding.set(signal, { sessionId, provider, model, revision: quote?.fundingRevision ?? null })
1123
+ if (!quote?.fundingRevision) return config
1124
+ const switched = previousConfig && (previousConfig.provider !== config.provider || previousConfig.model !== config.model)
1125
+ // AgentLoop seeds the new proposal from the last request header. Its
1126
+ // previous route's output cap must not follow a deliberate switch.
1127
+ if (switched && previousConfig.maxTokens === config.maxTokens && config.maxTokens !== undefined) {
1128
+ const { maxTokens: _previousCap, ...selected } = config
1129
+ config = selected
1130
+ }
1131
+ if (quote.funding !== 'api') return config
1132
+ if (config.maxTokens !== undefined && (!Number.isSafeInteger(config.maxTokens) || config.maxTokens < 1)) return config
1133
+ const maxTokens = config.maxTokens === undefined ? quote.maxOutputTokens : Math.min(config.maxTokens, quote.maxOutputTokens)
1134
+ return { ...config, maxTokens }
1135
+ },
1136
+ isTextOnlyModel: route => engineProfiles.some(profile => profile.provider === route.provider && profile.model === route.model),
1137
+ beforeModelRequest: async request => {
1138
+ const profile = engineProfiles.find(item => item.provider === request.provider && item.model === request.model)
1139
+ if (!profile) return
1140
+ const current = engineContext(profile, request)
1141
+ projectNativeRequest(request, current)
1142
+ await checkEngineCredential(profile, current.bot.id)
1143
+ if (!current.independent && request.signal) nativeHostRequests.set(request.signal, { request, revision: current.fundingRevision })
1144
+ },
1145
+ accountStream: (streamOptions, next, meta) => {
1146
+ turnBackends?.tagModelRequest(streamOptions)
1147
+ const enriched = meta?.subject ? { ...meta, botId: describeBot(meta.subject) } : meta
1148
+ // N06b: the unified admission owns every Bot model call; the diagnostic
1149
+ // decorator only remains for lightweight contexts without the store.
1150
+ if (!modelAccounting) return accountStream(streamOptions, next, enriched)
1151
+ // A direct MR call inside an admitted execution frame references that
1152
+ // run's invoice instead of opening a second Bot-side one.
1153
+ funding?.funded?.bindModelRequest?.(streamOptions, { agentLoop: meta?.agentLoop === true, ownRequest: meta?.ownRequest === true })
1154
+ return modelAccounting.stream(streamOptions, next, { ...enriched, source: RECEIPT_SOURCE[enriched?.source] ?? enriched?.source })
1155
+ },
1156
+ onBind, runtimeConfig,
1157
+ readModels: { bindings, machines:()=>machines.readModel(),
1158
+ botBindings: async ({ runtime, signal }) => {
1159
+ signal?.throwIfAborted(); await runtime.load()
1160
+ const catalog = settingsCatalog()
1161
+ return { ...botSettings.readModel(Object.values(runtime.state?.bots ?? {})),
1162
+ machines: catalog.machines.map(m => ({ machineId: m.machineId, label: m.label, location: m.location, roots: m.roots.map(r => ({ rootId: r.rootId, label: r.label, modes: r.modes })) })),
1163
+ fundingScopes: catalog.fundingScopes }
1164
+ },
1165
+ fundingAccounts: async () => ({ status: 'available', items: fundingAccounts.catalog() }),
1166
+ // Pinned web panels read the owner's own directory through the same single
1167
+ // authority as every other projection; a section without it stays blocked.
1168
+ webPanels: async () => webPanelActions.provider(),
1169
+ threads: async ({ runtime }) => {
1170
+ if (!botThreads?.ready) return { status: 'blocked', items: [], reason: '当前宿主未提供完整线程会话能力。', nextAction: '更新插件并重新加载宿主后重试。' }
1171
+ const views = Object.keys(runtime.state.bots).map(botId => ({ botId, ...botThreads.actions['threads.readModel']({ botId }) }))
1172
+ return { status: 'available', items: views.flatMap(view => view.items.map(item => ({ ...item, botId: view.botId }))),
1173
+ bots: views.map(({ botId, status, reason, nextAction, activeThreadId }) => ({ botId, status, reason, nextAction, activeThreadId })) }
1174
+ },
1175
+ // N09: the managed Task projection reads the durable journal through the
1176
+ // same single authority; a section without a runner stays blocked.
1177
+ ...(taskService ? { tasks: async ({ runtime, signal }) => {
1178
+ signal?.throwIfAborted(); await runtime.load()
1179
+ const loaded = journal.productList(Object.values(runtime.state?.bots ?? {}).map(bot => bot.id), { limit: TASK_READ_LIMIT })
1180
+ const items = loaded.items.map(projectProductTask)
1181
+ const plans=Object.values(runtime.state?.bots??{}).flatMap(bot=>journal.listPlans(bot.id))
1182
+ return { status: 'available', items, total: loaded.total, truncated: loaded.truncated, plans, backends: taskBackends.catalog() }
1183
+ } } : {}),
1184
+ // N07: the usage projection reads the durable source receipts and the
1185
+ // funding totals from the single authority; an unknown amount stays
1186
+ // null instead of being rendered as zero.
1187
+ ...(funding ? { usage: async ({ runtime, signal }) => {
1188
+ signal?.throwIfAborted(); await runtime.load()
1189
+ const bots = Object.values(runtime.state?.bots ?? {})
1190
+ const names = new Map(bots.map(bot => [bot.id, bot.name]))
1191
+ // An owner resolution lives on the run's package, so the receipts and
1192
+ // the spend total agree after a waive or an attested amount.
1193
+ const ledger = await funding.store.read()
1194
+ const resolutions = new Map()
1195
+ const unresolved = []
1196
+ for (const run of ledger.runs) {
1197
+ const botId = [run.modelCall, run.cliExecution, run.fundingExecution].filter(Boolean).map(value => value.botId).find(Boolean) ?? null
1198
+ for (const item of [...(run.packages ?? []), ...(run.reviews ?? [])]) {
1199
+ if (!item || item.ran !== true) continue
1200
+ const key = `${run.id}/${item.id}`
1201
+ if (item.ownerResolution) { resolutions.set(key, item.ownerResolution); continue }
1202
+ if (!botId || typeof item.costUsd === 'number' && Number.isFinite(item.costUsd)) continue
1203
+ unresolved.push({ botId, runId: run.id, packageId: item.id, provider: item.provider ?? null,
1204
+ model: item.model ?? null, billing: item.billing ?? null, usage: item.usage ?? null,
1205
+ finishedAt: item.finishedAt ?? null })
1206
+ }
1207
+ }
1208
+ const items = []
1209
+ for (const bot of bots) {
1210
+ let after = 0
1211
+ for (;;) {
1212
+ const page = await funding.store.modelCalls({ botId: bot.id, after, limit: 100 })
1213
+ for (const receipt of page.items) {
1214
+ const resolution = resolutions.get(`${receipt.runId}/${receipt.packageId}`) ?? null
1215
+ items.push({ id: receipt.id, botId: receipt.botId, botName: names.get(receipt.botId) ?? null,
1216
+ sessionId: receipt.sessionId, taskId: receipt.taskId, runId: receipt.runId,
1217
+ source: receipt.source, provider: receipt.provider, model: receipt.model, billing: receipt.funding,
1218
+ amountUsd: projectModelCallAmount(receipt, resolution), status: receipt.state, sequence: receipt.sequence,
1219
+ createdAt: receipt.createdAt, finishedAt: receipt.finishedAt,
1220
+ resolution: resolution ? { kind: resolution.kind, costUsd: resolution.costUsd, note: resolution.note, resolvedAt: resolution.resolvedAt } : null,
1221
+ inputTokens: receipt.usage?.inputTokens ?? null, outputTokens: receipt.usage?.outputTokens ?? null })
1222
+ }
1223
+ if (!page.next) break
1224
+ after = page.next
1225
+ }
1226
+ }
1227
+ const totals = funding.store.fundingTotals({ at: now() })
1228
+ const reserved = reservationSpending(ledger)
1229
+ const subscription = funding.store.fundingSubscriptionUsage({ at: now() })
1230
+ return { status: 'available', items, unresolved, timezone: totals.timezone, coverage: 'current-bot-source-receipts',
1231
+ summary: { apiUsd: totals.today, reservedUsd: reserved.reservedUsd, unknownCount: totals.unknownToday + totals.unknownMonth,
1232
+ subscriptionSummary: { callsToday: subscription.today.calls, callsMonth: subscription.month.calls, usdToday: totals.subscriptionToday, usdMonth: totals.subscriptionMonth } } }
1233
+ } } : {}) },
1234
+ actions: { ...actions, ...machineActions,
1235
+ 'ui.namespace': ownerOnly(payload => uiNamespace.read(payload)),
1236
+ 'nativeActivity.list': ownerOnly((payload, context) => {
1237
+ const value = nativeActivity.list(payload, context)
1238
+ const failures = conversationEngines?.activityHealth?.().failures ?? 0
1239
+ if (failures) return { ...value, recording: { status: 'degraded', failures: Math.max(failures, value.recording?.failures ?? 0), code: 'NATIVE_ACTIVITY_OBSERVER_FAILED' } }
1240
+ return value
1241
+ }),
1242
+ 'botProfile.inspect': ownerOnly((payload, context) => inspectProductBotProfile({ runtime: product.rt, lifecycle: product.lifecycle, signal: context?.signal }, payload)),
1243
+ 'botProfile.save': ownerOnly((payload, context) => saveProductBotProfile({ runtime: product.rt, lifecycle: product.lifecycle, signal: context?.signal }, payload, context)),
1244
+ 'independentChat.send': ownerOnly((payload, context) => independentChatAction('send', payload, context)),
1245
+ 'independentChat.inspect': ownerOnly((payload, context) => independentChatAction('inspect', payload, context)),
1246
+ 'independentChat.cancel': ownerOnly((payload, context) => independentChatAction('cancel', payload, context)),
1247
+ 'independentChat.view': ownerOnly((payload, context) => independentChatAction('view', payload, context)),
1248
+ 'independentChat.messages': ownerOnly((payload, context) => independentChatAction('messages', payload, context)),
1249
+ 'independentChat.newConversation': ownerOnly((payload, context) => independentChatAction('newConversation', payload, context)),
1250
+ // The library panel reads and writes one Bot's files. The directory is chosen by the
1251
+ // library itself (the caller's Bot, or the operator's choice), never by the panel.
1252
+ // Saved routines: list what a Bot has, and run one on a machine the caller may use.
1253
+ // The role catalog is a read-only convenience: it describes jobs, it grants nothing.
1254
+ 'roles.list': async () => listRoles(),
1255
+ ...(routineStore ? {
1256
+ 'routine.list': async payload => routineStore.inspect(payload?.botId),
1257
+ 'routine.save': async payload => routineStore.save(payload?.botId, payload?.routine),
1258
+ 'routine.remove': async payload => routineStore.remove(payload?.botId, payload?.routineId),
1259
+ 'routine.history': async payload => routineExecution.history(payload?.botId),
1260
+ 'routine.cancel': async payload => routineExecution.cancel(payload?.botId, payload?.runId),
1261
+ 'routine.resolve': async (payload, context) => routineExecution.resolve(payload?.botId, payload?.runId, payload?.note, context),
1262
+ 'routine.run': async (payload, context) => routineExecution.run({ subject: taskOwner(payload),
1263
+ botId: payload?.botId, routineId: payload?.routineId, machineId: payload?.machineId, rootId: payload?.rootId,
1264
+ context, signal: context.signal }),
1265
+ } : {}),
1266
+ ...(botLibrary ? {
1267
+ 'library.list': async payload => botLibrary.list(payload?.botId, payload?.relativePath ?? ''),
1268
+ 'library.read': async payload => ({ content: botLibrary.read(payload?.botId, payload?.relativePath ?? '') }),
1269
+ 'library.search': async payload => ({ results: botLibrary.search(payload?.botId, payload?.query) }),
1270
+ 'library.write': async payload => botLibrary.write(payload?.botId, payload?.relativePath ?? '', payload?.content ?? ''),
1271
+ 'library.upload': async payload => botLibrary.upload(payload?.botId, payload?.relativePath, payload?.contentBase64, payload?.overwrite),
1272
+ 'library.download': async payload => botLibrary.download(payload?.botId, payload?.relativePath),
1273
+ 'library.remove': async payload => botLibrary.remove(payload?.botId, payload?.relativePath),
1274
+ 'library.mkdir': async payload => botLibrary.mkdir(payload?.botId, payload?.relativePath),
1275
+ } : {}),
1276
+ // The Bot runtime card is a panel: its buttons dispatch on this channel, so the same four
1277
+ // operations the owner RPC exposes must exist here as well.
1278
+ ...(botRuntimeAdapter ? {
1279
+ 'botRuntimeStatus': async () => botRuntimeAdapter.list(),
1280
+ 'botRuntimeMove': async payload => botRuntimeAdapter.move(payload?.botId, payload?.mode),
1281
+ 'botRuntimeHistory': async payload => botRuntimeAdapter.history(payload?.botId, { limit: payload?.limit }),
1282
+ 'botRuntimeMessage': async payload => {
1283
+ if (config.independentRuntime?.fullTextHistory !== true) throw new Error('HISTORY_FULLTEXT_DISABLED')
1284
+ return botRuntimeAdapter.readMessage(payload?.botId, payload?.index)
1285
+ },
1286
+ } : {}),
1287
+ // The workbench reads Bot routing through the same product channel as every other panel
1288
+ // action. Registering it here is what makes the routing card work at runtime; the owner
1289
+ // RPC methods are a second door onto the same service.
1290
+ 'botRoutingInspect': async () => ({ ...botRouting.inspect(), settingsRevision: routingSettings.get().revision }),
1291
+ 'botRouting.status': (payload, context) => botRouting.submissionStatus(payload, context),
1292
+ 'botRouting.save': async (payload, context) => {
1293
+ const saved = routingSettings.save(payload, context)
1294
+ return { ...botRouting.inspect(), settingsRevision: saved.revision }
1295
+ },
1296
+ 'botRoutingDecide': async payload => botRouting.decide({
1297
+ text: payload?.text,
1298
+ pinnedBotId: payload?.pinnedBotId ?? '',
1299
+ requiresMachine: payload?.requiresMachine === true,
1300
+ machineId: payload?.machineId ?? '',
1301
+ rootId: payload?.rootId ?? '',
1302
+ needs: payload?.needs ?? {},
1303
+ requiredTools: Array.isArray(payload?.requiredTools) ? payload.requiredTools : [],
1304
+ requesterBotId: typeof payload?.requesterBotId === 'string' ? payload.requesterBotId : '',
1305
+ }),
1306
+ ...(botSettings ? {
1307
+ 'binding.inspect': ownerOnly((payload, context) => inspectProductBinding({ runtime: product.rt, lifecycle: product.lifecycle, botSettings, fundingAccounts, signal: context?.signal }, payload)),
1308
+ 'binding.save': ownerOnly(async (payload, context) => {
1309
+ // A Bot holds its greeting until its model is picked, and the model change releases it
1310
+ // immediately. Saving a binding changes both the model and its authority, so hold the
1311
+ // greeting across that change: otherwise the greeting turn runs before the compiled
1312
+ // grant carries the binding's model route and is refused as MODEL_DENIED.
1313
+ const bot = product.rt.botOf(payload.botId)
1314
+ const held = bot?.kickoff
1315
+ const quiet = product.lifecycle.committedMainBotIds().includes(payload.botId)
1316
+ && (Object.hasOwn(payload.settings ?? {}, 'mainRoutes') || Boolean(botSettings.get(payload.botId, { publishChange: false }).settings.mainRoutes))
1317
+ if (bot && held) delete bot.kickoff
1318
+ let result
1319
+ let modelChanged = false
1320
+ try {
1321
+ result = await botSettings.save(payload, context)
1322
+ const model = result.settings?.model
1323
+ const ref = model ? `${model.provider}/${model.model}` : null
1324
+ modelChanged = Boolean(result.status === 'available' && bot && ref && bot.model !== ref)
1325
+ if (modelChanged) await product.rt.updateBot(payload.botId, { model: ref })
1326
+ await product.lifecycle.refreshGrants()
1327
+ } finally {
1328
+ if (bot && held) bot.kickoff = held
1329
+ }
1330
+ if (bot && held && quiet && modelChanged) await product.rt.save()
1331
+ if (bot && held && !quiet) await product.rt.updateBot(payload.botId, { model: bot.model }).catch(() => {})
1332
+ return result
1333
+ }) } : {}),
1334
+ ...(fundingAccounts ? { 'funding.account.save': (payload, context) => fundingAccounts.save(payload, context) } : {}),
1335
+ // N09: owner task management runs on the owning Bot's own subject, so
1336
+ // the Bot's committed grant authorizes every cancel/retry/reconcile.
1337
+ ...(taskService ? {
1338
+ 'task.attention': ownerOnly(async payload => ({ ...await readProductAttention({ runtime: product.rt, journal, limit: TASK_READ_LIMIT }, payload),
1339
+ ...(notifications ? { notifications: notifications.readModel() } : {}) })),
1340
+ // Task management is the person's own console, not a Bot surface. Every
1341
+ // action here names its caller class instead of inheriting the default,
1342
+ // so a change at the authorization seam cannot quietly hand a non-owner
1343
+ // the ability to cancel, retry or reconcile somebody's work.
1344
+ 'task.inspect': ownerOnly(payload => inspectProductTask({ service: taskService, journal, subject: taskOwner(payload), taskId: payload?.taskId })),
1345
+ // Bot routing turns a preview into a real package: the decision names the Bot, the
1346
+ // worker binding is committed first, and only then is the plan submitted. A failure in
1347
+ // either step leaves no half-dispatched task behind.
1348
+ 'botRouting.dispatch': (payload, context) => botRouting.dispatch(payload, context),
1349
+ 'task.plan.submit': ownerOnly(payload => taskService.submitPlan(taskOwner(payload),{requestKey:payload?.requestKey,plan:payload?.plan},{owner:true})),
1350
+ 'task.plan.cancel': ownerOnly(payload => taskService.cancelPlan(taskOwner(payload),{planId:payload?.planId})),
1351
+ 'task.cancel': ownerOnly(payload => taskService.cancel(taskOwner(payload), { taskId: payload?.taskId })),
1352
+ 'task.retry': ownerOnly(payload => taskService.retry(taskOwner(payload), { taskId: payload?.taskId, requestKey: payload?.requestKey })),
1353
+ 'task.reconcile': ownerOnly(payload => taskService.reconcile(taskOwner(payload), { taskId: payload?.taskId, requestKey: payload?.requestKey })),
1354
+ } : {}),
1355
+ ...(notifications ? {
1356
+ 'notifications.readModel': ownerOnly(() => notifications.readModel()),
1357
+ 'notifications.list': ownerOnly(payload => notifications.list(payload ?? {})),
1358
+ 'notifications.read': ownerOnly(payload => notifications.read(payload?.seq)),
1359
+ 'notifications.markAll': ownerOnly(() => notifications.markAll()),
1360
+ } : {}),
1361
+ // The adapter is constructed after createBotProduct returns. Register
1362
+ // lazy handlers now; checking readiness while building these options
1363
+ // would permanently omit every action from this product instance.
1364
+ 'threads.readModel': ownerOnly(payload => threadAction('threads.readModel', payload)),
1365
+ 'threads.list': ownerOnly(payload => threadAction('threads.list', payload)),
1366
+ 'threads.create': ownerOnly(payload => threadAction('threads.create', payload)),
1367
+ 'threads.rename': ownerOnly(payload => threadAction('threads.rename', payload)),
1368
+ 'threads.switch': ownerOnly(payload => threadAction('threads.switch', payload)),
1369
+ 'threads.open': ownerOnly(payload => threadAction('threads.open', payload)),
1370
+ 'threads.archive': ownerOnly(payload => threadAction('threads.archive', payload)),
1371
+ 'threads.unarchive': ownerOnly(payload => threadAction('threads.unarchive', payload)),
1372
+ 'threads.ensure': ownerOnly(payload => threadAction('threads.ensure', payload)),
1373
+ 'threads.recover': ownerOnly(payload => threadAction('threads.recover', payload)),
1374
+ 'threads.reconcile': ownerOnly(payload => threadAction('threads.reconcile', payload)),
1375
+ 'data.catalog': ownerOnly(payload => dataAction('data.catalog', payload)),
1376
+ 'data.load': ownerOnly(payload => dataAction('data.load', payload)),
1377
+ 'data.describe': ownerOnly(payload => dataAction('data.describe', payload)),
1378
+ 'data.sql': ownerOnly(payload => dataAction('data.sql', payload)),
1379
+ 'data.show': ownerOnly(payload => dataAction('data.show', payload)),
1380
+ 'data.page': ownerOnly(payload => dataAction('data.page', payload)),
1381
+ 'data.export': ownerOnly(payload => dataAction('data.export', payload)),
1382
+ 'data.remove': ownerOnly(payload => dataAction('data.remove', payload)),
1383
+ 'data.cancel': ownerOnly(payload => dataAction('data.cancel', payload)),
1384
+ // Pinned web panels are the person's own console, not a Bot surface. Every entry
1385
+ // names its caller class instead of inheriting whatever the seam defaults to, and
1386
+ // none of them accepts a URL from the payload: the probe reads the owner's stored
1387
+ // pin, so there is no "fetch this address" door here at all.
1388
+ 'webPanels.list': ownerOnly((payload, context) => webPanelActions.actions['webPanels.list'](payload, context)),
1389
+ 'webPanels.probe': ownerOnly((payload, context) => webPanelActions.actions['webPanels.probe'](payload, context)),
1390
+ 'webPanels.save': ownerOnly((payload, context) => webPanelActions.actions['webPanels.save'](payload, context)),
1391
+ 'webPanels.remove': ownerOnly((payload, context) => webPanelActions.actions['webPanels.remove'](payload, context)),
1392
+ 'webPanels.order': ownerOnly((payload, context) => webPanelActions.actions['webPanels.order'](payload, context)),
1393
+ // An unconfirmed receipt fail-closes its Bot until an owner resolves it:
1394
+ // attest the vendor-confirmed amount or waive it, with a required note.
1395
+ ...(funding ? { 'usage.resolve': (payload, context) => {
1396
+ if (context?.kind !== 'owner') throw new Error('REMOTE_OWNER_REQUIRED')
1397
+ if (!payload || typeof payload !== 'object' || Object.keys(payload).some(key => !['botId', 'runId', 'packageId', 'kind', 'costUsd', 'note'].includes(key))) {
1398
+ throw new Error('INVALID_RESOLUTION_REQUEST')
1399
+ }
1400
+ return funding.store.resolveUnknownPackage(payload)
1401
+ } } : {}) },
1402
+ authorizeRequest })
1403
+ const preparedAuxiliary = createAuxiliaryModelStream({
1404
+ llm: () => { try { return ctx.get?.('llm') ?? ctx.llm ?? null } catch { return ctx.llm ?? null } },
1405
+ frames: modelFrames, fundingFor: bindingFunding,
1406
+ identityFor: request => policy.modelIdentity(request),
1407
+ redact: text => product.rt.redactSecrets(text),
1408
+ carryUsageTag: (from, to) => product.rt.carryUsageTag(from, to),
1409
+ })
1410
+ const auxiliaryStream = async function* (request, context) {
1411
+ await product.rt.loadSecrets?.()
1412
+ yield* preparedAuxiliary(request, context)
1413
+ }
1414
+ auxiliaryStream.stream = auxiliaryStream
1415
+ auxiliaryStream.reasoningOff = preparedAuxiliary.reasoningOff
1416
+ ctx.provide('botAuxiliaryModelStream', Object.freeze(auxiliaryStream))
1417
+ // Fix the initial history boundary after committed Bots are hydrated,
1418
+ // before tools can start new work. A failed read fails startup visibly.
1419
+ await product.rt.load()
1420
+ independentChat = createIndependentChatService({ Database, file: storageFile, lifecycle: product.lifecycle,
1421
+ runtime: () => { try { return ctx.get?.('botRuntimeAdapter') ?? null } catch { return null } }, now })
1422
+ ctx.provide('botIndependentChat', independentChat)
1423
+ notifications?.readModel()
1424
+ // Per-Bot conversation threads. The catalog is this Host's own file
1425
+ // beside the rest of its state. A thread Session is made through the
1426
+ // product runtime and recorded in the Bot's committed state, which is
1427
+ // what lets the lifecycle compile it into the Bot's ownership — see
1428
+ // bot-thread-host.mjs. It is built after the runtime exists, because the
1429
+ // adapter asks the runtime for its own facts.
1430
+ botThreads = createBotThreadHost({ rt: product.rt, lifecycle: product.lifecycle, authority, directory,
1431
+ file: join(dirname(storageFile), 'bot-threads.json'), runtimeMode: botId => product.serving.readMode(botId) ?? 'unavailable' })
1432
+ ctx.provide('botThreads', botThreads)
1433
+ botThreads?.service.reconcile()
1434
+ teamCoordination = createTeamCoordination({ Database, file: storageFile, rt: product.rt,
1435
+ directory, authority, lifecycle: product.lifecycle, serving: product.serving, policy,
1436
+ threadHost: () => botThreads,
1437
+ runtime: () => ctx.get('botRuntimeAdapter'), flush: session => ctx.sessions.flush(session),
1438
+ sessionEvents: { subscribe: handler => ctx.on('session/event', handler) }, now, limits: { maxDepth: 4 } })
1439
+ ctx.provide('botTeamCoordination', teamCoordination)
1440
+ releaseTeamGuard = ctx.tools.guard(exec => {
1441
+ const botId = (() => { try { return describeBot(directory.forAgent(exec.agent)) } catch { return null } })()
1442
+ if (!botId) return
1443
+ try { teamCoordination.assertCanAct(botId, { sessionId: exec.agent.session.id }) }
1444
+ catch (error) { return `${error.code ?? 'TEAM_ACCESS_CHANGED'}: ${product.rt.redactSecrets(String(error.message))}` }
1445
+ })
1446
+ releaseTeamTools = registerTeamTools(ctx, { service: teamCoordination,
1447
+ botIdFor: exec => describeBot(directory.forAgent(exec?.agent)),
1448
+ names: ['coordinate_bots', 'assignment_status', 'list_threads'], redact: product.rt.redactSecrets })
1449
+ releaseTeamStop = ctx.on('session/event', (session, event) => {
1450
+ if (event.type !== 'turn/end' || event.data?.reason?.kind !== 'aborted'
1451
+ || !['user', 'parent'].includes(event.data.reason.reason?.kind)) return
1452
+ let botId
1453
+ try { botId = describeBot(directory.forSession(session.id)) } catch { return }
1454
+ if (botId) teamCoordination.stop(botId)
1455
+ })
1456
+ teamCoordination.start()
1457
+ releaseMachineSecrets=guardMachineSecrets(ctx,product.rt,machines)
1458
+ ctx.provide('botRemoteMachines', machines)
1459
+ registerRemoteComputerTools(ctx,{runtime:product.rt,directory,machines,taskService,remoteCliBackend,operatorSubject,botRouting,routineStore,routineExecution,handoffTask,
1460
+ routedExecution: turnBackends, botIdFor: exec => describeBot(directory.forAgent(exec?.agent)), routingBotIds: () => product.lifecycle.committedMainBotIds() })
1461
+ releaseDataTools = registerDataTools(ctx, { engine: dataEngine, botIdFor: dataAccess.botIdFor })
1462
+ // The MR execution paths project their routes through the same Bot
1463
+ // binding, so a consult run carries the binding price instead of an
1464
+ // unknown-cost package.
1465
+ if (funding) ctx.provide('botFunding', funding.funded)
1466
+ deferredRouterState?.resolve(funding.state)
1467
+ machineHeartbeat = createMachineHeartbeat({ machines: () => machines, preferredBotIds: () => product.lifecycle.committedMainBotIds() })
1468
+ machineHeartbeat.start()
1469
+ timer = setInterval(() => { void refresh() }, refreshIntervalMs)
1470
+ timer.unref()
1471
+ return product
1472
+ }catch(error){
1473
+ await grantRefresh.close()
1474
+ ctx.get?.('botIndependentToolHost')?.close?.()
1475
+ try { independentChat?.close() } catch { /* remaining Host teardown must still run */ }
1476
+ try { teamCoordination?.close() } catch {}
1477
+ releaseTeamTools?.(); releaseTeamGuard?.(); releaseTeamStop?.()
1478
+ await conversationEngines?.close().catch(() => {})
1479
+ try { nativeActivity?.close() } catch {}
1480
+ await machineHeartbeat?.close?.()
1481
+ await routineExecution?.close?.()
1482
+ await machines?.closeDesktopSessions?.()
1483
+ await desktopProxy?.close()
1484
+ desktopHttp?.close()
1485
+ releaseDataTools?.()
1486
+ await dataEngine?.closeAll().catch(() => {})
1487
+ for(const cleanup of [()=>product?.dispose(),()=>releaseMachineSecrets?.(),()=>releaseTaskTools?.(),()=>securePairing?.close(),()=>nativeRemoteActions?.close(),()=>machines?.close(),()=>botSettings?.close(),()=>fundingAccounts?.close(),()=>modelAccounting?.close(),()=>routingSettings?.close(),()=>uiNamespace?.close(),()=>webPanelStore?.close()])try{cleanup()}catch{}
1488
+ if(taskRunnerWatchdog)clearInterval(taskRunnerWatchdog)
1489
+ if(taskService)await taskService.close().catch(()=>{})
1490
+ else journal?.close()
1491
+ notifications?.close()
1492
+ botThreads?.close()
1493
+ turnBackends?.close()
1494
+ if(remoteCliBackend)await remoteCliBackend.close().catch(()=>{})
1495
+ if(funding)await funding.close().catch(()=>{})
1496
+ if(deferredRouterState)try{deferredRouterState.reject(error)}catch{}
1497
+ if(sharedAdmission)await sharedAdmission.release().catch(()=>{})
1498
+ throw error
1499
+ }
1500
+ })()
1501
+ if (failClosed) {
1502
+ // A half-composed product (no Bot runtime) must never look like a working Host.
1503
+ boot.catch(error => {
1504
+ process.stderr.write(`Bot product composition failed: ${error?.stack ?? error}\n`)
1505
+ setTimeout(() => { throw error }, 0)
1506
+ })
1507
+ }
1508
+ return Object.freeze({
1509
+ machineId, workspaceId, grants, grantFor,
1510
+ observations: () => accountStream.observations?.() ?? [],
1511
+ routeStatus: () => ({ hydrated, routes: [...routes].sort() }),
1512
+ boot,
1513
+ async dispose() {
1514
+ if (timer) clearInterval(timer)
1515
+ const product = await boot.then(value => value, () => null)
1516
+ await grantRefresh.close()
1517
+ ctx.get?.('botIndependentToolHost')?.close?.()
1518
+ try { independentChat?.close() } catch { /* remaining Host teardown must still run */ }
1519
+ try { teamCoordination?.close() } catch {}
1520
+ releaseTeamTools?.(); releaseTeamGuard?.(); releaseTeamStop?.()
1521
+ let engineCloseError
1522
+ try { await conversationEngines?.close() } catch (error) { engineCloseError = error }
1523
+ try { nativeActivity?.close() } catch (error) { engineCloseError ??= error }
1524
+ try{
1525
+ await machineHeartbeat?.close?.()
1526
+ await routineExecution?.close?.()
1527
+ await machines?.closeDesktopSessions?.()
1528
+ await desktopProxy?.close()
1529
+ desktopHttp?.close()
1530
+ releaseDataTools?.()
1531
+ await dataEngine?.closeAll().catch(() => {})
1532
+ for(const cleanup of [()=>product?.dispose(),()=>releaseMachineSecrets?.(),()=>releaseTaskTools?.(),()=>securePairing?.close(),()=>nativeRemoteActions?.close(),()=>machines?.close(),()=>botSettings?.close(),()=>fundingAccounts?.close(),()=>modelAccounting?.close(),()=>routingSettings?.close(),()=>uiNamespace?.close(),()=>webPanelStore?.close()])try{cleanup()}catch{}
1533
+ if(taskRunnerWatchdog)clearInterval(taskRunnerWatchdog)
1534
+ if(taskService)await taskService.close().catch(()=>{})
1535
+ else journal?.close()
1536
+ notifications?.close()
1537
+ botThreads?.close()
1538
+ turnBackends?.close()
1539
+ if(remoteCliBackend)await remoteCliBackend.close().catch(()=>{})
1540
+ if(funding)await funding.close()
1541
+ }finally{authority.dispose();if(sharedAdmission)await sharedAdmission.release()}
1542
+ if (engineCloseError) throw engineCloseError
1543
+ },
1544
+ })
1545
+ }
1546
+
1547
+ /** Bounded diagnostic decorator: identity and source only, never a monetary claim. */
1548
+ export function createDiagnosticAccountStream({ limit = OBSERVATION_LIMIT, now = Date.now } = {}) {
1549
+ const ring = []
1550
+ const stream = (options, next, meta) => {
1551
+ if (ring.length >= limit) ring.shift()
1552
+ ring.push({ at: now(), source: meta?.source ?? null, sessionId: meta?.sessionId ?? null,
1553
+ botId: meta?.botId ?? null, provider: options?.provider ?? null, model: options?.model ?? null,
1554
+ resolved: Boolean(meta?.subject) })
1555
+ return next()
1556
+ }
1557
+ stream.observations = () => ring.slice()
1558
+ return stream
1559
+ }