@qduc/term2 0.38.0 → 0.40.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 (338) hide show
  1. package/README.md +12 -2
  2. package/dist/agent.d.ts +4 -0
  3. package/dist/agent.d.ts.map +1 -1
  4. package/dist/agent.js +18 -4
  5. package/dist/agent.js.map +1 -1
  6. package/dist/app.d.ts +4 -0
  7. package/dist/app.d.ts.map +1 -1
  8. package/dist/app.js +50 -4
  9. package/dist/app.js.map +1 -1
  10. package/dist/build-info.json +3 -3
  11. package/dist/cli.js +189 -49
  12. package/dist/cli.js.map +1 -1
  13. package/dist/commands/bridge-command.d.ts +11 -0
  14. package/dist/commands/bridge-command.d.ts.map +1 -0
  15. package/dist/commands/bridge-command.js +67 -0
  16. package/dist/commands/bridge-command.js.map +1 -0
  17. package/dist/components/input/FirstRunSetupPrompt.js +3 -3
  18. package/dist/components/input/FirstRunSetupPrompt.js.map +1 -1
  19. package/dist/components/input/ModelMenuSession.d.ts.map +1 -1
  20. package/dist/components/input/ModelMenuSession.js +2 -0
  21. package/dist/components/input/ModelMenuSession.js.map +1 -1
  22. package/dist/components/input/SubagentPoolMenuSession.js +1 -1
  23. package/dist/components/input/SubagentPoolMenuSession.js.map +1 -1
  24. package/dist/components/input/menu-types.d.ts +2 -0
  25. package/dist/components/input/menu-types.d.ts.map +1 -1
  26. package/dist/components/layout/BackgroundTaskManager.d.ts.map +1 -1
  27. package/dist/components/layout/BackgroundTaskManager.js +28 -17
  28. package/dist/components/layout/BackgroundTaskManager.js.map +1 -1
  29. package/dist/components/layout/BottomArea.d.ts.map +1 -1
  30. package/dist/components/layout/BottomArea.js +5 -3
  31. package/dist/components/layout/BottomArea.js.map +1 -1
  32. package/dist/components/message/ChatMessage.d.ts +1 -0
  33. package/dist/components/message/ChatMessage.d.ts.map +1 -1
  34. package/dist/components/message/ChatMessage.js +3 -3
  35. package/dist/components/message/ChatMessage.js.map +1 -1
  36. package/dist/components/message/CommandMessage.js +21 -3
  37. package/dist/components/message/CommandMessage.js.map +1 -1
  38. package/dist/components/message/MessageList.d.ts +4 -1
  39. package/dist/components/message/MessageList.d.ts.map +1 -1
  40. package/dist/components/message/MessageList.js +20 -4
  41. package/dist/components/message/MessageList.js.map +1 -1
  42. package/dist/components/prompt/QueuePausedPrompt.d.ts +1 -0
  43. package/dist/components/prompt/QueuePausedPrompt.d.ts.map +1 -1
  44. package/dist/components/prompt/QueuePausedPrompt.js +6 -1
  45. package/dist/components/prompt/QueuePausedPrompt.js.map +1 -1
  46. package/dist/contracts/application-stream.d.ts +3 -0
  47. package/dist/contracts/application-stream.d.ts.map +1 -1
  48. package/dist/contracts/conversation-items.d.ts +6 -0
  49. package/dist/contracts/conversation-items.d.ts.map +1 -1
  50. package/dist/contracts/streamed-model-turn.d.ts +5 -0
  51. package/dist/contracts/streamed-model-turn.d.ts.map +1 -1
  52. package/dist/gateway/gateway.d.ts.map +1 -1
  53. package/dist/gateway/gateway.js +204 -34
  54. package/dist/gateway/gateway.js.map +1 -1
  55. package/dist/gateway/persistence/projection.d.ts +17 -0
  56. package/dist/gateway/persistence/projection.d.ts.map +1 -1
  57. package/dist/gateway/persistence/projection.js +108 -10
  58. package/dist/gateway/persistence/projection.js.map +1 -1
  59. package/dist/gateway/runtime-factory.d.ts.map +1 -1
  60. package/dist/gateway/runtime-factory.js +1 -0
  61. package/dist/gateway/runtime-factory.js.map +1 -1
  62. package/dist/gateway/workspace-boundary-probe.d.ts +5 -0
  63. package/dist/gateway/workspace-boundary-probe.d.ts.map +1 -1
  64. package/dist/gateway/workspace-boundary-probe.js +6 -1
  65. package/dist/gateway/workspace-boundary-probe.js.map +1 -1
  66. package/dist/hooks/conversation-ui-reducer.d.ts +1 -0
  67. package/dist/hooks/conversation-ui-reducer.d.ts.map +1 -1
  68. package/dist/hooks/conversation-ui-reducer.js +6 -1
  69. package/dist/hooks/conversation-ui-reducer.js.map +1 -1
  70. package/dist/hooks/use-app-commands.d.ts +8 -1
  71. package/dist/hooks/use-app-commands.d.ts.map +1 -1
  72. package/dist/hooks/use-app-commands.js +30 -1
  73. package/dist/hooks/use-app-commands.js.map +1 -1
  74. package/dist/hooks/use-conversation-messages.d.ts +1 -1
  75. package/dist/hooks/use-conversation-messages.d.ts.map +1 -1
  76. package/dist/hooks/use-conversation-messages.js +2 -1
  77. package/dist/hooks/use-conversation-messages.js.map +1 -1
  78. package/dist/hooks/use-conversation.d.ts +1 -1
  79. package/dist/hooks/use-conversation.d.ts.map +1 -1
  80. package/dist/hooks/use-conversation.js +19 -6
  81. package/dist/hooks/use-conversation.js.map +1 -1
  82. package/dist/hooks/use-first-run-setup.d.ts.map +1 -1
  83. package/dist/hooks/use-first-run-setup.js +14 -9
  84. package/dist/hooks/use-first-run-setup.js.map +1 -1
  85. package/dist/hooks/use-model-catalog.d.ts +18 -0
  86. package/dist/hooks/use-model-catalog.d.ts.map +1 -0
  87. package/dist/hooks/use-model-catalog.js +51 -0
  88. package/dist/hooks/use-model-catalog.js.map +1 -0
  89. package/dist/hooks/use-model-selection.d.ts +3 -4
  90. package/dist/hooks/use-model-selection.d.ts.map +1 -1
  91. package/dist/hooks/use-model-selection.js +8 -43
  92. package/dist/hooks/use-model-selection.js.map +1 -1
  93. package/dist/hooks/use-subagent-pool-selection.d.ts +1 -1
  94. package/dist/hooks/use-subagent-pool-selection.d.ts.map +1 -1
  95. package/dist/hooks/use-subagent-pool-selection.js +11 -46
  96. package/dist/hooks/use-subagent-pool-selection.js.map +1 -1
  97. package/dist/lib/agent-client.d.ts +2 -0
  98. package/dist/lib/agent-client.d.ts.map +1 -1
  99. package/dist/lib/agent-client.js +3 -1
  100. package/dist/lib/agent-client.js.map +1 -1
  101. package/dist/lib/agent-configuration.d.ts +3 -0
  102. package/dist/lib/agent-configuration.d.ts.map +1 -1
  103. package/dist/lib/agent-configuration.js +6 -0
  104. package/dist/lib/agent-configuration.js.map +1 -1
  105. package/dist/lib/agent-factory.d.ts +3 -0
  106. package/dist/lib/agent-factory.d.ts.map +1 -1
  107. package/dist/lib/agent-factory.js +4 -2
  108. package/dist/lib/agent-factory.js.map +1 -1
  109. package/dist/lib/input-owner.d.ts +2 -0
  110. package/dist/lib/input-owner.d.ts.map +1 -1
  111. package/dist/lib/input-owner.js +1 -1
  112. package/dist/lib/input-owner.js.map +1 -1
  113. package/dist/non-interactive.d.ts.map +1 -1
  114. package/dist/non-interactive.js +4 -2
  115. package/dist/non-interactive.js.map +1 -1
  116. package/dist/prompts/bare.md +3 -8
  117. package/dist/prompts/discord-communication.md +9 -0
  118. package/dist/prompts/durable-goal-context.d.ts.map +1 -1
  119. package/dist/prompts/durable-goal-context.js +4 -3
  120. package/dist/prompts/durable-goal-context.js.map +1 -1
  121. package/dist/prompts/durable-goal-context.ts +4 -2
  122. package/dist/providers/ai-sdk-streamed-model.d.ts.map +1 -1
  123. package/dist/providers/ai-sdk-streamed-model.js +4 -0
  124. package/dist/providers/ai-sdk-streamed-model.js.map +1 -1
  125. package/dist/providers/codex-responses-model.d.ts.map +1 -1
  126. package/dist/providers/codex-responses-model.js +30 -7
  127. package/dist/providers/codex-responses-model.js.map +1 -1
  128. package/dist/providers/openai-responses-model.d.ts.map +1 -1
  129. package/dist/providers/openai-responses-model.js +7 -0
  130. package/dist/providers/openai-responses-model.js.map +1 -1
  131. package/dist/providers/retrying-model.d.ts.map +1 -1
  132. package/dist/providers/retrying-model.js +3 -1
  133. package/dist/providers/retrying-model.js.map +1 -1
  134. package/dist/providers/websocket-receive-watchdog.d.ts.map +1 -1
  135. package/dist/providers/websocket-receive-watchdog.js +3 -2
  136. package/dist/providers/websocket-receive-watchdog.js.map +1 -1
  137. package/dist/services/agent-runtime/application-run-loop.d.ts.map +1 -1
  138. package/dist/services/agent-runtime/application-run-loop.js +7 -0
  139. package/dist/services/agent-runtime/application-run-loop.js.map +1 -1
  140. package/dist/services/agent-stream.d.ts.map +1 -1
  141. package/dist/services/agent-stream.js +2 -0
  142. package/dist/services/agent-stream.js.map +1 -1
  143. package/dist/services/control-socket/control-session-port.d.ts.map +1 -1
  144. package/dist/services/control-socket/control-session-port.js +37 -1
  145. package/dist/services/control-socket/control-session-port.js.map +1 -1
  146. package/dist/services/control-socket/control-socket.d.ts +36 -1
  147. package/dist/services/control-socket/control-socket.d.ts.map +1 -1
  148. package/dist/services/control-socket/control-socket.js +162 -9
  149. package/dist/services/control-socket/control-socket.js.map +1 -1
  150. package/dist/services/conversation/conversation-adapter-factory.d.ts.map +1 -1
  151. package/dist/services/conversation/conversation-adapter-factory.js +3 -1
  152. package/dist/services/conversation/conversation-adapter-factory.js.map +1 -1
  153. package/dist/services/conversation/conversation-adapter.d.ts +15 -2
  154. package/dist/services/conversation/conversation-adapter.d.ts.map +1 -1
  155. package/dist/services/conversation/conversation-adapter.js +106 -9
  156. package/dist/services/conversation/conversation-adapter.js.map +1 -1
  157. package/dist/services/conversation/conversation-decoder.d.ts.map +1 -1
  158. package/dist/services/conversation/conversation-decoder.js +6 -2
  159. package/dist/services/conversation/conversation-decoder.js.map +1 -1
  160. package/dist/services/conversation/conversation-events.d.ts +8 -1
  161. package/dist/services/conversation/conversation-events.d.ts.map +1 -1
  162. package/dist/services/conversation/conversation-events.js +1 -0
  163. package/dist/services/conversation/conversation-events.js.map +1 -1
  164. package/dist/services/conversation/conversation-history-repair.d.ts.map +1 -1
  165. package/dist/services/conversation/conversation-history-repair.js +11 -10
  166. package/dist/services/conversation/conversation-history-repair.js.map +1 -1
  167. package/dist/services/conversation/conversation-orchestrator.d.ts +5 -0
  168. package/dist/services/conversation/conversation-orchestrator.d.ts.map +1 -1
  169. package/dist/services/conversation/conversation-orchestrator.js +61 -14
  170. package/dist/services/conversation/conversation-orchestrator.js.map +1 -1
  171. package/dist/services/conversation/conversation-orchestrator.types.d.ts +3 -1
  172. package/dist/services/conversation/conversation-orchestrator.types.d.ts.map +1 -1
  173. package/dist/services/conversation/conversation-record-stream.d.ts +2 -0
  174. package/dist/services/conversation/conversation-record-stream.d.ts.map +1 -1
  175. package/dist/services/conversation/conversation-record-stream.js +12 -8
  176. package/dist/services/conversation/conversation-record-stream.js.map +1 -1
  177. package/dist/services/conversation/conversation-record.d.ts +1 -0
  178. package/dist/services/conversation/conversation-record.d.ts.map +1 -1
  179. package/dist/services/conversation/conversation-record.js +32 -14
  180. package/dist/services/conversation/conversation-record.js.map +1 -1
  181. package/dist/services/conversation/conversation-replay.d.ts.map +1 -1
  182. package/dist/services/conversation/conversation-replay.js +74 -149
  183. package/dist/services/conversation/conversation-replay.js.map +1 -1
  184. package/dist/services/conversation/conversation-service.d.ts +9 -0
  185. package/dist/services/conversation/conversation-service.d.ts.map +1 -1
  186. package/dist/services/conversation/conversation-service.js +68 -13
  187. package/dist/services/conversation/conversation-service.js.map +1 -1
  188. package/dist/services/conversation/conversation-state-schema.d.ts +1 -0
  189. package/dist/services/conversation/conversation-state-schema.d.ts.map +1 -1
  190. package/dist/services/conversation/conversation-state-schema.js +1 -0
  191. package/dist/services/conversation/conversation-state-schema.js.map +1 -1
  192. package/dist/services/conversation/conversation-turn-items.d.ts.map +1 -1
  193. package/dist/services/conversation/conversation-turn-items.js +3 -0
  194. package/dist/services/conversation/conversation-turn-items.js.map +1 -1
  195. package/dist/services/conversation/durable-goal-stop-check.js +1 -1
  196. package/dist/services/conversation/durable-goal-stop-check.js.map +1 -1
  197. package/dist/services/conversation/journal-to-ledger.d.ts +2 -11
  198. package/dist/services/conversation/journal-to-ledger.d.ts.map +1 -1
  199. package/dist/services/conversation/journal-to-ledger.js +37 -180
  200. package/dist/services/conversation/journal-to-ledger.js.map +1 -1
  201. package/dist/services/conversation/queue-persistence.d.ts +2 -1
  202. package/dist/services/conversation/queue-persistence.d.ts.map +1 -1
  203. package/dist/services/conversation/queue-persistence.js +14 -1
  204. package/dist/services/conversation/queue-persistence.js.map +1 -1
  205. package/dist/services/conversation/run-item-normalizer.d.ts.map +1 -1
  206. package/dist/services/conversation/run-item-normalizer.js +5 -1
  207. package/dist/services/conversation/run-item-normalizer.js.map +1 -1
  208. package/dist/services/conversation/tool-occurrence.d.ts +18 -0
  209. package/dist/services/conversation/tool-occurrence.d.ts.map +1 -0
  210. package/dist/services/conversation/tool-occurrence.js +32 -0
  211. package/dist/services/conversation/tool-occurrence.js.map +1 -0
  212. package/dist/services/discord/discord-bridge.d.ts +41 -0
  213. package/dist/services/discord/discord-bridge.d.ts.map +1 -0
  214. package/dist/services/discord/discord-bridge.js +859 -0
  215. package/dist/services/discord/discord-bridge.js.map +1 -0
  216. package/dist/services/discord/discord-command.d.ts +3 -0
  217. package/dist/services/discord/discord-command.d.ts.map +1 -0
  218. package/dist/services/discord/discord-command.js +172 -0
  219. package/dist/services/discord/discord-command.js.map +1 -0
  220. package/dist/services/discord/discord-credentials.d.ts +12 -0
  221. package/dist/services/discord/discord-credentials.d.ts.map +1 -0
  222. package/dist/services/discord/discord-credentials.js +155 -0
  223. package/dist/services/discord/discord-credentials.js.map +1 -0
  224. package/dist/services/discord/discord-gateway.d.ts +20 -0
  225. package/dist/services/discord/discord-gateway.d.ts.map +1 -0
  226. package/dist/services/discord/discord-gateway.js +257 -0
  227. package/dist/services/discord/discord-gateway.js.map +1 -0
  228. package/dist/services/discord/discord-outbound.d.ts +51 -0
  229. package/dist/services/discord/discord-outbound.d.ts.map +1 -0
  230. package/dist/services/discord/discord-outbound.js +275 -0
  231. package/dist/services/discord/discord-outbound.js.map +1 -0
  232. package/dist/services/discord/discord-send-capability.d.ts +5 -0
  233. package/dist/services/discord/discord-send-capability.d.ts.map +1 -0
  234. package/dist/services/discord/discord-send-capability.js +11 -0
  235. package/dist/services/discord/discord-send-capability.js.map +1 -0
  236. package/dist/services/input-surge-guard.d.ts.map +1 -1
  237. package/dist/services/input-surge-guard.js +4 -3
  238. package/dist/services/input-surge-guard.js.map +1 -1
  239. package/dist/services/large-uncached-input-guard.d.ts +1 -1
  240. package/dist/services/large-uncached-input-guard.d.ts.map +1 -1
  241. package/dist/services/large-uncached-input-guard.js +0 -6
  242. package/dist/services/large-uncached-input-guard.js.map +1 -1
  243. package/dist/services/logging/assistant-turn-journal.d.ts +1 -1
  244. package/dist/services/logging/assistant-turn-journal.d.ts.map +1 -1
  245. package/dist/services/logging/assistant-turn-journal.js +4 -3
  246. package/dist/services/logging/assistant-turn-journal.js.map +1 -1
  247. package/dist/services/logging/conversation-log-events.d.ts +1 -0
  248. package/dist/services/logging/conversation-log-events.d.ts.map +1 -1
  249. package/dist/services/logging/conversation-log-events.js.map +1 -1
  250. package/dist/services/profiles/registry.d.ts.map +1 -1
  251. package/dist/services/profiles/registry.js +23 -6
  252. package/dist/services/profiles/registry.js.map +1 -1
  253. package/dist/services/queue/queue-controller.d.ts +15 -3
  254. package/dist/services/queue/queue-controller.d.ts.map +1 -1
  255. package/dist/services/queue/queue-controller.js +52 -4
  256. package/dist/services/queue/queue-controller.js.map +1 -1
  257. package/dist/services/retry/retry-classifier.d.ts.map +1 -1
  258. package/dist/services/retry/retry-classifier.js +5 -3
  259. package/dist/services/retry/retry-classifier.js.map +1 -1
  260. package/dist/services/retry/retry-errors.d.ts +15 -0
  261. package/dist/services/retry/retry-errors.d.ts.map +1 -1
  262. package/dist/services/retry/retry-errors.js +29 -0
  263. package/dist/services/retry/retry-errors.js.map +1 -1
  264. package/dist/services/retry/retry-recovery-budget.d.ts +13 -0
  265. package/dist/services/retry/retry-recovery-budget.d.ts.map +1 -1
  266. package/dist/services/retry/retry-recovery-budget.js +35 -4
  267. package/dist/services/retry/retry-recovery-budget.js.map +1 -1
  268. package/dist/services/session/background-task-control.d.ts.map +1 -1
  269. package/dist/services/session/background-task-control.js +4 -6
  270. package/dist/services/session/background-task-control.js.map +1 -1
  271. package/dist/services/session/continuation-recovery-handler.d.ts.map +1 -1
  272. package/dist/services/session/continuation-recovery-handler.js +15 -7
  273. package/dist/services/session/continuation-recovery-handler.js.map +1 -1
  274. package/dist/services/session/initial-turn-recovery-handler.d.ts.map +1 -1
  275. package/dist/services/session/initial-turn-recovery-handler.js +15 -7
  276. package/dist/services/session/initial-turn-recovery-handler.js.map +1 -1
  277. package/dist/services/session/session-composition.d.ts +4 -0
  278. package/dist/services/session/session-composition.d.ts.map +1 -1
  279. package/dist/services/session/session-composition.js +10 -1
  280. package/dist/services/session/session-composition.js.map +1 -1
  281. package/dist/services/session/session-identity.d.ts +8 -3
  282. package/dist/services/session/session-identity.d.ts.map +1 -1
  283. package/dist/services/session/session-identity.js +16 -3
  284. package/dist/services/session/session-identity.js.map +1 -1
  285. package/dist/services/session-rollover/session-rollover-brief.d.ts +1 -2
  286. package/dist/services/session-rollover/session-rollover-brief.d.ts.map +1 -1
  287. package/dist/services/session-rollover/session-rollover-brief.js +9 -11
  288. package/dist/services/session-rollover/session-rollover-brief.js.map +1 -1
  289. package/dist/services/stream-event-processor.d.ts.map +1 -1
  290. package/dist/services/stream-event-processor.js +4 -0
  291. package/dist/services/stream-event-processor.js.map +1 -1
  292. package/dist/services/tool-execution-ledger.d.ts +11 -2
  293. package/dist/services/tool-execution-ledger.d.ts.map +1 -1
  294. package/dist/services/tool-execution-ledger.js +123 -30
  295. package/dist/services/tool-execution-ledger.js.map +1 -1
  296. package/dist/skins/testing/scenes.js +1 -1
  297. package/dist/skins/testing/scenes.js.map +1 -1
  298. package/dist/test-helpers/cli-compile-cache-setup.d.ts +7 -0
  299. package/dist/test-helpers/cli-compile-cache-setup.d.ts.map +1 -0
  300. package/dist/test-helpers/cli-compile-cache-setup.js +7 -0
  301. package/dist/test-helpers/cli-compile-cache-setup.js.map +1 -0
  302. package/dist/test-helpers/cli-compile-cache.d.ts +4 -0
  303. package/dist/test-helpers/cli-compile-cache.d.ts.map +1 -0
  304. package/dist/test-helpers/cli-compile-cache.js +33 -0
  305. package/dist/test-helpers/cli-compile-cache.js.map +1 -0
  306. package/dist/test-helpers/integration-cli-global-setup.d.ts +3 -0
  307. package/dist/test-helpers/integration-cli-global-setup.d.ts.map +1 -0
  308. package/dist/test-helpers/integration-cli-global-setup.js +40 -0
  309. package/dist/test-helpers/integration-cli-global-setup.js.map +1 -0
  310. package/dist/test-helpers/integration-cli.d.ts +7 -0
  311. package/dist/test-helpers/integration-cli.d.ts.map +1 -0
  312. package/dist/test-helpers/integration-cli.js +8 -0
  313. package/dist/test-helpers/integration-cli.js.map +1 -0
  314. package/dist/test-helpers/terminal-e2e.d.ts.map +1 -1
  315. package/dist/test-helpers/terminal-e2e.js +40 -2
  316. package/dist/test-helpers/terminal-e2e.js.map +1 -1
  317. package/dist/tools/agent/goal-check.d.ts.map +1 -1
  318. package/dist/tools/agent/goal-check.js +6 -2
  319. package/dist/tools/agent/goal-check.js.map +1 -1
  320. package/dist/tools/discord-send.d.ts +14 -0
  321. package/dist/tools/discord-send.d.ts.map +1 -0
  322. package/dist/tools/discord-send.js +66 -0
  323. package/dist/tools/discord-send.js.map +1 -0
  324. package/dist/tools/system/shell.d.ts.map +1 -1
  325. package/dist/tools/system/shell.js +10 -0
  326. package/dist/tools/system/shell.js.map +1 -1
  327. package/dist/tools/tool-names.d.ts +1 -0
  328. package/dist/tools/tool-names.d.ts.map +1 -1
  329. package/dist/tools/tool-names.js +1 -0
  330. package/dist/tools/tool-names.js.map +1 -1
  331. package/dist/types/message.d.ts +3 -1
  332. package/dist/types/message.d.ts.map +1 -1
  333. package/dist/types/message.js.map +1 -1
  334. package/dist/utils/process-restart.d.ts +33 -0
  335. package/dist/utils/process-restart.d.ts.map +1 -0
  336. package/dist/utils/process-restart.js +52 -0
  337. package/dist/utils/process-restart.js.map +1 -0
  338. package/package.json +4 -3
