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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/.dsh-plugin/bot-product.mjs +81 -5
  2. package/.dsh-plugin/client.js +17490 -1791
  3. package/README.i18n.yaml +4 -4
  4. package/README.md +409 -364
  5. package/README.zh.md +409 -364
  6. package/apps/integration-client/README.md +31 -7
  7. package/apps/integration-client/api.mjs +14 -3
  8. package/apps/integration-client/binding-editor.jsx +308 -109
  9. package/apps/integration-client/binding-model.mjs +262 -0
  10. package/apps/integration-client/binding.css +144 -0
  11. package/apps/integration-client/bot-model-status.mjs +18 -0
  12. package/apps/integration-client/bot-profile-identity.jsx +197 -0
  13. package/apps/integration-client/bot-profile.css +57 -0
  14. package/apps/integration-client/bot-profile.mjs +97 -0
  15. package/apps/integration-client/bot-settings-dialog.css +18 -0
  16. package/apps/integration-client/bot-settings-dialog.jsx +111 -0
  17. package/apps/integration-client/bot-thread-header-bridge.css +20 -0
  18. package/apps/integration-client/bot-thread-header.jsx +278 -0
  19. package/apps/integration-client/bot-threads-panel.jsx +378 -0
  20. package/apps/integration-client/bot-threads.css +169 -0
  21. package/apps/integration-client/chat-design.mjs +250 -0
  22. package/apps/integration-client/chat-ui.jsx +175 -0
  23. package/apps/integration-client/chat.css +477 -0
  24. package/apps/integration-client/composer-draft-binding.mjs +50 -0
  25. package/apps/integration-client/composer-draft-bound.jsx +14 -0
  26. package/apps/integration-client/composer-draft-host.jsx +142 -0
  27. package/apps/integration-client/composer-draft-session.jsx +287 -0
  28. package/apps/integration-client/composer-draft-witness.mjs +115 -0
  29. package/apps/integration-client/composer-draft.css +4 -0
  30. package/apps/integration-client/composer-draft.mjs +322 -0
  31. package/apps/integration-client/contact-attention.jsx +54 -0
  32. package/apps/integration-client/data-panel.jsx +439 -0
  33. package/apps/integration-client/data.css +69 -0
  34. package/apps/integration-client/desktop-bridge.mjs +1 -0
  35. package/apps/integration-client/desktop-http-socket.mjs +49 -0
  36. package/apps/integration-client/desktop-keyboard.mjs +32 -0
  37. package/apps/integration-client/desktop-pane.jsx +99 -30
  38. package/apps/integration-client/desktop-stream.mjs +13 -3
  39. package/apps/integration-client/desktop-tools.jsx +19 -0
  40. package/apps/integration-client/desktop-touch.mjs +27 -0
  41. package/apps/integration-client/desktop.css +41 -0
  42. package/apps/integration-client/independent-chat-controller.mjs +224 -0
  43. package/apps/integration-client/independent-chat-entry.jsx +61 -0
  44. package/apps/integration-client/independent-chat-navigation.mjs +21 -0
  45. package/apps/integration-client/independent-chat-page.jsx +148 -0
  46. package/apps/integration-client/independent-chat-rich.jsx +64 -0
  47. package/apps/integration-client/independent-chat-rich.mjs +30 -0
  48. package/apps/integration-client/independent-chat.css +73 -0
  49. package/apps/integration-client/index.jsx +24 -16
  50. package/apps/integration-client/library-panel.jsx +4 -1
  51. package/apps/integration-client/licenses/jsqr-1.4.0-LICENSE.txt +202 -0
  52. package/apps/integration-client/main-route-editor.jsx +94 -0
  53. package/apps/integration-client/main-route-model.mjs +100 -0
  54. package/apps/integration-client/model-picker.css +37 -0
  55. package/apps/integration-client/model-picker.jsx +83 -0
  56. package/apps/integration-client/native-activity.css +141 -0
  57. package/apps/integration-client/native-activity.jsx +276 -0
  58. package/apps/integration-client/native-activity.mjs +384 -0
  59. package/apps/integration-client/notifications-panel.jsx +186 -0
  60. package/apps/integration-client/notifications.css +126 -0
  61. package/apps/integration-client/pairing-qr.jsx +79 -0
  62. package/apps/integration-client/panels.jsx +126 -115
  63. package/apps/integration-client/product-slots.jsx +46 -0
  64. package/apps/integration-client/routine-recorder.mjs +23 -2
  65. package/apps/integration-client/secure-pairing.jsx +84 -0
  66. package/apps/integration-client/tasks-panel.jsx +32 -10
  67. package/apps/integration-client/ui-icons.jsx +17 -0
  68. package/apps/integration-client/usage-explorer.jsx +43 -0
  69. package/apps/integration-client/usage-report.mjs +71 -0
  70. package/apps/integration-client/usage.css +34 -0
  71. package/apps/integration-client/web-panel-model.mjs +140 -0
  72. package/apps/integration-client/web-panel-page.jsx +221 -0
  73. package/apps/integration-client/web-panel-pins-live.jsx +28 -0
  74. package/apps/integration-client/web-panel.css +139 -0
  75. package/apps/integration-client/workspace-model.mjs +67 -0
  76. package/apps/integration-client/workspace-navigation.mjs +56 -0
  77. package/apps/integration-client/workspace-ui.jsx +173 -0
  78. package/apps/integration-client/workspace.css +332 -0
  79. package/modules/bot-runtime/SOURCE.json +176 -50
  80. package/modules/bot-runtime/index.mjs +169 -13
  81. package/modules/bot-runtime/product-overlay/OVERLAY.json +128 -2
  82. package/modules/bot-runtime/product-overlay/base/src/client/header.js +244 -0
  83. package/modules/bot-runtime/product-overlay/base/src/client/learning-card.js +202 -0
  84. package/modules/bot-runtime/product-overlay/base/src/client/model-menu.js +371 -0
  85. package/modules/bot-runtime/product-overlay/base/src/client/secret-card.js +363 -0
  86. package/modules/bot-runtime/product-overlay/base/src/client/sources.js +68 -0
  87. package/modules/bot-runtime/product-overlay/base/src/client/turn-error.js +260 -0
  88. package/modules/bot-runtime/product-overlay/base/src/host/channel.js +647 -0
  89. package/modules/bot-runtime/product-overlay/base/src/host/memory.js +2050 -0
  90. package/modules/bot-runtime/product-overlay/base/src/host/models.js +314 -0
  91. package/modules/bot-runtime/product-overlay/base/src/host/parts.js +1473 -0
  92. package/modules/bot-runtime/product-overlay/base/src/host/privacy.js +368 -0
  93. package/modules/bot-runtime/product-overlay/base/src/host/roster.js +776 -0
  94. package/modules/bot-runtime/product-overlay/base/src/host/store.js +512 -0
  95. package/modules/bot-runtime/product-overlay/base/src/host/usage.js +653 -0
  96. package/modules/bot-runtime/product-overlay/patches/src/client/details-drawer.js.patch +48 -2
  97. package/modules/bot-runtime/product-overlay/patches/src/client/header.js.patch +41 -0
  98. package/modules/bot-runtime/product-overlay/patches/src/client/index.js.patch +33 -1
  99. package/modules/bot-runtime/product-overlay/patches/src/client/learning-card.js.patch +20 -0
  100. package/modules/bot-runtime/product-overlay/patches/src/client/model-menu.js.patch +24 -0
  101. package/modules/bot-runtime/product-overlay/patches/src/client/secret-card.js.patch +25 -0
  102. package/modules/bot-runtime/product-overlay/patches/src/client/sources.js.patch +52 -0
  103. package/modules/bot-runtime/product-overlay/patches/src/client/turn-error.js.patch +23 -0
  104. package/modules/bot-runtime/product-overlay/patches/src/host/channel.js.patch +46 -0
  105. package/modules/bot-runtime/product-overlay/patches/src/host/memory.js.patch +11 -0
  106. package/modules/bot-runtime/product-overlay/patches/src/host/models.js.patch +16 -0
  107. package/modules/bot-runtime/product-overlay/patches/src/host/parts.js.patch +95 -0
  108. package/modules/bot-runtime/product-overlay/patches/src/host/privacy.js.patch +11 -0
  109. package/modules/bot-runtime/product-overlay/patches/src/host/roster.js.patch +145 -0
  110. package/modules/bot-runtime/product-overlay/patches/src/host/store.js.patch +68 -0
  111. package/modules/bot-runtime/product-overlay/patches/src/host/usage.js.patch +11 -0
  112. package/modules/bot-runtime/read-model.mjs +19 -4
  113. package/modules/bot-runtime/vendor/ds-bot/src/client/details-drawer.js +15 -5
  114. package/modules/bot-runtime/vendor/ds-bot/src/client/header.js +9 -2
  115. package/modules/bot-runtime/vendor/ds-bot/src/client/index.js +9 -2
  116. package/modules/bot-runtime/vendor/ds-bot/src/client/learning-card.js +2 -2
  117. package/modules/bot-runtime/vendor/ds-bot/src/client/model-menu.js +5 -3
  118. package/modules/bot-runtime/vendor/ds-bot/src/client/secret-card.js +3 -3
  119. package/modules/bot-runtime/vendor/ds-bot/src/client/sources.js +25 -2
  120. package/modules/bot-runtime/vendor/ds-bot/src/client/turn-error.js +3 -2
  121. package/modules/bot-runtime/vendor/ds-bot/src/host/channel.js +14 -2
  122. package/modules/bot-runtime/vendor/ds-bot/src/host/memory.js +1 -1
  123. package/modules/bot-runtime/vendor/ds-bot/src/host/models.js +7 -0
  124. package/modules/bot-runtime/vendor/ds-bot/src/host/parts.js +26 -9
  125. package/modules/bot-runtime/vendor/ds-bot/src/host/privacy.js +1 -1
  126. package/modules/bot-runtime/vendor/ds-bot/src/host/roster.js +112 -3
  127. package/modules/bot-runtime/vendor/ds-bot/src/host/store.js +34 -1
  128. package/modules/bot-runtime/vendor/ds-bot/src/host/usage.js +1 -1
  129. package/modules/independent-runtime/index.mjs +152 -62
  130. package/modules/independent-runtime/remote-tools.mjs +8 -5
  131. package/modules/openmaus-protocol/NOTICE +10 -0
  132. package/modules/openmaus-protocol/README.md +15 -0
  133. package/modules/openmaus-protocol/SOURCE.json +68 -0
  134. package/modules/openmaus-protocol/dist/protocol.mjs +1203 -0
  135. package/modules/openmaus-protocol/index.mjs +1 -0
  136. package/modules/openmaus-protocol/transport.mjs +77 -0
  137. package/modules/openmaus-protocol/vendor/LICENSE +202 -0
  138. package/modules/openmaus-protocol/vendor/companion/src/e2e/README.md +405 -0
  139. package/modules/openmaus-protocol/vendor/companion/src/e2e/credentials.ts +128 -0
  140. package/modules/openmaus-protocol/vendor/companion/src/e2e/descriptor.ts +84 -0
  141. package/modules/openmaus-protocol/vendor/companion/src/e2e/noise.ts +456 -0
  142. package/modules/openmaus-protocol/vendor/companion/src/e2e/phone.ts +104 -0
  143. package/modules/openmaus-protocol/vendor/companion/src/e2e/session.ts +417 -0
  144. package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.json +2565 -0
  145. package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.sha256 +1 -0
  146. package/modules/openmaus-protocol/vendor/companion/src/e2e/yamux.ts +335 -0
  147. package/package.json +20 -6
  148. package/packages/bot-acp/README.md +156 -0
  149. package/packages/bot-acp/licenses/LICENSE-OpenMausBot +202 -0
  150. package/packages/bot-acp/licenses/NOTICE.md +27 -0
  151. package/packages/bot-acp/src/acp-adapter.mjs +795 -0
  152. package/packages/bot-acp/src/acp-host.mjs +148 -0
  153. package/packages/bot-acp/src/native-launch.mjs +121 -0
  154. package/packages/bot-acp/src/permission-policy.mjs +283 -0
  155. package/packages/bot-acp/src/rpc-codec.mjs +265 -0
  156. package/packages/bot-acp/src/session-core.mjs +581 -0
  157. package/packages/bot-acp/src/stdio-rpc.mjs +329 -0
  158. package/packages/bot-engines/README.md +281 -0
  159. package/packages/bot-engines/licenses/LICENSE-OpenMausBot +202 -0
  160. package/packages/bot-engines/licenses/NOTICE.md +32 -0
  161. package/packages/bot-engines/src/claude-engine.mjs +772 -0
  162. package/packages/bot-engines/src/claude-llm-adapter.mjs +797 -0
  163. package/packages/bot-engines/src/claude-llm-turn.mjs +292 -0
  164. package/packages/bot-engines/src/claude-permission-broker.mjs +130 -0
  165. package/packages/bot-engines/src/claude-permission-proxy.mjs +87 -0
  166. package/packages/bot-engines/src/claude-protocol.mjs +177 -0
  167. package/packages/bot-engines/src/driver-catalog.mjs +334 -0
  168. package/packages/bot-engines/src/host-loop-request.mjs +95 -0
  169. package/packages/bot-engines/src/host-registrations.mjs +105 -0
  170. package/packages/capability-broker/src/cli-bootstrap.mjs +86 -16
  171. package/packages/capability-broker/src/cli-bridge-response.mjs +80 -0
  172. package/packages/capability-broker/src/cli-stdio-bridge.mjs +21 -8
  173. package/packages/cli-runtime/src/codex-session-core.mjs +4 -0
  174. package/packages/cli-runtime/src/native-cli-output.mjs +57 -0
  175. package/packages/cli-runtime/src/native-peer.ps1 +51 -3
  176. package/packages/cli-runtime/src/native-process.mjs +77 -14
  177. package/packages/model-router-plugin/README.md +3 -3
  178. package/packages/model-router-plugin/package.json +1 -1
  179. package/packages/orchestration/src/binding-product.mjs +19 -0
  180. package/packages/orchestration/src/bot-data-actions.mjs +45 -0
  181. package/packages/orchestration/src/bot-data-chart.mjs +109 -0
  182. package/packages/orchestration/src/bot-data-context.mjs +44 -0
  183. package/packages/orchestration/src/bot-data-engine.mjs +809 -0
  184. package/packages/orchestration/src/bot-data-io.mjs +186 -0
  185. package/packages/orchestration/src/bot-data-sql.mjs +195 -0
  186. package/packages/orchestration/src/bot-data-surface.mjs +144 -0
  187. package/packages/orchestration/src/bot-data-tools.mjs +68 -0
  188. package/packages/orchestration/src/bot-data-worker.mjs +228 -0
  189. package/packages/orchestration/src/bot-independent-execution.mjs +195 -0
  190. package/packages/orchestration/src/bot-independent-stream.mjs +74 -0
  191. package/packages/orchestration/src/bot-loop.mjs +152 -19
  192. package/packages/orchestration/src/bot-notifications.mjs +159 -0
  193. package/packages/orchestration/src/bot-policy.mjs +43 -4
  194. package/packages/orchestration/src/bot-profile.mjs +171 -0
  195. package/packages/orchestration/src/bot-routine.mjs +4 -4
  196. package/packages/orchestration/src/bot-runtime-assembly.mjs +255 -8
  197. package/packages/orchestration/src/bot-runtime-switch.mjs +13 -2
  198. package/packages/orchestration/src/bot-serving.mjs +164 -0
  199. package/packages/orchestration/src/bot-session-store.mjs +105 -9
  200. package/packages/orchestration/src/bot-settings.mjs +50 -11
  201. package/packages/orchestration/src/bot-thread-admission.mjs +84 -0
  202. package/packages/orchestration/src/bot-thread-host.mjs +187 -0
  203. package/packages/orchestration/src/bot-threads.mjs +790 -0
  204. package/packages/orchestration/src/data-product-access.mjs +37 -0
  205. package/packages/orchestration/src/data-tool-contract.mjs +291 -0
  206. package/packages/orchestration/src/desktop-http-carrier.mjs +120 -0
  207. package/packages/orchestration/src/desktop-proxy.mjs +311 -0
  208. package/packages/orchestration/src/desktop-sessions.mjs +43 -9
  209. package/packages/orchestration/src/ds-lifecycle.mjs +31 -4
  210. package/packages/orchestration/src/grant-refresh-queue.mjs +17 -0
  211. package/packages/orchestration/src/independent-chat-page.mjs +39 -0
  212. package/packages/orchestration/src/independent-chat-service.mjs +308 -0
  213. package/packages/orchestration/src/independent-context-state.mjs +44 -0
  214. package/packages/orchestration/src/independent-message-wire.mjs +84 -0
  215. package/packages/orchestration/src/independent-tool-catalog.mjs +175 -0
  216. package/packages/orchestration/src/independent-tool-families.mjs +73 -0
  217. package/packages/orchestration/src/independent-tool-host.mjs +344 -0
  218. package/packages/orchestration/src/main-bot-grant.mjs +15 -0
  219. package/packages/orchestration/src/main-model-routes.mjs +208 -0
  220. package/packages/orchestration/src/model-accounting.mjs +99 -35
  221. package/packages/orchestration/src/model-auxiliary-dispatch.mjs +187 -0
  222. package/packages/orchestration/src/model-loop-frame.mjs +179 -0
  223. package/packages/orchestration/src/model-prepared-generation.mjs +175 -0
  224. package/packages/orchestration/src/model-request-bounds.mjs +111 -0
  225. package/packages/orchestration/src/native-activity-contract.mjs +491 -0
  226. package/packages/orchestration/src/native-activity-service.mjs +102 -0
  227. package/packages/orchestration/src/native-activity-store.mjs +249 -0
  228. package/packages/orchestration/src/native-conversation-tools.mjs +78 -0
  229. package/packages/orchestration/src/native-data-tools.mjs +100 -0
  230. package/packages/orchestration/src/native-remote-actions.mjs +742 -0
  231. package/packages/orchestration/src/native-team-tools.mjs +73 -0
  232. package/packages/orchestration/src/pairing-invite.mjs +37 -0
  233. package/packages/orchestration/src/product-composition.mjs +1559 -1001
  234. package/packages/orchestration/src/remote-machines.mjs +82 -14
  235. package/packages/orchestration/src/routine-execution.mjs +5 -3
  236. package/packages/orchestration/src/routine-session.mjs +19 -13
  237. package/packages/orchestration/src/secure-pairing-host.mjs +112 -0
  238. package/packages/orchestration/src/task-journal.mjs +29 -0
  239. package/packages/orchestration/src/task-product.mjs +17 -0
  240. package/packages/orchestration/src/team-assignment-store.mjs +120 -0
  241. package/packages/orchestration/src/team-bridge.mjs +282 -0
  242. package/packages/orchestration/src/team-scheduler.mjs +83 -0
  243. package/packages/orchestration/src/team-sdk-tools.mjs +84 -0
  244. package/packages/orchestration/src/team-tool-contract.mjs +284 -0
  245. package/packages/orchestration/src/ui-host-namespace.mjs +23 -0
  246. package/packages/orchestration/src/web-panel-actions.mjs +103 -0
  247. package/packages/orchestration/src/web-panel-framing.mjs +227 -0
  248. package/packages/orchestration/src/web-panel-store.mjs +206 -0
  249. package/remote-computer-agent/README.md +39 -29
  250. package/remote-computer-agent/build.mjs +3 -0
  251. package/remote-computer-agent/dist/OPENMAUSBOT_LICENSE +202 -0
  252. package/remote-computer-agent/dist/OPENMAUSBOT_NOTICE +10 -0
  253. package/remote-computer-agent/dist/OPENMAUSBOT_SOURCE.json +68 -0
  254. package/remote-computer-agent/dist/cli-stdio-bridge.mjs +215 -25
  255. package/remote-computer-agent/dist/cli-stdio-bridge.mjs.map +4 -4
  256. package/remote-computer-agent/dist/cli.mjs +1688 -168
  257. package/remote-computer-agent/dist/cli.mjs.map +4 -4
  258. package/remote-computer-agent/dist/index.mjs +1579 -122
  259. package/remote-computer-agent/dist/index.mjs.map +4 -4
  260. package/remote-computer-agent/dist/native-peer.ps1 +51 -3
  261. package/remote-computer-agent/package-lock.json +308 -2
  262. package/remote-computer-agent/package.json +2 -1
  263. package/remote-computer-agent/pair-device.ps1 +20 -0
  264. package/remote-computer-agent/setup.ps1 +38 -16
  265. package/remote-computer-agent/src/cli.ts +24 -3
  266. package/remote-computer-agent/src/desktop-session.ts +11 -3
  267. package/remote-computer-agent/src/e2e-server.ts +81 -0
  268. package/remote-computer-agent/src/pairing-page.ts +35 -0
  269. package/remote-computer-agent/src/protocol.ts +25 -25
  270. package/remote-computer-agent/src/secure-pairing.ts +105 -0
  271. package/remote-computer-agent/src/store.ts +20 -5
  272. package/remote-computer-agent/src/transport.ts +39 -15
