@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
@@ -11,7 +11,7 @@
11
11
  * never mistaken for a finished one.
12
12
  */
13
13
 
14
- export const ROUTINE_ACTIONS = Object.freeze(['click', 'type', 'key', 'scroll', 'wait', 'input'])
14
+ export const ROUTINE_ACTIONS = Object.freeze(['click', 'type', 'key', 'scroll', 'wait', 'input', 'release-input'])
15
15
  export const MAX_ROUTINE_STEPS = 200
16
16
  export const MAX_TYPE_LENGTH = 2000
17
17
  export const MAX_RECORDED_TEXT_LENGTH = 8192
@@ -70,10 +70,10 @@ export function normalizeStep(input) {
70
70
  if (!input || typeof input !== 'object' || Array.isArray(input)) fail('ROUTINE_STEP_INVALID', '步骤必须是对象。')
71
71
  const action = input.action
72
72
  if (!ROUTINE_ACTIONS.includes(action)) fail('ROUTINE_ACTION_UNKNOWN', `未知动作:${String(action)}`)
73
- if (action === 'input') {
73
+ if (action === 'input' || action === 'release-input') {
74
74
  const delayMs = input.delayMs ?? 0
75
75
  if (!finiteInt(delayMs) || delayMs < 0 || delayMs > 60_000) fail('ROUTINE_STEP_INVALID')
76
- return { action, event: normalizeRoutineInput(input.event), delayMs }
76
+ return action === 'release-input' ? { action, delayMs } : { action, event: normalizeRoutineInput(input.event), delayMs }
77
77
  }
78
78
  if (action === 'click') {
79
79
  if (!finiteInt(input.x) || !finiteInt(input.y) || input.x < 0 || input.y < 0) fail('ROUTINE_STEP_INVALID', 'click 需要非负整数坐标。')
@@ -119,7 +119,7 @@ export function normalizeRoutine(input) {
119
119
  recordedAt: recorded.recordedAt, durationMs: recorded.durationMs, acknowledgedInputs: recorded.acknowledgedInputs,
120
120
  ...(recorded.observedInputs !== undefined ? { observedInputs: recorded.observedInputs, pointerSampleMs: recorded.pointerSampleMs } : {}) }
121
121
  return {
122
- schemaVersion: recording || input.steps.some(step => step.action === 'input') ? 2 : 1,
122
+ schemaVersion: recording || input.steps.some(step => ['input','release-input'].includes(step.action)) ? 2 : 1,
123
123
  name,
124
124
  ...(typeof input.description === 'string' && input.description.trim() ? { description: input.description.trim().slice(0, 500) } : {}),
125
125
  steps: input.steps.map(normalizeStep),
@@ -10,10 +10,13 @@
10
10
 
11
11
  import { mkdirSync } from 'node:fs'
12
12
  import { join } from 'node:path'
13
- import { createBotLoop } from './bot-loop.mjs'
13
+ import { createHash } from 'node:crypto'
14
+ import { createBotLoop, botRequestReceipt } from './bot-loop.mjs'
14
15
  import { openBotSessionStore } from './bot-session-store.mjs'
15
16
  import { createBotDispatch } from './bot-runtime-dispatch.mjs'
16
17
  import { openRuntimeSwitch } from './bot-runtime-switch.mjs'
18
+ import { createBotServing } from './bot-serving.mjs'
19
+ import { contextView, contextRequestOk, independentContextId, retirementConfirmed } from './independent-context-state.mjs'
17
20
 
18
21
  const fail = code => { const error = new Error(code); error.code = code; throw error }
19
22
 
@@ -22,22 +25,80 @@ const fail = code => { const error = new Error(code); error.code = code; throw e
22
25
  * dir: string,
23
26
  * integration: (request: object) => Promise<object>,
24
27
  * providerFor: (botId: string) => ((request: object) => Promise<object>) | null,
25
- * toolsFor?: (botId: string) => { names?: string[], call: Function } | null,
28
+ * toolsFor?: (botId: string) => { names?: string[], schemas?: () => object[] | null, isReadonly?: (name: string) => boolean, call: (name: string, args: object, context: { botId: string, signal?: AbortSignal, turnId: string, callId: string }) => Promise<unknown> } | null,
26
29
  * isBusy?: (botId: string) => boolean | Promise<boolean>,
30
+ * serving?: object | null,
31
+ * assertTurn?: (botId: string) => void,
27
32
  * now?: () => number,
28
33
  * maxSteps?: number,
29
34
  * }} options
30
35
  */
31
36
  export function createIndependentRuntime({
32
- dir, integration, providerFor, toolsFor = () => null, isBusy = () => false, now = Date.now, maxSteps,
37
+ dir, integration, providerFor, toolsFor = () => null, isBusy = () => false, serving = null, assertTurn = null, now = Date.now, maxSteps,
38
+ retireConversation = null,
33
39
  } = {}) {
34
40
  if (typeof dir !== 'string' || dir === '') throw new TypeError('RUNTIME_DIR_REQUIRED')
35
41
  if (typeof integration !== 'function') throw new TypeError('RUNTIME_REQUIRES_INTEGRATION')
36
42
  if (typeof providerFor !== 'function') throw new TypeError('RUNTIME_REQUIRES_PROVIDER_FACTORY')
43
+ if (assertTurn !== null && typeof assertTurn !== 'function') throw new TypeError('RUNTIME_ASSERT_TURN_INVALID')
44
+ if (retireConversation !== null && typeof retireConversation !== 'function') throw new TypeError('RUNTIME_RETIRE_CONVERSATION_INVALID')
45
+ if (serving !== null && (typeof serving !== 'object' || typeof serving.enter !== 'function' || typeof serving.assertMode !== 'function')) {
46
+ throw new TypeError('RUNTIME_SERVING_ADMISSION_INVALID')
47
+ }
37
48
  mkdirSync(dir, { recursive: true })
38
49
  const store = openBotSessionStore({ dir: join(dir, 'sessions'), now })
39
50
  const switchApi = openRuntimeSwitch({ file: join(dir, 'runtime-switch.json'), now })
40
51
  const loops = new Map()
52
+ const controllers = new Set(), resetLeases = new Map()
53
+ let closed = false
54
+ const assertOpen = () => { if (closed) fail('INDEPENDENT_RUNTIME_CLOSED') }
55
+ const controlled = signal => {
56
+ const controller = new AbortController()
57
+ const abort = () => controller.abort(signal.reason)
58
+ if (signal?.aborted) abort()
59
+ else signal?.addEventListener('abort', abort, { once: true })
60
+ controllers.add(controller)
61
+ return { controller, release() { controllers.delete(controller); signal?.removeEventListener('abort', abort) } }
62
+ }
63
+ // The shared Host admission: the same lease the integrated stream holds, so a move sees a Bot
64
+ // this path is serving exactly as it sees one the Harness is serving. It is an admission only —
65
+ // the committed switch still decides which runtime answers, and the policy still decides what
66
+ // the answer may do.
67
+ const guard = serving ?? createBotServing({ modeOf: botId => switchApi.modeOf(botId), now })
68
+ // One re-decision per checkpoint, from committed state only: which runtime serves this Bot, and
69
+ // whether the Bot still stands at all. The loop asks before every transcript write, every
70
+ // provider step and every tool call, so a Bot moved, deleted or revoked mid-turn stops at the
71
+ // next checkpoint instead of finishing on a state that no longer stands.
72
+ const turnGuard = botId => {
73
+ assertOpen()
74
+ if (guard) guard.assertMode(botId, 'independent')
75
+ if (assertTurn) assertTurn(botId)
76
+ if (store.get(botId).contextReset) fail('INDEPENDENT_CONTEXT_PENDING_RECONCILIATION')
77
+ }
78
+
79
+ /**
80
+ * The tool surface, resolved per step and per call rather than once per Bot.
81
+ *
82
+ * A loop is cached per Bot so a turn does not rebuild it, but the tools behind it are Host state:
83
+ * `schemas()` is asked again for every request, and `call()` resolves the Host's own service
84
+ * again for every call. A grant that changed, or a Host tool service that went away, therefore
85
+ * stops the next call instead of being frozen into a loop somebody cached.
86
+ */
87
+ const toolServiceFor = botId => Object.freeze({
88
+ schemas: () => {
89
+ const tools = toolsFor(botId)
90
+ return tools && typeof tools.schemas === 'function' ? tools.schemas() : null
91
+ },
92
+ isReadonly: name => {
93
+ const tools = toolsFor(botId)
94
+ return Boolean(tools && typeof tools.isReadonly === 'function' && tools.isReadonly(name))
95
+ },
96
+ call: (name, args, context) => {
97
+ const tools = toolsFor(botId)
98
+ if (!tools) fail('BOT_LOOP_TOOLS_UNAVAILABLE')
99
+ return tools.call(name, args, context)
100
+ },
101
+ })
41
102
 
42
103
  // One loop per Bot: it holds no state beyond the store, but caching keeps a
43
104
  // provider factory from being called twice in one turn.
@@ -46,7 +107,11 @@ export function createIndependentRuntime({
46
107
  if (existing) return existing
47
108
  const provider = providerFor(botId)
48
109
  if (typeof provider !== 'function') fail('RUNTIME_PROVIDER_UNAVAILABLE')
49
- const loop = createBotLoop({ store, provider, tools: toolsFor(botId) ?? null, now, ...(maxSteps ? { maxSteps } : {}) })
110
+ // Every step re-reads the committed mode: a Bot moved mid-turn stops at the next step
111
+ // instead of finishing on a runtime that no longer owns it.
112
+ const guarded = request => { turnGuard(request.botId); return provider(request) }
113
+ const loop = createBotLoop({ store, provider: guarded,
114
+ tools: typeof toolsFor === 'function' ? toolServiceFor(botId) : null, guard: turnGuard, now, ...(maxSteps ? { maxSteps } : {}) })
50
115
  loops.set(botId, loop)
51
116
  return loop
52
117
  }
@@ -55,22 +120,160 @@ export function createIndependentRuntime({
55
120
  switchApi,
56
121
  integration,
57
122
  independent: async request => {
58
- const result = await loopFor(request.botId).run({ botId: request.botId, input: request.input, signal: request.signal })
59
- return { answer: result.answer, steps: result.steps, toolCalls: result.toolCalls, turns: result.turns }
123
+ if (!guard) return loopFor(request.botId).run({ botId: request.botId, input: request.input, signal: request.signal, onProgress: request.onProgress })
124
+ // The whole turn, tools included, under the one lease: a second turn, or a move, is refused
125
+ // while this one runs, and the lease is released on answer, cancel, failure or throw.
126
+ guard.assertMode(request.botId, 'independent')
127
+ const lease = guard.acquire(request.botId)
128
+ try {
129
+ const result = await loopFor(request.botId).run({ botId: request.botId, input: request.input, signal: request.signal, onProgress: request.onProgress })
130
+ return { answer: result.answer, steps: result.steps, toolCalls: result.toolCalls, turns: result.turns }
131
+ } finally {
132
+ lease.release()
133
+ }
60
134
  },
61
135
  now,
62
136
  })
63
137
 
138
+ const contextCheck = botId => {
139
+ assertOpen()
140
+ guard.assertMode(botId, 'independent')
141
+ if (assertTurn) assertTurn(botId)
142
+ }
143
+ const reset = async (botId, { requestKey, expectedContextId, signal, retirementEvidence } = {}, reconcile = false) => {
144
+ contextCheck(botId)
145
+ if (!contextRequestOk(requestKey)) fail('INVALID_BOT_CONTEXT_RESET')
146
+ if (!reconcile && !retireConversation) throw Object.assign(new Error('INDEPENDENT_CONTEXT_RETIREMENT_UNAVAILABLE'), { code: 'INDEPENDENT_CONTEXT_RETIREMENT_UNAVAILABLE', outcome: 'refused' })
147
+ const contextId = independentContextId(botId, requestKey)
148
+ if (store.contextCommitted(botId, requestKey, contextId)) return contextView(store.get(botId))
149
+ const previous = store.get(botId)
150
+ if (previous.contextReset && (!reconcile || previous.contextReset.requestKey !== requestKey || previous.contextReset.contextId !== contextId)) fail('INDEPENDENT_CONTEXT_PENDING_RECONCILIATION')
151
+ if (reconcile && !retirementConfirmed(retirementEvidence)) fail('INDEPENDENT_CONTEXT_RETIREMENT_UNCONFIRMED')
152
+ // Acquire synchronously before the first await. A turn or move in this tick cannot enter.
153
+ const lease = resetLeases.get(botId) ?? guard.acquire(botId)
154
+ resetLeases.set(botId, lease)
155
+ const scope = controlled(signal)
156
+ let intentWritten = Boolean(previous.contextReset), confirmed = false
157
+ const checkpoint = () => { scope.controller.signal.throwIfAborted(); contextCheck(botId) }
158
+ try {
159
+ checkpoint()
160
+ if (!reconcile) {
161
+ store.beginContext(botId, { requestKey, contextId, expectedContextId })
162
+ intentWritten = true
163
+ const evidence = await retireConversation(botId, { reason: 'new-conversation', signal: scope.controller.signal })
164
+ if (!retirementConfirmed(evidence)) fail('INDEPENDENT_CONTEXT_RETIREMENT_UNCONFIRMED')
165
+ }
166
+ confirmed = true
167
+ checkpoint()
168
+ const session = store.commitContext(botId, { requestKey, contextId })
169
+ loops.delete(botId)
170
+ return contextView(session)
171
+ } catch (error) {
172
+ // A post-rename read error may still have committed the intended context.
173
+ let observed
174
+ try {
175
+ if (store.contextCommitted(botId, requestKey, contextId)) { loops.delete(botId); return contextView(store.get(botId)) }
176
+ if (intentWritten && !confirmed && error.outcome === 'refused') store.abortContext(botId, { requestKey, contextId })
177
+ observed = store.get(botId)
178
+ } catch { /* Preserve uncertainty and its durable admission fence. */ }
179
+ error.outcome = !observed || observed.contextReset?.requestKey === requestKey ? 'unknown' : error.outcome ?? 'refused'
180
+ throw error
181
+ } finally {
182
+ scope.release()
183
+ // Unknown retirement keeps the lease and durable intent. Only explicit trusted
184
+ // reconciliation with stop evidence can release it; inspect never retries it.
185
+ let pending = true
186
+ try { pending = store.get(botId).contextReset?.requestKey === requestKey } catch { /* fail closed */ }
187
+ if (!pending) { resetLeases.delete(botId); lease.release() }
188
+ }
189
+ }
190
+
191
+ // ---- trusted work-thread turns (teammate coordination) ------------------------------
192
+ // A work-thread is a REAL, distinct conversation history for one (target Bot, work
193
+ // thread): its own session-store file under work-threads/<id>, run through the same
194
+ // guarded loop, the same single-writer lease and the target's own provider/tools/grants
195
+ // as its default conversation — never a label on the default transcript. The Host-only
196
+ // requestId correlation and the persisted user/answer pair make a replay a read, and an
197
+ // unconfirmed answer a fence, never a second dispatch.
198
+ const workThreadIdOk = value => typeof value === 'string' && /^[A-Za-z0-9_.-]{1,128}$/.test(value)
199
+ const workThreadStores = new Map()
200
+ const workThreadStoreFor = (workThreadId, botId) => {
201
+ if (!workThreadIdOk(workThreadId)) fail('INVALID_WORK_THREAD_ID')
202
+ if (!workThreadIdOk(botId)) fail('INVALID_BOT_ID')
203
+ const key = createHash('sha256').update(JSON.stringify([botId, workThreadId])).digest('hex')
204
+ let store = workThreadStores.get(key)
205
+ if (!store) { store = openBotSessionStore({ dir: join(dir, 'work-threads', key), now }); workThreadStores.set(key, store) }
206
+ return store
207
+ }
208
+ // The loop addresses one transcript by botId; a work-thread keeps the target's identity
209
+ // for provider/guard but its own store key, so this view pins the key and ignores the arg.
210
+ const workThreadStoreView = (store, workThreadId) => Object.freeze({
211
+ get: () => store.get(workThreadId),
212
+ append: (_botId, messages, options) => store.append(workThreadId, messages, options),
213
+ })
214
+ // A work-thread turn re-decides the committed runtime and Bot per checkpoint, but never
215
+ // the default conversation's context reset: the work-thread has its own linear store.
216
+ const workThreadGuard = botId => { assertOpen(); if (guard) guard.assertMode(botId, 'independent'); if (assertTurn) assertTurn(botId) }
217
+ const captureTeamOrigin = ({ botId, conversationId = null, workThreadId = null } = {}) => {
218
+ workThreadGuard(botId)
219
+ // workThreadId is supplied only by the Host's durable assignment lookup.
220
+ if (conversationId?.startsWith('teamwt-')) {
221
+ if (!workThreadId) fail('TEAM_ORIGIN_UNCONFIRMED')
222
+ return { mode: 'independent', conversationId, workThreadId, contextId: workThreadStoreFor(workThreadId, botId).get(workThreadId).contextId }
223
+ }
224
+ const current = store.get(botId)
225
+ if (current.contextReset || conversationId && conversationId !== current.contextId) fail('TEAM_ORIGIN_RETIRED')
226
+ return { mode: 'independent', conversationId: current.contextId, contextId: current.contextId, workThreadId: null }
227
+ }
228
+ const teamOriginCurrent = (botId, origin) => {
229
+ try { workThreadGuard(botId); const current = origin.workThreadId ? workThreadStoreFor(origin.workThreadId, botId).get(origin.workThreadId) : store.get(botId); return !current.contextReset && current.contextId === origin.contextId }
230
+ catch { return false }
231
+ }
232
+ const runTeamTurn = async ({ botId, workThreadId = null, input, requestId, signal, conversationId, consult = false, validate = () => {}, origin = null }) => {
233
+ assertOpen()
234
+ // Validate all identifiers and open the transcript before acquiring admission.
235
+ const transcript = workThreadId ? workThreadStoreFor(workThreadId, botId) : store
236
+ const key = workThreadId ?? botId
237
+ botRequestReceipt(transcript.get(key), requestId)
238
+ const checkpoint = () => { workThreadGuard(botId); validate(); if (origin && !teamOriginCurrent(botId, origin)) fail('TEAM_ORIGIN_RETIRED') }
239
+ checkpoint()
240
+ const lease = guard.acquire(botId), scope = controlled(signal)
241
+ try {
242
+ const provider = providerFor(botId)
243
+ if (typeof provider !== 'function') fail('RUNTIME_PROVIDER_UNAVAILABLE')
244
+ const guarded = request => { checkpoint(); return provider({ ...request, botId, conversationId }) }
245
+ const loop = createBotLoop({ store: workThreadId ? workThreadStoreView(transcript, workThreadId) : transcript, provider: guarded,
246
+ tools: consult ? null : toolServiceFor(botId), guard: checkpoint, now, ...(maxSteps ? { maxSteps } : {}) })
247
+ return await loop.run({ botId, input, requestId, conversationId, signal: scope.controller.signal })
248
+ } finally { scope.release(); lease.release() }
249
+ }
250
+
64
251
  return Object.freeze({
65
252
  /** Serve one turn on the committed runtime. */
66
- turn: request => dispatch.turn(request),
253
+ async turn(request = {}) {
254
+ assertOpen()
255
+ if (store.get(request.botId).contextReset) fail('INDEPENDENT_CONTEXT_PENDING_RECONCILIATION')
256
+ const scope = controlled(request.signal)
257
+ try { return await dispatch.turn({ ...request, signal: scope.controller.signal }) }
258
+ finally { scope.release() }
259
+ },
260
+ /** Trusted Host context token; never a Harness/session directory identity. */
261
+ contextOf: botId => store.contextOf(botId),
262
+ newConversation: (botId, options) => reset(botId, options),
263
+ /** Explicit Host evidence recovery only. Owner requests cannot supply evidence. */
264
+ reconcileConversation: (botId, options) => reset(botId, options, true),
67
265
  /** Which runtime a Bot is on. */
68
266
  describe: botId => dispatch.describe(botId),
69
267
  /** Move a Bot between runtimes; refused while it is busy. */
70
268
  move: async (botId, mode) => {
269
+ assertOpen()
270
+ if (store.get(botId).contextReset) fail('INDEPENDENT_CONTEXT_PENDING_RECONCILIATION')
71
271
  const before = store.list([botId])[0] ?? null
72
272
  const result = await switchApi.move(botId, mode, {
73
273
  isBusy,
274
+ // The synchronous re-check at the commit boundary: the await above can lose the race to
275
+ // a turn this Host admitted while it was in flight.
276
+ ...(guard ? { assertIdle: () => guard.assertIdle(botId) } : {}),
74
277
  snapshot: before ? { turns: before.turns, messages: before.messages, at: now() } : null,
75
278
  })
76
279
  // A moved Bot must not keep a loop remembering an older provider or grant.
@@ -79,6 +282,37 @@ export function createIndependentRuntime({
79
282
  },
80
283
  /** Which Bots were moved, in the switch's own words. */
81
284
  list: () => switchApi.list(),
285
+ /** Which Bots this Host is serving right now, by either path. */
286
+ busyBots: () => (guard && typeof guard.busyBots === 'function' ? guard.busyBots() : []),
287
+ captureTeamOrigin,
288
+ teamOriginCurrent,
289
+ async resumeTeamTurn({ botId, origin, requestId, input, signal, validate } = {}) {
290
+ if (!origin || !teamOriginCurrent(botId, origin)) return { started: false, retired: true }
291
+ const result = await runTeamTurn({ botId, origin, workThreadId: origin.workThreadId, conversationId: origin.conversationId, requestId, input, signal, validate })
292
+ return { ...result, resumed: true }
293
+ },
294
+
295
+ /**
296
+ * Run ONE real turn in a work-thread's own transcript, under the Bot's single-writer
297
+ * lease and its own provider/tools/grants. The trusted per-work-thread `conversationId`
298
+ * is injected into the provider request so the target's policy frame and native identity
299
+ * carry it; nothing else about the caller crosses the boundary. This always dispatches —
300
+ * dedupe, replay and unknown-fencing are the caller's (the assignment journal's) job.
301
+ */
302
+ workThreadTurn: request => runTeamTurn(request),
303
+
304
+ /**
305
+ * Reconcile a work-thread's last exchange from its durable transcript: whether a brief
306
+ * was written and whether a confirmed model answer follows it. A brief with no confirmed
307
+ * answer is a fence (the turn may have run), never a re-dispatch; an empty transcript is
308
+ * a request that was never written. Bodies never leave the runtime beyond this bounded
309
+ * answer string the assignment journal stores.
310
+ */
311
+ workThreadRead(botId, workThreadId, requestId) {
312
+ assertOpen()
313
+ const transcript = workThreadStoreFor(workThreadId, botId)
314
+ return { ...botRequestReceipt(transcript.get(workThreadId), requestId), turns: transcript.get(workThreadId).turns }
315
+ },
82
316
 
83
317
  /**
84
318
  * A bounded, redacted view of one Bot transcript: role, time, size and a short preview.
@@ -92,6 +326,7 @@ export function createIndependentRuntime({
92
326
  const body = String(message.content ?? '')
93
327
  return {
94
328
  role: message.role,
329
+ contextId: message.contextId,
95
330
  at: message.at ?? null,
96
331
  chars: body.length,
97
332
  preview: chars === 0 ? '' : body.slice(0, chars),
@@ -110,10 +345,16 @@ export function createIndependentRuntime({
110
345
  const message = messages[messages.length - 1 - index]
111
346
  return {
112
347
  role: message.role,
348
+ contextId: message.contextId,
113
349
  at: message.at ?? null,
114
350
  chars: String(message.content ?? '').length,
115
351
  content: String(message.content ?? ''),
116
352
  ...(message.name ? { name: message.name } : {}),
353
+ ...(message.toolCall ? { toolCall: message.toolCall } : {}),
354
+ ...(message.toolCalls ? { toolCalls: message.toolCalls } : {}),
355
+ ...(message.toolCallId ? { toolCallId: message.toolCallId } : {}),
356
+ ...(message.modelSource ? { modelSource: message.modelSource } : {}),
357
+ ...(message.messageId ? { messageId: message.messageId } : {}),
117
358
  }
118
359
  },
119
360
 
@@ -123,6 +364,12 @@ export function createIndependentRuntime({
123
364
  return rows.map(row => ({ ...row, ...dispatch.describe(row.botId) }))
124
365
  },
125
366
  /** Drop cached loops when the plugin unloads. */
126
- close() { loops.clear() },
367
+ close() {
368
+ if (closed) return
369
+ closed = true
370
+ for (const controller of controllers) controller.abort(Object.assign(new Error('INDEPENDENT_RUNTIME_CLOSED'), { code: 'BOT_LOOP_CANCELLED' }))
371
+ loops.clear()
372
+ workThreadStores.clear()
373
+ },
127
374
  })
128
375
  }
@@ -52,11 +52,17 @@ export function openRuntimeSwitch({ file, now = Date.now } = {}) {
52
52
  },
53
53
  /**
54
54
  * Move one Bot to a runtime, atomically.
55
+ *
56
+ * `isBusy` may be asynchronous — a Host that has to ask a live runtime about a running turn
57
+ * can. `assertIdle` is the one that protects the commit: it is synchronous and it stands
58
+ * directly in front of the write, because the await above can lose the race to a turn that
59
+ * started while it was in flight.
60
+ *
55
61
  * @param {string} botId
56
62
  * @param {'integration'|'independent'} mode
57
- * @param {{ isBusy?: (botId: string) => boolean | Promise<boolean> }} [options]
63
+ * @param {{ isBusy?: (botId: string) => boolean | Promise<boolean>, assertIdle?: (botId: string) => boolean, snapshot?: object }} [options]
58
64
  */
59
- async move(botId, mode, { isBusy, snapshot = null } = {}) {
65
+ async move(botId, mode, { isBusy, assertIdle, snapshot = null } = {}) {
60
66
  if (!idOk(botId)) fail('INVALID_BOT_ID')
61
67
  if (!RUNTIME_MODES.includes(mode)) fail('INVALID_RUNTIME_MODE')
62
68
  const busy = typeof isBusy === 'function' ? await isBusy(botId) : false
@@ -65,6 +71,11 @@ export function openRuntimeSwitch({ file, now = Date.now } = {}) {
65
71
  const state = read()
66
72
  const current = state.bots[botId]?.mode ?? 'integration'
67
73
  if (current === mode) return { botId, mode, changed: false }
74
+ // Nothing may await between this check and the write below, or a turn admitted during the
75
+ // check above would be served by two runtimes at once.
76
+ if (typeof assertIdle === 'function' && assertIdle(botId) === false) {
77
+ fail('BOT_BUSY', '这个 Bot 正在一轮对话中,等它空闲后再切换运行时。')
78
+ }
68
79
  // A snapshot makes a move auditable afterwards: counts only, never message bodies.
69
80
  state.bots[botId] = { mode, changedAt: now(), ...(snapshot ? { snapshot } : {}) }
70
81
  write(state)
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The one Host-side admission a Bot turn runs under.
3
+ *
4
+ * A Bot is served by exactly one runtime, and a Bot that is answering is not a Bot that may be
5
+ * moved. Both paths therefore share this module: the integrated Harness stream and the
6
+ * independent loop take a lease from here, and a move is only committed while this module says
7
+ * the Bot is idle.
8
+ *
9
+ * This is an admission, not a second authority. It grants no model, no tool and no money: the
10
+ * committed Bot policy still decides what a call may do, and the one accounting stream still
11
+ * records what it cost. What it does own is the single-writer question — "is this Bot already
12
+ * being served, and by which runtime" — and it answers it from committed state, never from
13
+ * anything a request carries.
14
+ *
15
+ * Two rules are load-bearing:
16
+ *
17
+ * 1. The committed mode is read back through a trusted reader. A switch that cannot be read, or
18
+ * that answers with something that is not a mode, is never treated as `integration`: an
19
+ * unreadable decision refuses the call instead of assuming the default path.
20
+ * 2. `assertIdle` is synchronous and is called immediately before the switch writes. The check
21
+ * before it may await; nothing between the last check and the commit may, or a turn that
22
+ * started in the meantime would be served by two runtimes.
23
+ */
24
+
25
+ const RUNTIME_MODES = Object.freeze(['integration', 'independent'])
26
+ const idOk = value => typeof value === 'string' && /^[A-Za-z0-9_.-]{1,128}$/.test(value)
27
+ const fail = (code, message) => { const error = new Error(message ?? code); error.code = code; throw error }
28
+
29
+ /**
30
+ * @param {{
31
+ * modeOf?: ((botId: string) => string | null) | null,
32
+ * rt?: object | null,
33
+ * now?: () => number,
34
+ * }} options
35
+ */
36
+ export function createBotServing({ modeOf = null, rt = null, now = Date.now } = {}) {
37
+ if (modeOf !== null && typeof modeOf !== 'function') throw new TypeError('BOT_SERVING_MODE_READER_INVALID')
38
+ // One lease per Bot, counted so a nested call inside a live turn cannot deadlock on itself.
39
+ const leases = new Map()
40
+ // The admission is published before the Bot runtime exists — the product publishes it, then
41
+ // composes the runtime — so the runtime is bound to it afterwards. Generation ownership means a
42
+ // stale release (a disposed runtime) can never clear a newer binding, and a newer binding can
43
+ // never leave the old one's probe running.
44
+ let runtime = rt ?? null
45
+ let generation = 0
46
+
47
+ /**
48
+ * The committed mode, or null when it cannot be established. `null` is a refusal, never a
49
+ * default: a Host that installed no switch serves every Bot on the integration path, and that
50
+ * is the only case where this answers without asking.
51
+ */
52
+ const readMode = botId => {
53
+ if (typeof modeOf !== 'function') return 'integration'
54
+ let value
55
+ try { value = modeOf(botId) } catch { return null }
56
+ return RUNTIME_MODES.includes(value) ? value : null
57
+ }
58
+
59
+ /** Serve on `expected`, or refuse: another runtime owns this Bot, or nobody can say. */
60
+ const assertMode = (botId, expected) => {
61
+ if (!idOk(botId)) fail('INVALID_BOT_ID')
62
+ const committed = readMode(botId)
63
+ if (committed === null) {
64
+ fail('SERVING_MODE_UNKNOWN', `无法确认 ${botId} 的已提交运行时,本次请求被拒绝。`)
65
+ }
66
+ if (committed !== expected) {
67
+ fail('RUNTIME_MODE_MISMATCH', `运行时已切换:${botId} 当前由 ${committed} 提供服务,本次 ${expected} 请求被拒绝。`)
68
+ }
69
+ return committed
70
+ }
71
+
72
+ /**
73
+ * Bots the frozen runtime is working on right now, before any model chunk: a turn that has
74
+ * started, and one whose next step is queued. Counting only the stream would leave the whole
75
+ * front of a turn — the prompt, the tools, the queue — invisible to a move.
76
+ */
77
+ const nativeBusyBots = () => {
78
+ const busy = new Set()
79
+ const live = runtime?.live
80
+ if (!live || typeof live.entries !== 'function') return busy
81
+ for (const [sessionId, entry] of live.entries()) {
82
+ if (entry?.turn === undefined && entry?.pending === undefined) continue
83
+ const botId = runtime.selfOf?.(sessionId) ?? runtime.groupOwners?.get?.(sessionId)?.botId ?? null
84
+ if (typeof botId === 'string') busy.add(botId)
85
+ }
86
+ return busy
87
+ }
88
+
89
+ /**
90
+ * Take the serving lease for one whole turn or stream. The returned handle releases exactly
91
+ * once, so a completed, cancelled, failed or bypassed turn leaves nothing behind and a Bot can
92
+ * never be stuck permanently busy.
93
+ */
94
+ const enter = botId => {
95
+ if (!idOk(botId)) fail('INVALID_BOT_ID')
96
+ const lease = leases.get(botId) ?? { count: 0, since: now() }
97
+ lease.count += 1
98
+ leases.set(botId, lease)
99
+ let released = false
100
+ return Object.freeze({
101
+ botId,
102
+ release() {
103
+ if (released) return
104
+ released = true
105
+ const current = leases.get(botId)
106
+ if (!current) return
107
+ current.count -= 1
108
+ if (current.count <= 0) leases.delete(botId)
109
+ },
110
+ })
111
+ }
112
+
113
+ /**
114
+ * Take the only lease for one whole turn: a Bot that is already being served is refused. This
115
+ * is the admission the independent path uses, because two of its turns would write two
116
+ * transcripts over one Bot.
117
+ */
118
+ const acquire = botId => {
119
+ if (!idOk(botId)) fail('INVALID_BOT_ID')
120
+ if (busy(botId)) fail('BOT_BUSY', '这个 Bot 正在一轮对话中,等它空闲后再切换运行时。')
121
+ return enter(botId)
122
+ }
123
+
124
+ const busy = botId => (leases.get(botId)?.count ?? 0) > 0 || nativeBusyBots().has(botId)
125
+
126
+ /** Every Bot being served right now, by either path. */
127
+ const busyBots = () => [...new Set([...leases.keys(), ...nativeBusyBots()])].sort()
128
+
129
+ /**
130
+ * The move-time admission: true when the Bot is idle, a refusal when it is not. Synchronous by
131
+ * contract, because it stands directly in front of the switch's write.
132
+ */
133
+ const assertIdle = botId => {
134
+ if (!idOk(botId)) fail('INVALID_BOT_ID')
135
+ if (busy(botId)) fail('BOT_BUSY', '这个 Bot 正在一轮对话中,等它空闲后再切换运行时。')
136
+ return true
137
+ }
138
+
139
+ /**
140
+ * Bind the runtime whose live turn record this admission reads. The returned release clears the
141
+ * binding only while it is still the current one, so disposing a runtime cannot blind a newer
142
+ * one — and a runtime that is never disposed keeps its probe until it is replaced.
143
+ */
144
+ const bindRuntime = next => {
145
+ if (!next || typeof next !== 'object') throw new TypeError('BOT_SERVING_RUNTIME_REQUIRED')
146
+ generation += 1
147
+ const mine = generation
148
+ runtime = next
149
+ return () => { if (mine === generation) { generation += 1; runtime = null } }
150
+ }
151
+
152
+ return Object.freeze({
153
+ readMode,
154
+ assertMode,
155
+ enter,
156
+ acquire,
157
+ busy,
158
+ busyBots,
159
+ assertIdle,
160
+ bindRuntime,
161
+ /** Held leases, for a bounded diagnostic: ids and counts, never content. */
162
+ leases: () => [...leases.entries()].map(([botId, lease]) => ({ botId, count: lease.count, since: lease.since })).sort((left, right) => left.botId.localeCompare(right.botId)),
163
+ })
164
+ }