@@ -0,0 +1,859 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { connect } from 'node:net';
5
+ import { DiscordOutboundServer, sameDiscordBinding, } from './discord-outbound.js';
6
+ import { isControlSocketName, listControlAdvertisements, readProcStartTime, CONTROL_MAX_FRAME_BYTES, } from '../control-socket/control-socket.js';
7
+ const outboxStates = ['queued', 'sending', 'sent', 'uncertain'];
8
+ function validOutbox(value) {
9
+ if (!value || typeof value !== 'object' || Array.isArray(value))
10
+ return false;
11
+ const output = value;
12
+ return (Object.keys(output).length === 3 &&
13
+ Object.keys(output).every((key) => ['id', 'content', 'state'].includes(key)) &&
14
+ typeof output.id === 'string' &&
15
+ /^[a-f0-9]{24}$/.test(output.id) &&
16
+ typeof output.content === 'string' &&
17
+ outboxStates.includes(output.state));
18
+ }
19
+ const snowflakePattern = /^[0-9]{1,24}$/;
20
+ function validBinding(value) {
21
+ if (!value || typeof value !== 'object' || Array.isArray(value))
22
+ return false;
23
+ const binding = value;
24
+ const keys = Object.keys(binding);
25
+ return (keys.length === 3 &&
26
+ keys.every((key) => ['socketPath', 'sessionId', 'startedAt'].includes(key)) &&
27
+ typeof binding.socketPath === 'string' &&
28
+ typeof binding.sessionId === 'string' &&
29
+ typeof binding.startedAt === 'string');
30
+ }
31
+ const jobStates = ['new', 'dispatching', 'accepted', 'done', 'uncertain'];
32
+ function validHumanRoot(value, origin) {
33
+ if (!value || typeof value !== 'object' || Array.isArray(value))
34
+ return false;
35
+ const root = value;
36
+ const source = origin;
37
+ return (Object.keys(root).length === 2 &&
38
+ snowflakePattern.test(String(root.humanId)) &&
39
+ snowflakePattern.test(String(root.discordMessageId)) &&
40
+ !!source &&
41
+ typeof root.humanId === 'string' &&
42
+ typeof root.discordMessageId === 'string' &&
43
+ source.kind === 'human' &&
44
+ source.sender === root.humanId);
45
+ }
46
+ function validJob(value) {
47
+ if (!value || typeof value !== 'object' || Array.isArray(value))
48
+ return false;
49
+ const job = value;
50
+ const origin = job.origin;
51
+ if (!origin || typeof origin !== 'object' || Array.isArray(origin))
52
+ return false;
53
+ const source = origin;
54
+ return (Object.keys(job).every((key) => ['id', 'alias', 'text', 'origin', 'humanRoot', 'relayedBy', 'hops', 'state', 'noRoute', 'binding'].includes(key)) &&
55
+ typeof job.id === 'string' &&
56
+ /^[A-Za-z0-9_-]{1,128}$/.test(job.id) &&
57
+ typeof job.alias === 'string' &&
58
+ isControlSocketName(job.alias) &&
59
+ typeof job.text === 'string' &&
60
+ typeof job.hops === 'number' &&
61
+ Number.isInteger(job.hops) &&
62
+ job.hops >= 0 &&
63
+ job.hops <= 8 &&
64
+ jobStates.includes(job.state) &&
65
+ (job.noRoute === undefined || typeof job.noRoute === 'boolean') &&
66
+ validBinding(job.binding) &&
67
+ (job.humanRoot === undefined || validHumanRoot(job.humanRoot, job.origin)) &&
68
+ (job.relayedBy === undefined || (typeof job.relayedBy === 'string' && isControlSocketName(job.relayedBy))) &&
69
+ Object.keys(source).length === 2 &&
70
+ ['human', 'agent'].includes(source.kind) &&
71
+ typeof source.sender === 'string' &&
72
+ (source.kind === 'human' ? snowflakePattern.test(source.sender) : isControlSocketName(source.sender)));
73
+ }
74
+ /** Safe request categories only; never retain credentials, URLs or response bodies. */
75
+ class DiscordRequestError extends Error {
76
+ kind;
77
+ constructor(kind) {
78
+ super('Discord request failed');
79
+ this.kind = kind;
80
+ }
81
+ }
82
+ /** Map only bridge-owned request errors to fixed hints; unknown failures stay generic. */
83
+ function discordRequestErrorGuidance(error) {
84
+ if (!(error instanceof DiscordRequestError))
85
+ return undefined;
86
+ return {
87
+ transport: 'Discord request failed before a usable HTTP response; check network, DNS, TLS and proxy connectivity, then retry. Authentication was not verified',
88
+ authentication: 'Discord rejected bot authentication (HTTP 401); check or re-enroll the bot token and check TERM2_DISCORD_BOT_TOKEN overrides',
89
+ permission: 'Discord denied channel access (HTTP 403); check bot membership and channel permissions',
90
+ service: 'Discord service returned a server error (HTTP 5xx); retry later',
91
+ rejected: 'Discord rejected the channel read; check channel ID and bot access',
92
+ }[error.kind];
93
+ }
94
+ /** Tag state/schema failures so callers map fixed guidance without parsing error text. */
95
+ function stateError(code, message) {
96
+ return Object.assign(new Error(message), { code });
97
+ }
98
+ /** Explicit discriminator for bridge schema/journal failures; everything else stays generic. */
99
+ export function discordStateErrorCode(error) {
100
+ const code = error?.code;
101
+ return code === 'pinned_session_schema' || code === 'journal_state' || code === 'config_schema' ? code : undefined;
102
+ }
103
+ /** Validate a private single-channel bridge configuration; no token belongs in this file. */
104
+ export function parseDiscordBridgeConfig(value) {
105
+ const config = value;
106
+ if (config && typeof config === 'object' && Object.hasOwn(config, 'sessions'))
107
+ throw stateError('pinned_session_schema', 'Pinned-session schema has been replaced; archive old state and use a fresh stateDir/config');
108
+ if (!config ||
109
+ typeof config !== 'object' ||
110
+ Object.keys(config).some((key) => !['channelId', 'humanUserId', 'coordinator', 'stateDir', 'maxHops'].includes(key)) ||
111
+ typeof config.channelId !== 'string' ||
112
+ !snowflakePattern.test(config.channelId) ||
113
+ typeof config.humanUserId !== 'string' ||
114
+ !snowflakePattern.test(config.humanUserId) ||
115
+ typeof config.coordinator !== 'string' ||
116
+ !isControlSocketName(config.coordinator) ||
117
+ typeof config.stateDir !== 'string' ||
118
+ !path.isAbsolute(config.stateDir))
119
+ throw stateError('config_schema', 'Invalid Discord bridge config');
120
+ const maxHops = config.maxHops ?? 4;
121
+ if (!Number.isInteger(maxHops) || maxHops < 1 || maxHops > 8)
122
+ throw stateError('config_schema', 'Invalid Discord hop limit');
123
+ return { ...config, maxHops };
124
+ }
125
+ /** One bounded newline-framed RPC; transport failure is ambiguous for mutations, never retried here. */
126
+ export function callDiscordControl(socketPath, method, params) {
127
+ return new Promise((resolve, reject) => {
128
+ const socket = connect(socketPath);
129
+ let bytes = Buffer.alloc(0);
130
+ let hello = false;
131
+ let settled = false;
132
+ const finish = (error, value) => {
133
+ if (settled)
134
+ return;
135
+ settled = true;
136
+ socket.destroy();
137
+ if (error)
138
+ reject(error);
139
+ else
140
+ resolve(value);
141
+ };
142
+ socket.setTimeout(10_000, () => finish(new Error('Control timeout')));
143
+ socket.on('error', () => finish(new Error('Control unavailable')));
144
+ socket.on('close', () => finish(new Error('Control disconnected')));
145
+ socket.on('connect', () => socket.write(JSON.stringify({ v: 1, id: 'hello', method: 'hello' }) + '\n'));
146
+ socket.on('data', (chunk) => {
147
+ bytes = Buffer.concat([bytes, chunk]);
148
+ if (bytes.length > CONTROL_MAX_FRAME_BYTES)
149
+ return finish(new Error('Control frame too large'));
150
+ let newline;
151
+ while ((newline = bytes.indexOf(10)) >= 0) {
152
+ const line = bytes.subarray(0, newline).toString('utf8');
153
+ bytes = bytes.subarray(newline + 1);
154
+ try {
155
+ const response = JSON.parse(line);
156
+ if (response.v !== 1 || response.id !== (hello ? 'call' : 'hello'))
157
+ throw new Error('Invalid control response');
158
+ if (!response.ok) {
159
+ const error = new Error('Control rejected request');
160
+ error.code = typeof response.error?.code === 'string' ? response.error.code : 'internal';
161
+ return finish(error);
162
+ }
163
+ if (!hello) {
164
+ hello = true;
165
+ socket.write(JSON.stringify({ v: 1, id: 'call', method, params }) + '\n');
166
+ }
167
+ else
168
+ return finish(undefined, response.result);
169
+ }
170
+ catch {
171
+ return finish(new Error('Invalid control response'));
172
+ }
173
+ }
174
+ });
175
+ });
176
+ }
177
+ function atomicSave(file, data) {
178
+ const temporary = `${file}.${process.pid}.tmp`;
179
+ const fd = fs.openSync(temporary, 'w', 0o600);
180
+ try {
181
+ fs.writeFileSync(fd, JSON.stringify(data));
182
+ fs.fsyncSync(fd);
183
+ }
184
+ finally {
185
+ fs.closeSync(fd);
186
+ }
187
+ fs.renameSync(temporary, file);
188
+ const dir = fs.openSync(path.dirname(file), 'r');
189
+ try {
190
+ fs.fsyncSync(dir);
191
+ }
192
+ finally {
193
+ fs.closeSync(dir);
194
+ }
195
+ }
196
+ function chunks(text) {
197
+ const result = [];
198
+ let rest = text;
199
+ while (rest.length > 1700) {
200
+ let size = 1700;
201
+ if (/[\uD800-\uDBFF]/.test(rest[size - 1]))
202
+ size--;
203
+ result.push(rest.slice(0, size));
204
+ rest = rest.slice(size);
205
+ }
206
+ if (rest)
207
+ result.push(rest);
208
+ return result;
209
+ }
210
+ /** Safety-net REST read interval while Gateway push is live; nudges trigger reads immediately. */
211
+ const PUSH_FALLBACK_POLL_MS = 30_000;
212
+ /**
213
+ * Serial Discord REST adapter attached to existing explicitly opted-in terminal sessions.
214
+ * tick() is single-owner: durable state precedes effects; ambiguous effects are quarantined,
215
+ * not automatically replayed. No history scan or Discord bot message can wake a session.
216
+ */
217
+ export class DiscordBridge {
218
+ #config;
219
+ #token;
220
+ #runtimeDir;
221
+ #apiBase;
222
+ #warn;
223
+ #file;
224
+ #lock;
225
+ #journal;
226
+ #retryAt = 0;
227
+ #readFailures = 0;
228
+ #running = false;
229
+ #pushLive = false;
230
+ #nudged = false;
231
+ #nextIngress = 0;
232
+ #owned = false;
233
+ #failed = false;
234
+ #outbound;
235
+ constructor(config, token, options) {
236
+ this.#config = parseDiscordBridgeConfig(config);
237
+ if (!token || /[\s\r\n]/.test(token))
238
+ throw new Error('Discord bot token missing or invalid');
239
+ if (!path.isAbsolute(options.runtimeDir))
240
+ throw new Error('Invalid control runtime directory');
241
+ const base = options.apiBase ?? 'https://discord.com/api/v10';
242
+ const url = new URL(base);
243
+ if (base !== 'https://discord.com/api/v10' &&
244
+ !(url.protocol === 'http:' && ['127.0.0.1', '[::1]'].includes(url.hostname))) {
245
+ throw new Error('Invalid Discord transport endpoint');
246
+ }
247
+ this.#token = token;
248
+ this.#runtimeDir = options.runtimeDir;
249
+ this.#apiBase = base;
250
+ this.#warn = options.warn ?? (() => { });
251
+ this.#file = path.join(config.stateDir, 'journal.json');
252
+ this.#lock = path.join(config.stateDir, 'owner.json');
253
+ }
254
+ /** Acquire private state ownership and pin aliases to the currently advertised process epochs. */
255
+ open() {
256
+ const { stateDir } = this.#config;
257
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
258
+ const stat = fs.lstatSync(stateDir);
259
+ if (!stat.isDirectory() || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o700) {
260
+ throw new Error('Discord state directory must be owned by this user and mode 0700');
261
+ }
262
+ if (fs.existsSync(this.#lock)) {
263
+ const owner = JSON.parse(fs.readFileSync(this.#lock, 'utf8'));
264
+ if (!Number.isInteger(owner.pid) || !owner.start || readProcStartTime(owner.pid) === owner.start) {
265
+ throw new Error('Discord bridge already running or owner cannot be verified');
266
+ }
267
+ fs.unlinkSync(this.#lock);
268
+ }
269
+ const fd = fs.openSync(this.#lock, 'wx', 0o600);
270
+ try {
271
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, start: readProcStartTime(process.pid) }));
272
+ }
273
+ finally {
274
+ fs.closeSync(fd);
275
+ }
276
+ this.#owned = true;
277
+ try {
278
+ // The default alias is admission policy, not durable identity: each job already
279
+ // pins its own alias and process binding. Keep every other config safeguard.
280
+ const fingerprint = createHash('sha256')
281
+ .update(JSON.stringify({
282
+ channelId: this.#config.channelId,
283
+ humanUserId: this.#config.humanUserId,
284
+ stateDir,
285
+ maxHops: this.#config.maxHops,
286
+ }))
287
+ .digest('hex');
288
+ const legacyFingerprint = createHash('sha256').update(JSON.stringify(this.#config)).digest('hex');
289
+ if (fs.existsSync(this.#file)) {
290
+ const stateStat = fs.lstatSync(this.#file);
291
+ if (!stateStat.isFile() || (stateStat.mode & 0o777) !== 0o600 || stateStat.uid !== process.getuid?.()) {
292
+ throw new Error('Unsafe Discord journal permissions');
293
+ }
294
+ this.#journal = JSON.parse(fs.readFileSync(this.#file, 'utf8'));
295
+ if (this.#journal.v !== 2 ||
296
+ (this.#journal.fingerprint !== fingerprint && this.#journal.fingerprint !== legacyFingerprint) ||
297
+ typeof this.#journal.cursor !== 'string' ||
298
+ !/^([0-9]{1,24})?$/.test(this.#journal.cursor) ||
299
+ typeof this.#journal.paused !== 'boolean' ||
300
+ (this.#journal.stopPending !== undefined && typeof this.#journal.stopPending !== 'boolean') ||
301
+ (this.#journal.retryAt !== undefined &&
302
+ (typeof this.#journal.retryAt !== 'number' || !Number.isFinite(this.#journal.retryAt))) ||
303
+ !Array.isArray(this.#journal.jobs) ||
304
+ !Array.isArray(this.#journal.outbox) ||
305
+ !this.#journal.outbox.every(validOutbox) ||
306
+ Object.keys(this.#journal).some((key) => ![
307
+ 'v',
308
+ 'fingerprint',
309
+ 'cursor',
310
+ 'paused',
311
+ 'stopPending',
312
+ 'retryAt',
313
+ 'jobs',
314
+ 'outbox',
315
+ 'proactive',
316
+ ].includes(key)) ||
317
+ (this.#journal.proactive !== undefined && !Array.isArray(this.#journal.proactive)))
318
+ throw stateError('journal_state', 'Discord journal/config mismatch; archive old state and use a fresh stateDir');
319
+ for (const job of this.#journal.jobs)
320
+ if (!validJob(job))
321
+ throw stateError('journal_state', 'Invalid v2 Discord journal; archive old state and use a fresh stateDir');
322
+ if (this.#journal.stopPending)
323
+ this.#warn('Previous stop outcome uncertain; routing remains paused. Check each terminal before resume.');
324
+ for (const output of this.#journal.outbox)
325
+ if (output.state === 'sending') {
326
+ output.state = 'uncertain';
327
+ this.#warn('Uncertain Discord output retained; reconcile journal, do not resend blindly');
328
+ }
329
+ }
330
+ else {
331
+ this.#journal = { v: 2, fingerprint, cursor: '', paused: false, jobs: [], outbox: [] };
332
+ }
333
+ this.#journal.proactive ??= [];
334
+ for (const entry of this.#journal.proactive) {
335
+ if (!entry ||
336
+ Object.keys(entry).some((key) => !['binding', 'requestId', 'digest', 'alias', 'channelId', 'outputs'].includes(key)) ||
337
+ typeof entry.requestId !== 'string' ||
338
+ !/^[A-Za-z0-9_-]{1,64}$/.test(entry.requestId) ||
339
+ typeof entry.digest !== 'string' ||
340
+ !/^[a-f0-9]{64}$/.test(entry.digest) ||
341
+ !validBinding(entry.binding) ||
342
+ typeof entry.alias !== 'string' ||
343
+ !isControlSocketName(entry.alias) ||
344
+ entry.channelId !== this.#config.channelId ||
345
+ !Array.isArray(entry.outputs) ||
346
+ !entry.outputs.length ||
347
+ new Set(entry.outputs).size !== entry.outputs.length ||
348
+ entry.outputs.some((id) => !this.#journal.outbox.some((output) => output.id === id &&
349
+ typeof output.content === 'string' &&
350
+ ['queued', 'sending', 'sent', 'uncertain'].includes(output.state))) ||
351
+ this.#journal.proactive.filter((other) => other.requestId === entry.requestId && sameDiscordBinding(other.binding, entry.binding)).length !== 1)
352
+ throw stateError('journal_state', 'Invalid proactive Discord journal; reconciliation required');
353
+ }
354
+ const explicit = new Set(this.#journal.proactive.flatMap((entry) => entry.outputs));
355
+ const withheld = this.#journal.outbox.filter((output) => output.state === 'queued' && !explicit.has(output.id));
356
+ if (withheld.length)
357
+ this.#warn(`${withheld.length} legacy automatic Discord outputs withheld; retained locally for reconciliation`);
358
+ // Upgrade only a fully validated journal whose old whole-config hash matched.
359
+ // Legacy state must first be opened with its original default; never guess it.
360
+ this.#journal.fingerprint = fingerprint;
361
+ this.#retryAt = this.#journal.retryAt ?? 0;
362
+ this.#save();
363
+ }
364
+ catch (error) {
365
+ this.close();
366
+ throw error;
367
+ }
368
+ }
369
+ /** Release only this bridge's ownership; pending work and reconciliation records survive. */
370
+ close() {
371
+ this.#outbound?.close();
372
+ if (this.#owned) {
373
+ fs.unlinkSync(this.#lock);
374
+ this.#owned = false;
375
+ }
376
+ }
377
+ #save() {
378
+ this.#journal.retryAt = this.#retryAt;
379
+ if (this.#failed)
380
+ throw new Error('Discord persistence unavailable');
381
+ try {
382
+ atomicSave(this.#file, this.#journal);
383
+ }
384
+ catch (error) {
385
+ this.#failed = true;
386
+ throw error;
387
+ }
388
+ }
389
+ /** Advertise only after private durable ownership; credentials never enter the endpoint. */
390
+ async listenOutbound() {
391
+ if (!this.#owned || this.#failed || this.#outbound)
392
+ throw new Error('Discord bridge unavailable');
393
+ const server = new DiscordOutboundServer(this.#runtimeDir, this.#config.stateDir, {
394
+ fingerprint: this.#journal.fingerprint,
395
+ channelId: this.#config.channelId,
396
+ }, (method, params) => this.#send(method, params));
397
+ await server.listen();
398
+ this.#outbound = server;
399
+ }
400
+ // All mutations and saves are synchronous, including RPC admission. Tick never holds a
401
+ // detached journal snapshot across an await; no overlapping load/modify/write can occur.
402
+ #send(method, raw) {
403
+ const params = raw;
404
+ const reject = (reason) => ({
405
+ requestId: typeof params?.requestId === 'string' ? params.requestId : '',
406
+ delivery: 'rejected',
407
+ reason,
408
+ });
409
+ const sending = method === 'discord_send';
410
+ if ((!sending && method !== 'discord_send_status') ||
411
+ !params ||
412
+ typeof params !== 'object' ||
413
+ Object.keys(params).some((key) => !(sending ? ['binding', 'requestId', 'text'] : ['binding', 'requestId']).includes(key)) ||
414
+ typeof params.requestId !== 'string' ||
415
+ !/^[A-Za-z0-9_-]{1,64}$/.test(params.requestId) ||
416
+ !params.binding ||
417
+ Object.keys(params.binding).some((key) => !['socketPath', 'sessionId', 'startedAt'].includes(key)) ||
418
+ (sending && (typeof params.text !== 'string' || !params.text.trim() || params.text.length > 16_384)))
419
+ return reject('invalid_input');
420
+ const alias = Object.entries(this.#attached()).find(([, binding]) => sameDiscordBinding(params.binding, binding))?.[0];
421
+ if (!alias)
422
+ return reject('identity_changed');
423
+ if (!this.#owned || this.#failed)
424
+ throw new Error('Discord persistence unavailable');
425
+ const records = this.#journal.proactive;
426
+ const existing = records.find((entry) => entry.requestId === params.requestId && sameDiscordBinding(entry.binding, params.binding));
427
+ // Hash the JSON payload, not Node's lossy UTF-8 conversion of lone UTF-16 surrogates.
428
+ const digest = sending ? createHash('sha256').update(JSON.stringify(params.text)).digest('hex') : '';
429
+ const status = (entry) => {
430
+ const outputs = entry.outputs.map((id) => this.#journal.outbox.find((output) => output.id === id));
431
+ const counts = { queued: 0, sent: 0, uncertain: 0 };
432
+ for (const output of outputs)
433
+ counts[output.state === 'sending' ? 'queued' : output.state]++;
434
+ return {
435
+ requestId: entry.requestId,
436
+ channelId: entry.channelId,
437
+ senderAlias: entry.alias,
438
+ chunks: counts,
439
+ delivery: counts.uncertain ? 'uncertain' : counts.sent === outputs.length ? 'sent' : 'queued',
440
+ };
441
+ };
442
+ if (existing)
443
+ return sending && digest !== existing.digest ? reject('request_id_conflict') : status(existing);
444
+ if (!sending)
445
+ return { requestId: params.requestId, delivery: 'missing' };
446
+ try {
447
+ this.#binding(alias);
448
+ }
449
+ catch {
450
+ return reject('identity_changed');
451
+ }
452
+ if (this.#journal.paused)
453
+ return reject('paused');
454
+ // Match the existing work-inbox scale, not an artificial lifetime send count.
455
+ // Retain dedup evidence indefinitely; only new proactive admission is stopped at
456
+ // a 16 MiB serialized journal budget. Ingress/recovery and status remain usable.
457
+ const pending = records.filter((entry) => status(entry).delivery === 'queued').length;
458
+ if (pending >= 128)
459
+ return { ...reject('capacity'), capacity: { kind: 'pending', used: pending, limit: 128 } };
460
+ const seed = 'proactive_' +
461
+ createHash('sha256')
462
+ .update(JSON.stringify([params.binding, params.requestId]))
463
+ .digest('hex');
464
+ const before = this.#journal.outbox.length;
465
+ this.#enqueue(seed, `[${alias}] ${params.text}`);
466
+ const entry = {
467
+ binding: { ...params.binding },
468
+ requestId: params.requestId,
469
+ digest,
470
+ alias,
471
+ channelId: this.#config.channelId,
472
+ outputs: this.#journal.outbox.slice(before).map((output) => output.id),
473
+ };
474
+ records.push(entry);
475
+ const bytes = Buffer.byteLength(JSON.stringify(this.#journal));
476
+ if (bytes > 16 * 1024 * 1024) {
477
+ records.pop();
478
+ this.#journal.outbox.splice(before);
479
+ return { ...reject('capacity'), capacity: { kind: 'journal_bytes', used: bytes, limit: 16 * 1024 * 1024 } };
480
+ }
481
+ this.#save(); // index and chunks durable together, before acknowledgement or POST
482
+ return status(entry);
483
+ }
484
+ #enqueue(id, text) {
485
+ const pieces = chunks(text);
486
+ const label = /^\[[^\]]{1,64}\]/.exec(text)?.[0] ?? '[bridge]';
487
+ for (const [index, piece] of pieces.entries()) {
488
+ const content = pieces.length > 1 ? `${label} [${index + 1}/${pieces.length}] ${piece}` : piece;
489
+ const key = createHash('sha256').update(`${id}_${index}`).digest('hex').slice(0, 24);
490
+ if (!this.#journal.outbox.some((entry) => entry.id === key))
491
+ this.#journal.outbox.push({ id: key, content, state: 'queued' });
492
+ }
493
+ }
494
+ async #request(method, suffix, body) {
495
+ let response;
496
+ try {
497
+ response = await fetch(`${this.#apiBase}/channels/${this.#config.channelId}/messages${suffix}`, {
498
+ method,
499
+ headers: { Authorization: `Bot ${this.#token}`, 'Content-Type': 'application/json' },
500
+ ...(body ? { body: JSON.stringify(body) } : {}),
501
+ signal: AbortSignal.timeout(10_000),
502
+ redirect: 'error',
503
+ });
504
+ }
505
+ catch {
506
+ throw new DiscordRequestError('transport');
507
+ }
508
+ if (response.status === 429) {
509
+ const retry = (await response.json());
510
+ const seconds = typeof retry.retry_after === 'number' && Number.isFinite(retry.retry_after) ? retry.retry_after : 5;
511
+ this.#retryAt = Date.now() + Math.max(1, seconds) * 1000;
512
+ }
513
+ const reset = Number(response.headers.get('x-ratelimit-reset-after'));
514
+ if (response.headers.get('x-ratelimit-remaining') === '0' && Number.isFinite(reset) && reset > 0) {
515
+ this.#retryAt = Math.max(this.#retryAt, Date.now() + reset * 1000);
516
+ }
517
+ return response;
518
+ }
519
+ /** Live aliases come only from explicitly opted-in control advertisements; there is no cache. */
520
+ #attached() {
521
+ const attached = Object.create(null);
522
+ for (const ad of listControlAdvertisements(this.#runtimeDir)) {
523
+ if (ad.discordInput !== true)
524
+ continue;
525
+ attached[ad.name] = { socketPath: ad.socketPath, sessionId: ad.sessionId, startedAt: ad.startedAt };
526
+ }
527
+ return attached;
528
+ }
529
+ #binding(alias) {
530
+ const binding = this.#attached()[alias];
531
+ if (!binding)
532
+ throw new Error('Discord alias is not attached');
533
+ return binding;
534
+ }
535
+ async #ingress() {
536
+ // Discord is the backlog while the configured default is unavailable. Do not
537
+ // consume even commands/other targets out of order; admitted work still reconciles.
538
+ if (!this.#attached()[this.#config.coordinator])
539
+ return;
540
+ const response = await this.#request('GET', this.#journal.cursor ? `?after=${this.#journal.cursor}&limit=50` : '?limit=1');
541
+ if (!response.ok)
542
+ throw new DiscordRequestError(response.status === 401
543
+ ? 'authentication'
544
+ : response.status === 403
545
+ ? 'permission'
546
+ : response.status >= 500
547
+ ? 'service'
548
+ : 'rejected');
549
+ const messages = (await response.json());
550
+ if (!Array.isArray(messages) ||
551
+ messages.some((message) => !snowflakePattern.test(message.id) ||
552
+ message.channel_id !== this.#config.channelId ||
553
+ typeof message.content !== 'string' ||
554
+ !message.author?.id)) {
555
+ throw new Error('Invalid Discord channel response');
556
+ }
557
+ if (!this.#journal.cursor) {
558
+ if (!this.#attached()[this.#config.coordinator])
559
+ return;
560
+ this.#journal.cursor = messages[0]?.id ?? '0';
561
+ this.#warn('[bridge] Ready. Only new messages from the configured human are accepted. Use @alias text; !bridge pause/resume/status/stop.');
562
+ this.#save();
563
+ return;
564
+ }
565
+ messages.sort((a, b) => (BigInt(a.id) < BigInt(b.id) ? -1 : 1));
566
+ for (const message of messages) {
567
+ if (BigInt(message.id) <= BigInt(this.#journal.cursor))
568
+ continue;
569
+ // Attachment can disappear during the REST read or an awaited command.
570
+ if (!this.#attached()[this.#config.coordinator])
571
+ return;
572
+ // Never trust Discord bot identity, including our own echo or webhook messages.
573
+ if (message.author.id === this.#config.humanUserId && !message.author.bot && !message.webhook_id) {
574
+ if (message.content.startsWith('!bridge ')) {
575
+ await this.#command(message);
576
+ }
577
+ else if (message.content.trim()) {
578
+ const route = /^@([A-Za-z0-9_-]+)(?:\s+|$)([\s\S]*)$/.exec(message.content);
579
+ const alias = route?.[1] ?? this.#config.coordinator;
580
+ const text = route ? route[2].trim() : message.content;
581
+ if ((message.content.startsWith('@') && !route) || !text || text.length > 32_000) {
582
+ this.#warn('[bridge] Unknown target, empty or oversized message; not delivered.');
583
+ }
584
+ else if (this.#journal.jobs.filter((job) => !['done', 'uncertain'].includes(job.state)).length >= 128) {
585
+ // Bound work admission, never the shared cursor carrying human recovery controls.
586
+ this.#warn(`[bridge → ${alias}] Work inbox full (128 pending). Message ${message.id} NOT accepted: not queued or sent to a session. Resend after the backlog drains. !bridge status/resume/stop remain available.`);
587
+ }
588
+ else {
589
+ const attached = this.#attached();
590
+ if (!attached[this.#config.coordinator])
591
+ return;
592
+ const binding = attached[alias];
593
+ if (!binding)
594
+ this.#warn(`[bridge] Target ${alias} is not attached; message ${message.id} NOT accepted and not delivered.`);
595
+ else
596
+ this.#journal.jobs.push({
597
+ id: `discord_${message.id}`,
598
+ alias,
599
+ binding,
600
+ text,
601
+ origin: { kind: 'human', sender: this.#config.humanUserId },
602
+ humanRoot: { humanId: this.#config.humanUserId, discordMessageId: message.id },
603
+ hops: 0,
604
+ state: 'new',
605
+ });
606
+ }
607
+ }
608
+ }
609
+ this.#journal.cursor = message.id;
610
+ this.#save();
611
+ }
612
+ }
613
+ async #command(message) {
614
+ const command = message.content.slice('!bridge '.length).trim();
615
+ if (command === 'pause')
616
+ this.#journal.paused = true;
617
+ else if (command === 'resume')
618
+ this.#journal.paused = false;
619
+ else if (command === 'stop') {
620
+ this.#journal.paused = true;
621
+ this.#journal.stopPending = true;
622
+ this.#journal.cursor = message.id;
623
+ // Persist the stop before contacting any session; restarting cannot resume chains.
624
+ for (const job of this.#journal.jobs) {
625
+ job.noRoute = true;
626
+ if (job.state === 'new') {
627
+ job.state = 'done';
628
+ job.text = '';
629
+ }
630
+ }
631
+ this.#save();
632
+ let stopped = true;
633
+ for (const alias of Object.keys(this.#attached())) {
634
+ try {
635
+ const binding = this.#binding(alias);
636
+ await callDiscordControl(binding.socketPath, 'interrupt');
637
+ }
638
+ catch {
639
+ stopped = false;
640
+ this.#warn('Discord stop could not reach an attached session; use terminal Escape');
641
+ }
642
+ }
643
+ this.#journal.stopPending = !stopped;
644
+ this.#save();
645
+ }
646
+ else if (command !== 'status') {
647
+ this.#warn('[bridge] Commands: !bridge pause | resume | status | stop. Stop pauses routing and interrupts attached sessions; approvals stay terminal-owned.');
648
+ return;
649
+ }
650
+ const attached = Object.keys(this.#attached());
651
+ this.#warn(`[bridge] ${this.#journal.paused ? 'Paused' : 'Running'}; pending ${this.#journal.jobs.filter((job) => !['done', 'uncertain'].includes(job.state)).length}. ${attached.length ? `Attached: ${attached.join(', ')}.` : 'No sessions attached.'} Pause prevents new dispatch only. Stop requires explicit resume.`);
652
+ }
653
+ async #jobs() {
654
+ // Snapshot: a peer reply may create a new job, but it cannot run in this same tick.
655
+ for (const job of [...this.#journal.jobs]) {
656
+ if (job.state === 'done' || job.state === 'uncertain')
657
+ continue;
658
+ try {
659
+ const binding = job.binding;
660
+ const live = this.#attached()[job.alias];
661
+ if (!live || !sameDiscordBinding(live, binding))
662
+ throw new Error('Discord session identity changed');
663
+ const params = { ...binding, clientRequestId: job.id };
664
+ if (job.state === 'new') {
665
+ if (this.#journal.paused)
666
+ continue;
667
+ job.state = 'dispatching';
668
+ this.#save();
669
+ let receipt;
670
+ try {
671
+ receipt = await callDiscordControl(binding.socketPath, 'bridge_submit', {
672
+ ...params,
673
+ text: job.text,
674
+ origin: job.humanRoot && job.origin.kind === 'human'
675
+ ? {
676
+ ...job.origin,
677
+ discordMessageId: job.humanRoot.discordMessageId,
678
+ ...(job.relayedBy ? { relayedBy: job.relayedBy } : {}),
679
+ }
680
+ : job.origin,
681
+ });
682
+ }
683
+ catch (error) {
684
+ // Only the admission mutation may settle a job as denied. A rejected read-only
685
+ // result query below keeps the retained/uncertain recovery path instead.
686
+ if (error.code) {
687
+ job.state = 'done';
688
+ this.#warn(`[bridge → ${job.alias}] Control rejected admission; check session opt-in. Original input retained in journal, not retried.`);
689
+ this.#save();
690
+ continue;
691
+ }
692
+ throw error;
693
+ }
694
+ if (!['started', 'queued', 'steering'].includes(receipt.delivery)) {
695
+ job.state = 'done';
696
+ this.#warn(`[bridge → ${job.alias}] Not accepted (${receipt.reason ?? 'rejected'}); original input retained in journal. Use the terminal for approval/confirmation.`);
697
+ }
698
+ else {
699
+ job.state = 'accepted';
700
+ }
701
+ this.#save();
702
+ }
703
+ if (job.state !== 'accepted' && job.state !== 'dispatching')
704
+ continue;
705
+ const result = await callDiscordControl(binding.socketPath, 'bridge_result', params);
706
+ if (result.state === 'missing') {
707
+ job.state = 'uncertain';
708
+ this.#warn(`[bridge → ${job.alias}] Result no longer available. Input retained; check terminal history before manual recovery. NOT resubmitted.`);
709
+ this.#save();
710
+ continue;
711
+ }
712
+ if (result.state === 'pending') {
713
+ if (result.receipt)
714
+ job.state = 'accepted';
715
+ this.#save();
716
+ continue;
717
+ }
718
+ if (result.state === 'final') {
719
+ const text = typeof result.text === 'string' ? result.text : '';
720
+ const route = /^@([A-Za-z0-9_-]+)\s+([\s\S]+)$/.exec(text);
721
+ if (route && !job.noRoute && !result.truncated) {
722
+ const alias = route[1];
723
+ if (!this.#attached()[alias])
724
+ this.#warn(`[bridge] Unknown agent target ${alias}; not delivered.`);
725
+ else if (job.hops >= this.#config.maxHops)
726
+ this.#warn('[bridge] Peer hop limit reached; waiting for the human.');
727
+ else
728
+ this.#journal.jobs.push({
729
+ id: `${job.id}_p`,
730
+ alias,
731
+ binding: this.#binding(alias),
732
+ text: route[2],
733
+ origin: job.humanRoot
734
+ ? { kind: 'human', sender: job.humanRoot.humanId }
735
+ : { kind: 'agent', sender: job.alias },
736
+ ...(job.humanRoot ? { humanRoot: job.humanRoot } : {}),
737
+ relayedBy: job.alias,
738
+ hops: job.hops + 1,
739
+ state: 'new',
740
+ });
741
+ }
742
+ }
743
+ else
744
+ this.#warn(`[${job.alias}] ${result.state}; inspect the terminal. No peer activation.`);
745
+ job.state = 'done';
746
+ job.text = '';
747
+ this.#save();
748
+ }
749
+ catch {
750
+ // A coded failure here came from the read-only result query; the admission mutation
751
+ // classifies its own rejection above, so a dispatching row is never settled as denied.
752
+ // If the session was temporarily unavailable after a dispatch, read-only recovery
753
+ // on the next tick is safe; never send a second mutation automatically.
754
+ if (job.state === 'new') {
755
+ this.#warn(`[bridge → ${job.alias}] Target is not attached right now. Input retained and NOT delivered; only the exact admitted process identity may receive it. Reconcile in the terminal otherwise.`);
756
+ this.#save();
757
+ continue;
758
+ }
759
+ const live = this.#attached()[job.alias];
760
+ if (!live || !sameDiscordBinding(live, job.binding)) {
761
+ job.state = 'uncertain';
762
+ this.#warn(`[bridge → ${job.alias}] Session identity changed or disconnected. Input retained for reconciliation; NOT resubmitted. Reconcile after checking terminal history.`);
763
+ this.#save();
764
+ continue;
765
+ }
766
+ this.#warn('Control unavailable or outcome unknown; retaining input without resubmission');
767
+ }
768
+ }
769
+ }
770
+ async #outputs() {
771
+ // Only durable discord_send admissions authorize channel posts. Old automatic
772
+ // rows remain evidence, but must neither post nor starve explicit output.
773
+ const explicit = new Set(this.#journal.proactive.flatMap((entry) => entry.outputs));
774
+ for (const outgoing of this.#journal.outbox) {
775
+ if (Date.now() < this.#retryAt)
776
+ return;
777
+ if (outgoing.state !== 'queued' || !explicit.has(outgoing.id))
778
+ continue;
779
+ outgoing.state = 'sending';
780
+ this.#save();
781
+ try {
782
+ const response = await this.#request('POST', '', {
783
+ content: outgoing.content,
784
+ allowed_mentions: { parse: [], replied_user: false },
785
+ nonce: outgoing.id,
786
+ enforce_nonce: true,
787
+ });
788
+ if (response.status === 429) {
789
+ outgoing.state = 'queued';
790
+ this.#save();
791
+ return;
792
+ }
793
+ // Non-2xx may hide a successful effect at an intermediary. Never blindly resend.
794
+ if (!response.ok)
795
+ throw new Error('Discord output not acknowledged');
796
+ outgoing.state = 'sent';
797
+ this.#save();
798
+ }
799
+ catch {
800
+ outgoing.state = 'uncertain';
801
+ this.#save();
802
+ this.#warn('Uncertain Discord output retained in journal; do not resend without reconciliation');
803
+ }
804
+ // At most one Discord POST per normal one-second cycle, including failures.
805
+ return;
806
+ }
807
+ }
808
+ /** Report whether Gateway push is live; while it is, REST reads run on nudges plus a slow safety poll. */
809
+ setPushLive(live) {
810
+ this.#pushLive = live;
811
+ }
812
+ /** Request a REST read on the next cycle. Content always comes from the cursor-based read. */
813
+ nudge() {
814
+ this.#nudged = true;
815
+ }
816
+ /** Perform one bounded poll/admission/result/outbox cycle; call serially after open(). */
817
+ async tick() {
818
+ if (!this.#owned || this.#failed || this.#running)
819
+ throw new Error('Discord bridge not open or already ticking');
820
+ if (Date.now() < this.#retryAt)
821
+ return;
822
+ this.#running = true;
823
+ try {
824
+ // With Gateway push live, read only when nudged or on the slow safety poll;
825
+ // otherwise keep the original read-every-cycle behavior.
826
+ const ingressDue = !this.#pushLive || this.#nudged || Date.now() >= this.#nextIngress;
827
+ if (ingressDue)
828
+ try {
829
+ this.#nudged = false;
830
+ this.#nextIngress = Date.now() + PUSH_FALLBACK_POLL_MS;
831
+ await this.#ingress();
832
+ this.#readFailures = 0;
833
+ }
834
+ catch (error) {
835
+ // Retry after the backoff instead of waiting for the next nudge or safety poll.
836
+ this.#nudged = true;
837
+ this.#readFailures++;
838
+ this.#retryAt = Math.max(this.#retryAt, Date.now() + Math.min(60_000, 1000 * 2 ** Math.min(6, this.#readFailures)));
839
+ this.#warn(`${discordRequestErrorGuidance(error) ?? 'Discord ingress unavailable or rejected'}; cursor retained, bounded backoff active`);
840
+ }
841
+ await this.#jobs();
842
+ if (Date.now() >= this.#retryAt)
843
+ await this.#outputs();
844
+ // Keep pending/uncertain evidence; trim only settled records behind the persisted cursor.
845
+ const done = this.#journal.jobs.filter((job) => job.state === 'done');
846
+ const retained = new Set(this.#journal.proactive.flatMap((entry) => entry.outputs));
847
+ const sent = this.#journal.outbox.filter((output) => output.state === 'sent' && !retained.has(output.id));
848
+ if (done.length > 256)
849
+ this.#journal.jobs = this.#journal.jobs.filter((job) => job.state !== 'done' || done.indexOf(job) >= done.length - 256);
850
+ if (sent.length > 512)
851
+ this.#journal.outbox = this.#journal.outbox.filter((output) => retained.has(output.id) || output.state !== 'sent' || sent.indexOf(output) >= sent.length - 512);
852
+ this.#save();
853
+ }
854
+ finally {
855
+ this.#running = false;
856
+ }
857
+ }
858
+ }
859
+ //# sourceMappingURL=discord-bridge.js.map