@@ -0,0 +1,790 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { dirname } from 'node:path'
4
+
5
+ // Per-Bot conversation threads: several independent contexts under one committed
6
+ // Bot, each with its own Host Session identity.
7
+ //
8
+ // What this module is: the durable thread catalog — create, name, switch,
9
+ // archive — plus the gate every send must pass before any policy decision. It
10
+ // holds no Agent objects and never drives a turn, so a retired Bot cannot be
11
+ // written through a stale handle here: identity is re-read from the committed
12
+ // lifecycle and re-verified against the Host on every call.
13
+ //
14
+ // Two owners, one fact each. The Host owns identity and authority: a thread
15
+ // Session is created through the product runtime, recorded in the Bot's
16
+ // committed state, compiled into ownership by the lifecycle, and only then is
17
+ // it sendable. This catalog owns the label, the selection and the request
18
+ // keys. Nothing is written twice, so the two can never drift into a state where
19
+ // a catalog entry claims an authority the Host does not confirm.
20
+ //
21
+ // What it is not: a second execution authority. Create/switch/rename/archive
22
+ // touch no model, send no greeting or instruction and start no Task. A Session
23
+ // is made lazily by `ensureSession` through an injected trusted facade, with
24
+ // its working directory taken from the committed Bot record and never from
25
+ // thread or client arguments. Every RPC-shaped entry point takes a closed set
26
+ // of fields: a thread parameter can carry no model, route, folder, credential,
27
+ // endpoint or authorization.
28
+ //
29
+ // State is one small JSON file per Host, written atomically. Concurrent
30
+ // processes are not supported, exactly like the sibling stores
31
+ // (bot-session-store.mjs, bot-routine-store.mjs): the Host composes one.
32
+
33
+ export const BOT_THREAD_SCHEMA = 2
34
+ export const THREAD_STATES = Object.freeze(['open', 'archived'])
35
+ export const SESSION_STATES = Object.freeze(['none', 'pending', 'unbound', 'owned', 'missing', 'unknown'])
36
+ export const MAX_THREADS_PER_BOT = 64
37
+ export const MAX_THREADS = 4096
38
+ export const MAX_NAME = 80
39
+ export const MAX_REQUEST_KEYS = 512
40
+ export const DEFAULT_THREAD_NAME = '新线程'
41
+
42
+ const fail = (code, message) => { const error = new Error(message); error.code = code; throw error }
43
+ const idOk = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value)
44
+ const keyOk = value => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,128}$/.test(value)
45
+ const finite = value => Number.isSafeInteger(value) && value >= 0
46
+ const isArchivedAt = value => value !== undefined && value !== null
47
+ const stateOf = record => (isArchivedAt(record.archivedAt) ? 'archived' : 'open')
48
+
49
+ /** A stored name is a label, never an instruction: bounded, trimmed, defaulted. */
50
+ const nameOf = value => {
51
+ if (value === undefined || value === null) return DEFAULT_THREAD_NAME
52
+ if (typeof value !== 'string') fail('INVALID_THREAD_NAME', '线程名称必须是字符串。')
53
+ return value.trim().slice(0, MAX_NAME) || DEFAULT_THREAD_NAME
54
+ }
55
+
56
+ /** An RPC-shaped call takes exactly its own fields; anything else is refused. */
57
+ const closed = (value, keys, code = 'INVALID_THREAD_REQUEST') => {
58
+ if (value === undefined || value === null) return {}
59
+ if (typeof value !== 'object' || Array.isArray(value)) fail(code, '请求格式无效。')
60
+ for (const key of Object.keys(value)) if (!keys.includes(key)) fail(code, `不支持的字段:${key}`)
61
+ return value
62
+ }
63
+
64
+ function parseThread(value, botId) {
65
+ if (!value || typeof value !== 'object' || Array.isArray(value)) fail('BOT_THREADS_INVALID')
66
+ for (const key of ['threadId', 'name', 'createdAt', 'updatedAt']) if (!(key in value)) fail('BOT_THREADS_INVALID')
67
+ if (!idOk(value.threadId)) fail('BOT_THREADS_INVALID')
68
+ if (typeof value.name !== 'string' || value.name.length > MAX_NAME) fail('BOT_THREADS_INVALID')
69
+ if (!finite(value.createdAt) || !finite(value.updatedAt)) fail('BOT_THREADS_INVALID')
70
+ if (value.archivedAt !== undefined && value.archivedAt !== null && !finite(value.archivedAt)) fail('BOT_THREADS_INVALID')
71
+ if (value.sessionId !== undefined && value.sessionId !== null && !idOk(value.sessionId)) fail('BOT_THREADS_INVALID')
72
+ if (!SESSION_STATES.includes(value.sessionState ?? 'none')) fail('BOT_THREADS_INVALID')
73
+ if (value.requestKey !== undefined && value.requestKey !== null && !keyOk(value.requestKey)) fail('BOT_THREADS_INVALID')
74
+ if (value.operationKey !== undefined && value.operationKey !== null && !keyOk(value.operationKey)) fail('BOT_THREADS_INVALID')
75
+ const sessionState = value.sessionId ? (value.sessionState ?? 'unbound') : 'none'
76
+ return {
77
+ threadId: value.threadId,
78
+ botId,
79
+ name: value.name,
80
+ sessionId: value.sessionId ?? null,
81
+ // A record with no Session reads as 'none' — except for a durable create
82
+ // intent: 'pending' with no Session yet is exactly the marker a restart
83
+ // needs in order to ask the Host what became of the operation.
84
+ sessionState: value.sessionId ? sessionState : (value.sessionState === 'pending' ? 'pending' : 'none'),
85
+ operationKey: value.operationKey ?? null,
86
+ createdAt: value.createdAt,
87
+ updatedAt: value.updatedAt,
88
+ archivedAt: value.archivedAt ?? null,
89
+ requestKey: value.requestKey ?? null,
90
+ }
91
+ }
92
+
93
+ /** Validate a saved catalog strictly: a newer or malformed file fails loudly. */
94
+ export function parseBotThreads(value) {
95
+ if (!value || typeof value !== 'object' || Array.isArray(value)) fail('BOT_THREADS_INVALID')
96
+ // A file that exists must declare its schema: silently resetting the catalog
97
+ // would lose every thread record, so only a missing file reads as an empty
98
+ // one (see readState).
99
+ if (value.schemaVersion === undefined) fail('BOT_THREADS_INVALID')
100
+ if (value.schemaVersion > BOT_THREAD_SCHEMA) fail('BOT_THREAD_SCHEMA_NEWER', '线程目录由更新版本写入,请先升级宿主。')
101
+ // Schema 1 carried no operation key; it loads as a legacy catalog.
102
+ if (value.schemaVersion !== BOT_THREAD_SCHEMA && value.schemaVersion !== 1) fail('BOT_THREAD_SCHEMA')
103
+ if (!Number.isSafeInteger(value.revision) || value.revision < 0) fail('BOT_THREADS_INVALID')
104
+ if (!value.bots || typeof value.bots !== 'object' || Array.isArray(value.bots)) fail('BOT_THREADS_INVALID')
105
+ const seenThreads = new Set(), seenSessions = new Map(), bots = {}
106
+ for (const [botId, entry] of Object.entries(value.bots)) {
107
+ if (!idOk(botId) || !entry || typeof entry !== 'object' || Array.isArray(entry)) fail('BOT_THREADS_INVALID')
108
+ if (!Array.isArray(entry.threads) || entry.threads.length > MAX_THREADS_PER_BOT) fail('BOT_THREADS_INVALID')
109
+ if (entry.activeThreadId !== undefined && entry.activeThreadId !== null && !idOk(entry.activeThreadId)) fail('BOT_THREADS_INVALID')
110
+ if (entry.requestKeys !== undefined && (!entry.requestKeys || typeof entry.requestKeys !== 'object')) fail('BOT_THREADS_INVALID')
111
+ const threads = entry.threads.map(thread => {
112
+ const record = parseThread(thread, botId)
113
+ // One identity per thread and one thread per Session, across every Bot:
114
+ // a duplicated claim is a corrupted catalog, never a silent merge.
115
+ if (seenThreads.has(record.threadId)) fail('AMBIGUOUS_THREAD_OWNER')
116
+ seenThreads.add(record.threadId)
117
+ if (record.sessionId) {
118
+ if (seenSessions.has(record.sessionId)) fail('AMBIGUOUS_THREAD_SESSION')
119
+ seenSessions.set(record.sessionId, botId)
120
+ }
121
+ return record
122
+ })
123
+ if (seenThreads.size > MAX_THREADS) fail('BOT_THREADS_LIMIT')
124
+ bots[botId] = { activeThreadId: entry.activeThreadId ?? null, threads, requestKeys: { ...(entry.requestKeys ?? {}) } }
125
+ }
126
+ return { schemaVersion: BOT_THREAD_SCHEMA, revision: value.revision, bots }
127
+ }
128
+
129
+ function readState(file) {
130
+ let text
131
+ try { text = readFileSync(file, 'utf8') } catch (error) {
132
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return { schemaVersion: BOT_THREAD_SCHEMA, revision: 0, bots: {} }
133
+ throw error
134
+ }
135
+ let value
136
+ try { value = JSON.parse(text) } catch { fail('BOT_THREADS_INVALID', '线程目录文件损坏。') }
137
+ return parseBotThreads(value)
138
+ }
139
+
140
+ /**
141
+ * The thread catalog for every committed Bot.
142
+ *
143
+ * Injected facades are trusted host code, mirroring the composition seams the
144
+ * product already uses (lifecycle, principal directory, authority, runtime):
145
+ * committedBot(botId) -> saved Bot record | null (lifecycle.committedBot)
146
+ * lifecycleStatus() -> { healthy, closed, … } | null (lifecycle.status)
147
+ * botAuthorized(botId) -> true | false | null (authority probe)
148
+ * ownsSession(botId, sessionId) -> boolean (principal directory)
149
+ * committedThreads(botId) -> [{threadId, sessionId, archivedAt}] (Host's own record)
150
+ * createSession({botId, threadId, title, dir}) -> sessionId (runtime factory + Host record)
151
+ * setThreadArchived({botId, threadId, archivedAt}) -> record | null (Host record)
152
+ * renameThread({botId, threadId, name}) -> record | null (Host thread title)
153
+ * inspectSession(sessionId) -> boolean | null (optional, Host inspect)
154
+ * runtimeMode(botId) -> 'integration' | 'independent' | null
155
+ */
156
+ export function createBotThreads({
157
+ file,
158
+ committedBot,
159
+ lifecycleStatus = null,
160
+ botAuthorized = null,
161
+ ownsSession = null,
162
+ committedThreads = null,
163
+ createSession = null,
164
+ setThreadArchived = null,
165
+ renameThread = null,
166
+ inspectSession = null,
167
+ runtimeMode = null,
168
+ now = Date.now,
169
+ } = {}) {
170
+ if (typeof file !== 'string' || file === '') throw new TypeError('BOT_THREADS_FILE_REQUIRED')
171
+ if (typeof committedBot !== 'function') throw new TypeError('BOT_THREADS_LIFECYCLE_REQUIRED')
172
+ for (const [name, facade] of [['lifecycleStatus', lifecycleStatus], ['botAuthorized', botAuthorized], ['ownsSession', ownsSession], ['committedThreads', committedThreads], ['createSession', createSession], ['setThreadArchived', setThreadArchived], ['renameThread', renameThread], ['inspectSession', inspectSession], ['runtimeMode', runtimeMode]]) {
173
+ if (facade !== null && typeof facade !== 'function') throw new TypeError(`BOT_THREADS_${name.replace(/[A-Z]/g, letter => `_${letter}`).toUpperCase()}_INVALID`)
174
+ }
175
+ let state = readState(file)
176
+ let closed_ = false
177
+ let storageFailed = false
178
+ let durableState = structuredClone(state)
179
+ const assertCatalog = () => {
180
+ if (closed_) fail('BOT_THREADS_CLOSED', '线程目录已关闭。')
181
+ if (storageFailed) fail('BOT_THREADS_STORAGE_FAILED', '线程目录无法读取,暂不能确认会话归属。请修复存储后重启宿主。')
182
+ }
183
+ // Preserve existing record identities: an admitted Session factory may be
184
+ // awaiting its Host while a different local catalog commit fails.
185
+ const restore = snapshot => {
186
+ state.revision = snapshot.revision
187
+ for (const botId of Object.keys(state.bots)) if (!snapshot.bots[botId]) delete state.bots[botId]
188
+ for (const [botId, saved] of Object.entries(snapshot.bots)) {
189
+ const entry = state.bots[botId] ??= { threads: [], requestKeys: {}, activeThreadId: null }
190
+ const records = new Map(entry.threads.map(row => [row.threadId, row]))
191
+ entry.threads = saved.threads.map(row => {
192
+ const record = records.get(row.threadId) ?? {}
193
+ for (const key of Object.keys(record)) if (!Object.hasOwn(row, key)) delete record[key]
194
+ return Object.assign(record, structuredClone(row))
195
+ })
196
+ entry.requestKeys = { ...saved.requestKeys }
197
+ entry.activeThreadId = saved.activeThreadId
198
+ }
199
+ }
200
+ // One create per thread at a time. Two callers asking for the same thread's
201
+ // Session share one outcome, so a double click can never make two Sessions.
202
+ const flights = new Map()
203
+
204
+ // An atomic replace: the bytes are written beside the file and moved over it,
205
+ // so a reader never sees a half-written catalog. On Windows the move can meet
206
+ // a momentary sharing violation (an indexer or a scanner touching the file it
207
+ // is replacing); the content is already complete at that point, so a short
208
+ // bounded retry finishes the same write instead of failing a thread write the
209
+ // Host had already confirmed. This is a retry of one local commit — never a
210
+ // replay of anything the Host did.
211
+ const replace = (from, to) => {
212
+ for (let attempt = 0; ; attempt += 1) {
213
+ try { renameSync(from, to); return } catch (error) {
214
+ if (attempt >= 4 || (error?.code !== 'EPERM' && error?.code !== 'EBUSY')) throw error
215
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2 * (attempt + 1))
216
+ }
217
+ }
218
+ }
219
+
220
+ const save = () => {
221
+ assertCatalog()
222
+ try {
223
+ state.revision += 1
224
+ mkdirSync(dirname(file), { recursive: true })
225
+ const temp = `${file}.${process.pid}.tmp`
226
+ writeFileSync(temp, JSON.stringify(state))
227
+ replace(temp, file)
228
+ durableState = structuredClone(state)
229
+ } catch (error) {
230
+ restore(durableState)
231
+ try {
232
+ const disk = readState(file)
233
+ if (disk.revision < durableState.revision) throw new Error('CATALOG_ROLLED_BACK')
234
+ restore(disk)
235
+ durableState = structuredClone(state)
236
+ } catch {
237
+ // Never turn an unreadable catalog into an "unbound" Session. Its
238
+ // historical identities may be the only thing preventing a Bot bypass.
239
+ storageFailed = true
240
+ }
241
+ throw error
242
+ }
243
+ }
244
+ const entryOf = botId => state.bots[botId]
245
+ const ensureEntry = botId => {
246
+ if (!state.bots[botId]) state.bots[botId] = { activeThreadId: null, threads: [], requestKeys: {} }
247
+ return state.bots[botId]
248
+ }
249
+ const publicThread = record => ({
250
+ threadId: record.threadId,
251
+ botId: record.botId,
252
+ name: record.name,
253
+ state: stateOf(record),
254
+ archivedAt: record.archivedAt,
255
+ sessionId: record.sessionId,
256
+ // A hint for the interface only: authority is decided by the live Host
257
+ // check in assertSendable, never by this field. A durable create intent
258
+ // shows as 'pending' before the Session exists, and an outcome the Host
259
+ // cannot confirm as 'unknown'; both can outlive a Session-less record.
260
+ sessionState: record.sessionId ? record.sessionState
261
+ : (record.sessionState === 'pending' || record.sessionState === 'unknown' ? record.sessionState : 'none'),
262
+ createdAt: record.createdAt,
263
+ updatedAt: record.updatedAt,
264
+ active: (entryOf(record.botId)?.activeThreadId ?? null) === record.threadId,
265
+ })
266
+ const located = threadId => {
267
+ for (const [botId, entry] of Object.entries(state.bots)) {
268
+ const record = entry.threads.find(thread => thread.threadId === threadId)
269
+ if (record) return { botId, entry, record }
270
+ }
271
+ return null
272
+ }
273
+ const sessionHolder = sessionId => {
274
+ for (const [botId, entry] of Object.entries(state.bots)) {
275
+ const record = entry.threads.find(thread => thread.sessionId === sessionId)
276
+ if (record) return { botId, entry, record }
277
+ }
278
+ return null
279
+ }
280
+ const hostThreads = botId => (typeof committedThreads === 'function' ? committedThreads(botId) : null) ?? []
281
+
282
+ // Fail closed on every host-side doubt: an unhealthy lifecycle has already
283
+ // emptied every grant (suspend()), and an unknown Bot is a deleted or
284
+ // migrated-away one. Neither may accept a thread write.
285
+ const assertHealthy = () => {
286
+ assertCatalog()
287
+ if (typeof lifecycleStatus !== 'function') return
288
+ const status = lifecycleStatus()
289
+ if (status && (status.healthy === false || status.closed === true)) {
290
+ fail('LIFECYCLE_UNHEALTHY', '宿主生命周期未就绪,线程暂不可写入。')
291
+ }
292
+ }
293
+ const assertBot = botId => {
294
+ if (!idOk(botId)) fail('INVALID_BOT_ID', 'Bot 标识无效。')
295
+ const bot = committedBot(botId)
296
+ if (!bot) fail('BOT_NOT_COMMITTED', '该 Bot 不在已提交状态:可能已停用、删除或迁往其他运行时。')
297
+ return bot
298
+ }
299
+ // A suspended or disabled Bot has an empty committed grant: the Host probe
300
+ // says so, and an unanswerable probe is refused rather than assumed. The
301
+ // grant is the only authority a thread turn inherits, so a doubt here must
302
+ // stop the send.
303
+ const assertGrant = botId => {
304
+ if (typeof botAuthorized !== 'function') return { verified: false }
305
+ const verdict = botAuthorized(botId)
306
+ if (verdict === null || verdict === undefined) fail('BOT_GRANT_UNKNOWN', '无法核实该 Bot 的已提交授权,线程暂不可写入。')
307
+ if (verdict === false) fail('BOT_GRANT_EMPTY', '该 Bot 的授权已被撤销或停用,线程暂不可写入。')
308
+ return { verified: true }
309
+ }
310
+ // A Bot is served by exactly one runtime. A Bot moved to the independent
311
+ // loop must not also gain a Host Session here: two writers over one Bot is
312
+ // the failure mode the runtime switch exists to prevent.
313
+ const assertServing = botId => {
314
+ if (typeof runtimeMode !== 'function') return
315
+ const mode = runtimeMode(botId)
316
+ if (mode !== undefined && mode !== null && mode !== 'integration') {
317
+ fail('RUNTIME_MODE_MISMATCH', `运行时已切换:${botId} 当前由 ${mode} 提供服务,集成路径拒绝为其建立线程会话。`)
318
+ }
319
+ }
320
+ // The Host's own record of a thread, or null when the Host has none. The
321
+ // Host is the authority for identity: this catalog only mirrors it.
322
+ const hostThread = (botId, threadId) => hostThreads(botId).find(thread => thread.threadId === threadId) ?? null
323
+ // Live ownership, asked of the Host every time. A revoked mapping can never
324
+ // be used just because this catalog still says 'owned'.
325
+ const hostOwns = (botId, sessionId) => typeof ownsSession === 'function' && ownsSession(botId, sessionId) === true
326
+ // Cross-Bot is answered by ownership lookup, never by a caller-supplied pair:
327
+ // a thread id that lives under another Bot is refused by name.
328
+ const resolveThread = (botId, threadId) => {
329
+ if (!idOk(threadId)) fail('INVALID_THREAD_ID', '线程标识无效。')
330
+ const found = located(threadId)
331
+ if (!found) fail('THREAD_NOT_FOUND', '没有这个线程。')
332
+ if (found.botId !== botId) fail('THREAD_FOREIGN_BOT', '该线程属于另一个 Bot,不能在此使用。')
333
+ return found
334
+ }
335
+ const rememberRequestKey = (entry, requestKey, threadId) => {
336
+ if (!requestKey) return
337
+ entry.requestKeys[requestKey] = threadId
338
+ const keys = Object.keys(entry.requestKeys)
339
+ while (keys.length > MAX_REQUEST_KEYS) delete entry.requestKeys[keys.shift()]
340
+ }
341
+ const nextOpenThread = entry => entry.threads
342
+ .filter(thread => stateOf(thread) === 'open')
343
+ .sort((left, right) => right.updatedAt - left.updatedAt)[0] ?? null
344
+
345
+ const list = botId => {
346
+ assertCatalog()
347
+ if (!idOk(botId)) fail('INVALID_BOT_ID', 'Bot 标识无效。')
348
+ const entry = entryOf(botId)
349
+ if (!entry) return { botId, activeThreadId: null, threads: [] }
350
+ const threads = entry.threads.map(publicThread)
351
+ .sort((left, right) => {
352
+ if (left.active !== right.active) return left.active ? -1 : 1
353
+ if (left.state !== right.state) return left.state === 'open' ? -1 : 1
354
+ return right.updatedAt - left.updatedAt
355
+ })
356
+ return { botId, activeThreadId: entry.activeThreadId, threads }
357
+ }
358
+
359
+ // Creating a thread is a local catalog write. It creates no Session, calls
360
+ // no model, sends no greeting and starts no Task; the new thread only becomes
361
+ // the selection, which is why a duplicate requestKey is answered with the
362
+ // thread it already made instead of a second one.
363
+ const create = (input = {}) => {
364
+ const { botId, name, requestKey } = closed(input, ['botId', 'name', 'requestKey'])
365
+ assertHealthy()
366
+ assertBot(botId)
367
+ if (requestKey !== undefined && requestKey !== null && !keyOk(requestKey)) fail('INVALID_REQUEST_KEY', '请求键格式无效。')
368
+ const entry = ensureEntry(botId)
369
+ if (requestKey && entry.requestKeys[requestKey]) {
370
+ const record = entry.threads.find(thread => thread.threadId === entry.requestKeys[requestKey])
371
+ if (record) return { thread: publicThread(record), created: false }
372
+ }
373
+ if (entry.threads.length >= MAX_THREADS_PER_BOT) fail('THREAD_LIMIT', `每个 Bot 最多 ${MAX_THREADS_PER_BOT} 条线程,请先归档。`)
374
+ if (Object.keys(state.bots).reduce((total, key) => total + state.bots[key].threads.length, 0) >= MAX_THREADS) fail('THREAD_LIMIT', '线程总数已达上限。')
375
+ const at = now()
376
+ const record = {
377
+ threadId: randomUUID(),
378
+ botId,
379
+ name: nameOf(name),
380
+ sessionId: null,
381
+ sessionState: 'none',
382
+ operationKey: null,
383
+ createdAt: at,
384
+ updatedAt: at,
385
+ archivedAt: null,
386
+ requestKey: requestKey ?? null,
387
+ }
388
+ entry.threads.push(record)
389
+ rememberRequestKey(entry, requestKey ?? null, record.threadId)
390
+ entry.activeThreadId = record.threadId
391
+ save()
392
+ return { thread: publicThread(record), created: true }
393
+ }
394
+
395
+ // A thread's title is the Host's fact, exactly like its Session identity: the
396
+ // write lands in the committed state first, and only a reply that confirms
397
+ // the name asked for moves this catalog. A lost reply, or one that confirms a
398
+ // different title, leaves the old name standing — a rename that was never
399
+ // confirmed must never read as a rename that succeeded.
400
+ const rename = async (input = {}) => {
401
+ const { botId, threadId, name } = closed(input, ['botId', 'threadId', 'name'])
402
+ assertHealthy()
403
+ assertBot(botId)
404
+ const { record } = resolveThread(botId, threadId)
405
+ const next = nameOf(name)
406
+ if (typeof renameThread === 'function') {
407
+ let written
408
+ try {
409
+ written = await renameThread({ botId, threadId, name: next })
410
+ } catch (error) {
411
+ // A thread the Host has no record for has no Host title to write: its
412
+ // name travels with the record when the Session is made. Anything else
413
+ // the Host refuses is a refusal, not a rename.
414
+ if (error?.code !== 'BOT_THREAD_UNKNOWN') throw error
415
+ written = null
416
+ }
417
+ if (written !== null && (typeof written.name !== 'string' || written.name !== next)) {
418
+ fail('THREAD_RENAME_UNCONFIRMED', '宿主没有确认这次重命名,线程名称保持不变。')
419
+ }
420
+ }
421
+ record.name = next
422
+ record.updatedAt = now()
423
+ save()
424
+ return publicThread(record)
425
+ }
426
+
427
+ // Switching only moves the selection: no Session is created or touched, no
428
+ // model is called, no Task starts. An archived thread may be selected — it
429
+ // stays archived until the owner says otherwise — so a Bot always has a
430
+ // well-defined active thread.
431
+ const switchThread = (input = {}) => {
432
+ const { botId, threadId } = closed(input, ['botId', 'threadId'])
433
+ assertHealthy()
434
+ assertBot(botId)
435
+ const { entry } = resolveThread(botId, threadId)
436
+ entry.activeThreadId = threadId
437
+ save()
438
+ return { botId, activeThreadId: threadId }
439
+ }
440
+
441
+ // Archive hides, it never deletes: the Session identity is kept so the thread
442
+ // can be restored with its context intact. The Host records the archive in
443
+ // the same breath, because dropping a thread's authority is the Host's fact
444
+ // to own; only after the Host confirms does this catalog follow.
445
+ const archive = async (input = {}) => {
446
+ const { botId, threadId } = closed(input, ['botId', 'threadId'])
447
+ assertHealthy()
448
+ assertBot(botId)
449
+ const { entry, record } = resolveThread(botId, threadId)
450
+ if (stateOf(record) === 'archived') return { thread: publicThread(record), activeThreadId: entry.activeThreadId, changed: false }
451
+ const at = now()
452
+ if (typeof setThreadArchived === 'function') await setThreadArchived({ botId, threadId, archivedAt: at })
453
+ record.archivedAt = at
454
+ record.updatedAt = at
455
+ if (entry.activeThreadId === threadId) {
456
+ const next = nextOpenThread(entry)
457
+ entry.activeThreadId = next ? next.threadId : null
458
+ }
459
+ save()
460
+ return { thread: publicThread(record), activeThreadId: entry.activeThreadId, changed: true }
461
+ }
462
+
463
+ const unarchive = async (input = {}) => {
464
+ const { botId, threadId } = closed(input, ['botId', 'threadId'])
465
+ assertHealthy()
466
+ assertBot(botId)
467
+ const { record } = resolveThread(botId, threadId)
468
+ if (stateOf(record) === 'open') return publicThread(record)
469
+ if (typeof setThreadArchived === 'function') await setThreadArchived({ botId, threadId, archivedAt: null })
470
+ record.archivedAt = null
471
+ record.updatedAt = now()
472
+ save()
473
+ return publicThread(record)
474
+ }
475
+
476
+ const resolve = (input = {}) => {
477
+ const { botId, threadId } = closed(input, ['botId', 'threadId'])
478
+ assertCatalog()
479
+ if (!idOk(botId)) fail('INVALID_BOT_ID', 'Bot 标识无效。')
480
+ return publicThread(resolveThread(botId, threadId).record)
481
+ }
482
+
483
+ const threadBySession = sessionId => {
484
+ assertCatalog()
485
+ if (!idOk(sessionId)) return null
486
+ const found = sessionHolder(sessionId)
487
+ return found ? { botId: found.botId, threadId: found.record.threadId } : null
488
+ }
489
+
490
+ // The pre-policy gate. Everything a send needs is decided here, before any
491
+ // model or tool call: the Bot is committed and healthy, its grant still
492
+ // stands, the thread is open, and the Host itself confirms — right now, not
493
+ // from a cached flag — that this Session belongs to this Bot.
494
+ //
495
+ // It is written once, as a probe that answers with a result, because the
496
+ // shape a caller must branch on is the one that used to be unreachable: a
497
+ // caller that read a field after the throwing form had already refused never
498
+ // learned why. `assertSendable` throws the very same answer, so the two can
499
+ // never drift apart.
500
+ const probeSendable = (input = {}) => {
501
+ const { botId, threadId } = closed(input, ['botId', 'threadId'])
502
+ const refuse = (code, message, sessionState = null) => ({ ok: false, botId, threadId, code, message, sessionState })
503
+ try {
504
+ assertHealthy()
505
+ const bot = assertBot(botId)
506
+ assertServing(botId)
507
+ const { record } = resolveThread(botId, threadId)
508
+ if (stateOf(record) === 'archived') return refuse('THREAD_ARCHIVED', '该线程已归档,恢复后再继续对话。', record.sessionState)
509
+ const grant = assertGrant(botId)
510
+ // An operation whose outcome the Host never confirmed is unknown, whether or
511
+ // not a Session id was ever written down: it is answered before anything
512
+ // else about the Session, and it is never answered by making a new one.
513
+ if (record.sessionState === 'pending' || record.sessionState === 'unknown') {
514
+ return refuse('SESSION_UNKNOWN', '该线程的会话建立结果未知,请先由所有者核对恢复。', record.sessionState)
515
+ }
516
+ if (!record.sessionId) return refuse('SESSION_MISSING', '该线程尚未建立会话,请稍后重试。', record.sessionState)
517
+ if (record.sessionState === 'missing') return refuse('SESSION_MISSING', '该线程的宿主会话已不存在,需要重新建立。', record.sessionState)
518
+ if (!hostOwns(botId, record.sessionId)) {
519
+ // The Host withdrew or never confirmed this mapping: the catalog follows
520
+ // the Host at once, and the send is refused rather than allowed on a
521
+ // revoked identity.
522
+ record.sessionState = record.sessionId ? 'unbound' : 'none'
523
+ save()
524
+ return refuse('SESSION_NOT_OWNED', '宿主未确认该线程会话的归属,线程暂不可写入。', record.sessionState)
525
+ }
526
+ if (record.sessionState !== 'owned') { record.sessionState = 'owned'; save() }
527
+ return { ok: true, botId, threadId, sessionId: record.sessionId, sessionState: record.sessionState, bot, grantVerified: grant.verified }
528
+ } catch (error) {
529
+ return refuse(error?.code ?? 'THREAD_CHECK_FAILED', error?.message ?? String(error))
530
+ }
531
+ }
532
+
533
+ const assertSendable = (input = {}) => {
534
+ const probe = probeSendable(input)
535
+ if (probe.ok) return probe
536
+ const error = new Error(probe.message)
537
+ error.code = probe.code
538
+ throw error
539
+ }
540
+
541
+ // Where a thread's Session comes from, and how an interrupted create is
542
+ // answered. The durable 'pending' marker is written before the factory runs,
543
+ // so a crash between "Session made" and "record saved" is visible on the next
544
+ // call: the Host's own committed record is consulted, an identity it confirms
545
+ // is adopted, and an identity it cannot confirm stays 'unknown' — never
546
+ // silently replayed, because a second Session would orphan the first.
547
+ const resolvePending = (botId, record) => {
548
+ const host = hostThread(botId, record.threadId)
549
+ if (host?.sessionId) {
550
+ const holder = sessionHolder(host.sessionId)
551
+ if (holder && holder.record.threadId !== record.threadId) fail('THREAD_SESSION_CONFLICT', '该会话已绑定到另一条线程。')
552
+ record.sessionId = host.sessionId
553
+ record.operationKey = null
554
+ record.sessionState = hostOwns(botId, host.sessionId) ? 'owned' : 'unbound'
555
+ save()
556
+ return record.sessionState
557
+ }
558
+ record.sessionState = 'unknown'
559
+ save()
560
+ return 'unknown'
561
+ }
562
+
563
+ const runEnsure = async (botId, record, { recover }) => {
564
+ assertHealthy()
565
+ assertBot(botId)
566
+ assertServing(botId)
567
+ if (stateOf(record) === 'archived') fail('THREAD_ARCHIVED', '该线程已归档,恢复后再继续对话。')
568
+ assertGrant(botId)
569
+ if (typeof createSession !== 'function') fail('SESSION_FACTORY_REQUIRED', '宿主未提供会话工厂,线程暂无法建立会话。')
570
+ if (typeof ownsSession !== 'function') fail('SESSION_OWNERSHIP_UNVERIFIABLE', '宿主未提供会话归属核实,线程暂无法建立会话。')
571
+ if (record.sessionId && record.sessionState === 'owned' && hostOwns(botId, record.sessionId)) {
572
+ return { sessionId: record.sessionId, state: 'owned', created: false }
573
+ }
574
+ if (record.sessionId && record.sessionState === 'unbound') {
575
+ // A Session the Host has not confirmed is kept, never duplicated: making
576
+ // a second one would orphan the first and hide the missing hook.
577
+ if (hostOwns(botId, record.sessionId)) {
578
+ record.sessionState = 'owned'
579
+ save()
580
+ return { sessionId: record.sessionId, state: 'owned', created: false }
581
+ }
582
+ fail('SESSION_NOT_OWNED', '宿主未确认该线程会话的归属:请先在 Host 记录该线程会话后重试。')
583
+ }
584
+ if (record.sessionState === 'pending') {
585
+ // A previous run got as far as the factory and its outcome was never
586
+ // recorded. The Host's committed record decides, not a guess. The marker
587
+ // stands whether or not a Session id was ever written down.
588
+ const settled = resolvePending(botId, record)
589
+ if (settled === 'owned') return { sessionId: record.sessionId, state: 'owned', created: false }
590
+ if (settled === 'unbound') fail('SESSION_NOT_OWNED', '宿主未确认该线程会话的归属:请先在 Host 记录该线程会话后重试。')
591
+ if (recover !== true) fail('SESSION_UNKNOWN', '该线程上一次建立会话的结果未知:可能已创建但未能记录。请核对后由所有者确认恢复,不要自动重放。')
592
+ }
593
+ if (record.sessionState === 'unknown' && recover !== true) {
594
+ fail('SESSION_UNKNOWN', '该线程上一次建立会话的结果未知:可能已创建但未能记录。请核对后由所有者确认恢复,不要自动重放。')
595
+ }
596
+ if (record.sessionId && record.sessionState === 'missing') {
597
+ // The Host no longer has this Session, so a fresh one is the only answer.
598
+ record.sessionId = null
599
+ record.sessionState = 'none'
600
+ record.operationKey = null
601
+ save()
602
+ }
603
+ const bot = committedBot(botId)
604
+ // The durable intent comes first: whatever happens next, the next call
605
+ // finds this thread mid-operation and resolves it from the Host's record.
606
+ const operationKey = randomUUID()
607
+ record.operationKey = operationKey
608
+ record.sessionState = 'pending'
609
+ save()
610
+ let sessionId
611
+ try {
612
+ sessionId = await createSession({ botId, threadId: record.threadId, title: record.name, dir: typeof bot?.workDir === 'string' ? bot.workDir : undefined, operationKey })
613
+ } catch (error) {
614
+ // A trusted refusal that names no Session (read-only host, unknown Bot)
615
+ // clears the intent: nothing was made. Any other outcome is unknown.
616
+ if (error?.outcome === 'refused') {
617
+ record.sessionState = record.sessionId ? 'unbound' : 'none'
618
+ record.operationKey = null
619
+ save()
620
+ }
621
+ throw error
622
+ }
623
+ if (!idOk(sessionId)) fail('SESSION_INVALID', '宿主返回的会话标识无效。')
624
+ const holder = sessionHolder(sessionId)
625
+ if (holder) fail('THREAD_SESSION_CONFLICT', '该会话已绑定到另一条线程。')
626
+ record.sessionId = sessionId
627
+ record.operationKey = null
628
+ record.sessionState = 'none'
629
+ save()
630
+ if (!hostOwns(botId, sessionId)) {
631
+ record.sessionState = 'unbound'
632
+ save()
633
+ fail('SESSION_NOT_OWNED', '宿主未确认该线程会话的归属:请先在 Host 记录该线程会话后重试。')
634
+ }
635
+ record.sessionState = 'owned'
636
+ save()
637
+ return { sessionId, state: 'owned', created: true }
638
+ }
639
+
640
+ const ensureSession = (input = {}) => {
641
+ const { botId, threadId, recover } = closed(input, ['botId', 'threadId', 'recover'])
642
+ if (closed_) fail('BOT_THREADS_CLOSED', '线程目录已关闭。')
643
+ if (!idOk(botId) || !idOk(threadId)) fail('INVALID_THREAD_ID', '线程标识无效。')
644
+ const { record } = resolveThread(botId, threadId)
645
+ const flight = `${botId}\u0000${threadId}`
646
+ const running = flights.get(flight)
647
+ // Single flight: the second caller waits for the first outcome instead of
648
+ // making a Session of its own.
649
+ if (running) return running
650
+ const work = runEnsure(botId, record, { recover: recover === true }).finally(() => flights.delete(flight))
651
+ flights.set(flight, work)
652
+ return work
653
+ }
654
+
655
+ // The navigation contract. Opening a thread moves the selection and reports
656
+ // the real Session identity; it delivers nothing. With `create` the caller
657
+ // explicitly asks for the Session to exist (still no model call), which is
658
+ // the only path that makes one.
659
+ const openThread = (input = {}) => {
660
+ const { botId, threadId, create } = closed(input, ['botId', 'threadId', 'create'])
661
+ assertHealthy()
662
+ assertBot(botId)
663
+ assertServing(botId)
664
+ const { entry, record } = resolveThread(botId, threadId)
665
+ entry.activeThreadId = threadId
666
+ save()
667
+ if (create !== true) {
668
+ return { botId, threadId, activeThreadId: threadId, sessionId: record.sessionId, sessionState: record.sessionId ? record.sessionState : 'none', created: false, needsSession: record.sessionId === null }
669
+ }
670
+ return ensureSession({ botId, threadId }).then(result => ({
671
+ botId, threadId, activeThreadId: threadId, sessionId: result.sessionId, sessionState: 'owned', created: result.created, needsSession: false,
672
+ }))
673
+ }
674
+
675
+ // Re-read the Host's own view after a restart or a grant refresh. A Session
676
+ // the Host no longer has is reported, never dropped: archive hides threads,
677
+ // and only an explicit delete removes a real conversation. A create that was
678
+ // interrupted is settled here — adopted when the Host confirms it, otherwise
679
+ // left 'unknown' for an owner to review.
680
+ const reconcile = () => {
681
+ if (closed_) fail('BOT_THREADS_CLOSED', '线程目录已关闭。')
682
+ const result = { checked: 0, owned: 0, unbound: 0, missing: 0, unknown: 0, adopted: 0, pending: 0 }
683
+ for (const [botId, entry] of Object.entries(state.bots)) {
684
+ for (const record of entry.threads) {
685
+ const host = hostThread(botId, record.threadId)
686
+ if (host === null && record.sessionId === null && record.sessionState === 'none') continue
687
+ result.checked += 1
688
+ if (record.sessionState === 'pending') {
689
+ resolvePending(botId, record)
690
+ result.pending += 1
691
+ }
692
+ if (host !== null && host.sessionId && record.sessionId !== host.sessionId) {
693
+ record.sessionId = host.sessionId
694
+ result.adopted += 1
695
+ }
696
+ // The Host's archive is the authority: a thread it archived is archived.
697
+ if (host !== null && isArchivedAt(host.archivedAt) && !isArchivedAt(record.archivedAt)) {
698
+ record.archivedAt = host.archivedAt
699
+ if (entry.activeThreadId === record.threadId) entry.activeThreadId = nextOpenThread(entry)?.threadId ?? null
700
+ }
701
+ if (!record.sessionId) continue
702
+ if (hostOwns(botId, record.sessionId)) { record.sessionState = 'owned'; result.owned += 1; continue }
703
+ const exists = typeof inspectSession === 'function' ? inspectSession(record.sessionId) : null
704
+ record.sessionState = exists === false ? 'missing' : (record.sessionState === 'unknown' ? 'unknown' : 'unbound')
705
+ result[record.sessionState] += 1
706
+ }
707
+ }
708
+ if (result.checked > 0) save()
709
+ return result
710
+ }
711
+
712
+ // The owner's explicit recovery for a thread whose create outcome is unknown.
713
+ // It adopts the identity the Host confirms, and only when the Host confirms
714
+ // nothing does it make a fresh Session — as a recorded, reviewable act.
715
+ const recover = (input = {}) => {
716
+ const { botId, threadId } = closed(input, ['botId', 'threadId'])
717
+ return ensureSession({ botId, threadId, recover: true })
718
+ }
719
+
720
+ // The Bot-delete path calls this after the Host has retired the Bot. It
721
+ // refuses while the Bot is still committed, so a live Bot can never lose its
722
+ // threads to a stray call.
723
+ const purgeBot = botId => {
724
+ assertHealthy()
725
+ if (!idOk(botId)) fail('INVALID_BOT_ID', 'Bot 标识无效。')
726
+ if (committedBot(botId)) fail('BOT_STILL_COMMITTED', '该 Bot 仍在已提交状态,不能清除其线程。')
727
+ if (!state.bots[botId]) return { botId, removed: 0 }
728
+ const removed = state.bots[botId].threads.length
729
+ delete state.bots[botId]
730
+ save()
731
+ return { botId, removed }
732
+ }
733
+
734
+ const readModel = (input = {}) => {
735
+ const { botId } = closed(input, ['botId'])
736
+ if (closed_) return { status: 'blocked', reason: '线程目录已关闭。', nextAction: '重启宿主后重试。', items: [], activeThreadId: null }
737
+ if (storageFailed) return { status: 'blocked', reason: '线程目录无法读取,暂不能确认会话归属。', nextAction: '修复存储后重启宿主;不会自动重放会话创建。', items: [], activeThreadId: null }
738
+ if (!idOk(botId)) return { status: 'blocked', reason: 'Bot 标识无效。', nextAction: '从 Bot 列表重新进入。', items: [], activeThreadId: null }
739
+ const status = typeof lifecycleStatus === 'function' ? lifecycleStatus() : null
740
+ if (status && (status.healthy === false || status.closed === true)) {
741
+ return { status: 'blocked', reason: '宿主生命周期未就绪,线程暂时不可用。', nextAction: '修复宿主状态后刷新;线程目录已保留。', items: [], activeThreadId: null }
742
+ }
743
+ const bot = committedBot(botId)
744
+ if (!bot) {
745
+ return { status: 'blocked', reason: '该 Bot 已不在已提交状态。', nextAction: '停用或删除 Bot 后,其线程不再接受写入。', items: [], activeThreadId: null }
746
+ }
747
+ const mode = typeof runtimeMode === 'function' ? runtimeMode(botId) : null
748
+ if (mode !== undefined && mode !== null && mode !== 'integration') {
749
+ return { status: 'blocked', reason: `该 Bot 当前由 ${mode} 运行时提供服务。`, nextAction: '线程会话只在集成运行时可用;移动回集成运行时后恢复。', items: [], activeThreadId: null }
750
+ }
751
+ const view = list(botId)
752
+ return {
753
+ status: 'available',
754
+ reason: '',
755
+ nextAction: '',
756
+ items: view.threads,
757
+ activeThreadId: view.activeThreadId,
758
+ sessionBinding: typeof ownsSession === 'function' ? 'host-confirmed' : 'pending-host-hook',
759
+ archiveAuthority: typeof setThreadArchived === 'function' ? 'host' : 'catalog',
760
+ renameAuthority: typeof renameThread === 'function' ? 'host' : 'catalog',
761
+ identityAuthority: typeof committedThreads === 'function' ? 'host' : 'catalog',
762
+ }
763
+ }
764
+
765
+ const close = () => { closed_ = true; for (const flight of flights.values()) flight.catch(() => {}) }
766
+
767
+ return Object.freeze({
768
+ BOT_THREAD_SCHEMA,
769
+ THREAD_STATES,
770
+ SESSION_STATES,
771
+ list,
772
+ create,
773
+ rename,
774
+ switchThread,
775
+ archive,
776
+ unarchive,
777
+ resolve,
778
+ openThread,
779
+ assertSendable,
780
+ probeSendable,
781
+ ensureSession,
782
+ recover,
783
+ reconcile,
784
+ purgeBot,
785
+ readModel,
786
+ threadBySession,
787
+ revision: () => state.revision,
788
+ close,
789
+ })
790
+ }