clawmini 0.0.7 → 0.0.9

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 (367) hide show
  1. package/.changeset/README.md +8 -0
  2. package/.changeset/config.json +14 -0
  3. package/.github/workflows/release.yml +49 -0
  4. package/CHANGELOG.md +36 -0
  5. package/README.md +5 -4
  6. package/dist/adapter-discord/index.d.mts.map +1 -1
  7. package/dist/adapter-discord/index.mjs +465 -282
  8. package/dist/adapter-discord/index.mjs.map +1 -1
  9. package/dist/adapter-google-chat/index.mjs +367 -243
  10. package/dist/adapter-google-chat/index.mjs.map +1 -1
  11. package/dist/cli/index.mjs +684 -24
  12. package/dist/cli/index.mjs.map +1 -1
  13. package/dist/cli/lite.mjs +43 -13
  14. package/dist/cli/lite.mjs.map +1 -1
  15. package/dist/cli/{propose-policy.mjs → manage-policies.mjs} +270 -47
  16. package/dist/cli/manage-policies.mjs.map +1 -0
  17. package/dist/cli/run-host.d.mts +1 -0
  18. package/dist/cli/run-host.mjs +3090 -0
  19. package/dist/cli/run-host.mjs.map +1 -0
  20. package/dist/config-CPFQIGdG.mjs +57 -0
  21. package/dist/config-CPFQIGdG.mjs.map +1 -0
  22. package/dist/config-Dvl-Pov4.mjs +76 -0
  23. package/dist/config-Dvl-Pov4.mjs.map +1 -0
  24. package/dist/daemon/index.d.mts.map +1 -1
  25. package/dist/daemon/index.mjs +970 -332
  26. package/dist/daemon/index.mjs.map +1 -1
  27. package/dist/supervisor-actions-CiW56eLi.mjs +843 -0
  28. package/dist/supervisor-actions-CiW56eLi.mjs.map +1 -0
  29. package/dist/turn-log-buffer-DRgW53gl.mjs +767 -0
  30. package/dist/turn-log-buffer-DRgW53gl.mjs.map +1 -0
  31. package/dist/web/_app/immutable/chunks/{Drm9vgeP.js → 3AZlWB6U.js} +1 -1
  32. package/dist/web/_app/immutable/chunks/BhRSsUCh.js +2 -0
  33. package/dist/web/_app/immutable/chunks/BiLeM2i1.js +1 -0
  34. package/{web/.svelte-kit/output/client/_app/immutable/chunks/CME08kGM.js → dist/web/_app/immutable/chunks/BmBj85Ll.js} +1 -1
  35. package/dist/web/_app/immutable/chunks/BrERcKAH.js +1 -0
  36. package/dist/web/_app/immutable/chunks/Bv9252RM.js +1 -0
  37. package/dist/web/_app/immutable/chunks/CIXNBPKi.js +1 -0
  38. package/dist/web/_app/immutable/chunks/DISKL3GN.js +2 -0
  39. package/dist/web/_app/immutable/chunks/{Zeh-C-mx.js → DcpaLzmX.js} +1 -1
  40. package/dist/web/_app/immutable/chunks/DnQ3vS13.js +1 -0
  41. package/dist/web/_app/immutable/chunks/KsloHTKS.js +1 -0
  42. package/{web/.svelte-kit/output/client/_app/immutable/chunks/Ck-be5J2.js → dist/web/_app/immutable/chunks/RsHsUj-8.js} +2 -2
  43. package/dist/web/_app/immutable/chunks/{G_zz-Gou.js → wpfV79dV.js} +1 -1
  44. package/dist/web/_app/immutable/entry/app.CIw1Qj0n.js +2 -0
  45. package/dist/web/_app/immutable/entry/start.Di0-Jhte.js +1 -0
  46. package/dist/web/_app/immutable/nodes/{0.CYS8iApT.js → 0.DYyUA1au.js} +1 -1
  47. package/dist/web/_app/immutable/nodes/1.D-3QEMMZ.js +1 -0
  48. package/dist/web/_app/immutable/nodes/{2.BnwnD1Ki.js → 2.4olHnH7U.js} +1 -1
  49. package/{web/.svelte-kit/output/client/_app/immutable/nodes/3.Dr0ot9sV.js → dist/web/_app/immutable/nodes/3.4w0bE-m2.js} +3 -3
  50. package/dist/web/_app/immutable/nodes/4.CZvjhVHt.js +60 -0
  51. package/dist/web/_app/immutable/nodes/{5.BBGQ_i84.js → 5.DLbPVJY2.js} +1 -1
  52. package/dist/web/_app/version.json +1 -1
  53. package/dist/web/index.html +12 -12
  54. package/dist/workspace-oWmVh5mi.mjs +1001 -0
  55. package/dist/workspace-oWmVh5mi.mjs.map +1 -0
  56. package/docs/23_adapter_slash_autocomplete/development_log.md +19 -0
  57. package/docs/23_adapter_slash_autocomplete/notes.md +18 -0
  58. package/docs/23_adapter_slash_autocomplete/prd.md +46 -0
  59. package/docs/23_adapter_slash_autocomplete/questions.md +6 -0
  60. package/docs/23_adapter_slash_autocomplete/tickets.md +21 -0
  61. package/docs/24_subagent_job_policy_fixes/development_log.md +22 -0
  62. package/docs/24_subagent_job_policy_fixes/notes.md +28 -0
  63. package/docs/24_subagent_job_policy_fixes/prd.md +59 -0
  64. package/docs/24_subagent_job_policy_fixes/questions.md +3 -0
  65. package/docs/24_subagent_job_policy_fixes/tickets.md +49 -0
  66. package/docs/25_e2e_test_improvements/development_log.md +30 -0
  67. package/docs/25_e2e_test_improvements/notes.md +29 -0
  68. package/docs/25_e2e_test_improvements/prd.md +43 -0
  69. package/docs/25_e2e_test_improvements/questions.md +12 -0
  70. package/docs/25_e2e_test_improvements/tickets-2.md +22 -0
  71. package/docs/25_e2e_test_improvements/tickets.md +22 -0
  72. package/docs/25_policy_cwd/development_log.md +30 -0
  73. package/docs/25_policy_cwd/notes.md +28 -0
  74. package/docs/25_policy_cwd/prd.md +77 -0
  75. package/docs/25_policy_cwd/questions.md +6 -0
  76. package/docs/25_policy_cwd/tickets.md +77 -0
  77. package/docs/CLI_REFERENCE.md +3 -1
  78. package/docs/PHILOSOPHY.md +35 -0
  79. package/docs/adapter-visibility/SPEC.md +461 -0
  80. package/docs/adapter-visibility/SPEC_v2.md +202 -0
  81. package/docs/auto-update/SPEC.md +344 -0
  82. package/docs/backups/SPEC.md +296 -0
  83. package/docs/backups/clawmini.gitignore +69 -0
  84. package/docs/guides/assets/clawmini-avatar.png +0 -0
  85. package/docs/guides/backups.md +332 -0
  86. package/docs/guides/discord_adapter_setup.md +1 -1
  87. package/docs/guides/google_chat_adapter_setup.md +81 -0
  88. package/docs/unified-startup/SPEC.md +203 -0
  89. package/e2e/_helpers/test-environment.test.ts +49 -0
  90. package/e2e/_helpers/test-environment.ts +548 -0
  91. package/e2e/adapters/_google-chat-fixtures.ts +340 -0
  92. package/{src/cli/e2e → e2e/adapters}/adapter-discord.test.ts +22 -23
  93. package/e2e/adapters/adapter-google-chat-downtime.test.ts +157 -0
  94. package/e2e/adapters/adapter-google-chat-inbound.test.ts +697 -0
  95. package/e2e/adapters/adapter-google-chat-outbound.test.ts +297 -0
  96. package/e2e/adapters/adapter-google-chat-roundtrip.test.ts +56 -0
  97. package/e2e/adapters/adapter-google-chat-threads.test.ts +1078 -0
  98. package/e2e/agents/custom-api-env.test.ts +80 -0
  99. package/e2e/agents/export-lite-func.test.ts +104 -0
  100. package/e2e/agents/fallbacks.test.ts +124 -0
  101. package/e2e/agents/interrupt.test.ts +50 -0
  102. package/e2e/agents/no-reply-necessary.test.ts +57 -0
  103. package/e2e/agents/session-timeout-subagents.test.ts +76 -0
  104. package/e2e/agents/subagent-authorization.test.ts +246 -0
  105. package/e2e/agents/subagent-env.test.ts +49 -0
  106. package/e2e/agents/subagent-lifecycle.test.ts +782 -0
  107. package/e2e/agents/subagents-depth.test.ts +47 -0
  108. package/e2e/cli/agents.test.ts +176 -0
  109. package/e2e/cli/auto-update.test.ts +741 -0
  110. package/e2e/cli/basic.test.ts +44 -0
  111. package/{src/cli/e2e → e2e/cli}/export-lite.test.ts +16 -12
  112. package/e2e/cli/init-gitignore.test.ts +86 -0
  113. package/e2e/cli/init.test.ts +76 -0
  114. package/e2e/cli/messages.test.ts +363 -0
  115. package/e2e/cli/serve.test.ts +76 -0
  116. package/{src/cli/e2e → e2e/cli}/skills.test.ts +11 -10
  117. package/{src/cli/e2e → e2e/daemon}/daemon.test.ts +57 -195
  118. package/e2e/jobs/agent-jobs.test.ts +216 -0
  119. package/e2e/jobs/cron.test.ts +64 -0
  120. package/e2e/jobs/restart.test.ts +108 -0
  121. package/e2e/policies/approval-session.test.ts +69 -0
  122. package/e2e/policies/auto-create-policies-file.test.ts +35 -0
  123. package/e2e/policies/builtin-manage-policies.test.ts +184 -0
  124. package/e2e/policies/builtin-run-host.test.ts +180 -0
  125. package/e2e/policies/environment-policies.test.ts +177 -0
  126. package/e2e/policies/manage-policies.test.ts +566 -0
  127. package/e2e/policies/output-size.test.ts +98 -0
  128. package/e2e/policies/policies-context-cwd.test.ts +160 -0
  129. package/e2e/policies/relative-script-path.test.ts +60 -0
  130. package/e2e/policies/requests-show.test.ts +135 -0
  131. package/e2e/policies/requests.test.ts +208 -0
  132. package/e2e/policies/slash-policies.test.ts +308 -0
  133. package/e2e/policies/startup-cleanup.test.ts +48 -0
  134. package/e2e/routers/session-timeout.test.ts +106 -0
  135. package/e2e/routers/slash-model.test.ts +152 -0
  136. package/e2e/routers/slash-new.test.ts +50 -0
  137. package/e2e/routers/slash-restart-adapter.test.ts +96 -0
  138. package/e2e/routers/slash-restart.test.ts +114 -0
  139. package/e2e/routers/slash-shutdown.test.ts +55 -0
  140. package/e2e/routers/slash-stop.test.ts +232 -0
  141. package/e2e/routers/slash-upgrade.test.ts +88 -0
  142. package/{src/cli/e2e → e2e/sandbox}/environments.test.ts +14 -13
  143. package/eslint.config.js +6 -0
  144. package/napkin.md +1 -1
  145. package/package.json +8 -3
  146. package/src/adapter-discord/commands.test.ts +42 -0
  147. package/src/adapter-discord/commands.ts +33 -0
  148. package/src/adapter-discord/config.ts +12 -0
  149. package/src/adapter-discord/forwarder.test.ts +499 -21
  150. package/src/adapter-discord/forwarder.ts +343 -124
  151. package/src/adapter-discord/inbound-cache.test.ts +47 -0
  152. package/src/adapter-discord/inbound-cache.ts +37 -0
  153. package/src/adapter-discord/index.test.ts +67 -2
  154. package/src/adapter-discord/index.ts +84 -216
  155. package/src/adapter-discord/interactions.test.ts +54 -3
  156. package/src/adapter-discord/interactions.ts +97 -53
  157. package/src/adapter-discord/processMessage.ts +239 -0
  158. package/src/adapter-discord/state.ts +1 -0
  159. package/src/adapter-google-chat/auth.test.ts +9 -5
  160. package/src/adapter-google-chat/auth.ts +29 -23
  161. package/src/adapter-google-chat/cards.ts +7 -2
  162. package/src/adapter-google-chat/client.test.ts +37 -2
  163. package/src/adapter-google-chat/client.ts +138 -38
  164. package/src/adapter-google-chat/config.ts +19 -0
  165. package/src/adapter-google-chat/forwarder.test.ts +81 -56
  166. package/src/adapter-google-chat/forwarder.ts +394 -185
  167. package/src/adapter-google-chat/inbound-cache.test.ts +61 -0
  168. package/src/adapter-google-chat/inbound-cache.ts +36 -0
  169. package/src/adapter-google-chat/state.test.ts +1 -0
  170. package/src/adapter-google-chat/state.ts +9 -1
  171. package/src/adapter-google-chat/subscriptions.ts +8 -6
  172. package/src/cli/builtin-policies.ts +44 -0
  173. package/src/cli/commands/agents.ts +59 -5
  174. package/src/cli/commands/down.ts +54 -2
  175. package/src/cli/commands/environments.ts +8 -2
  176. package/src/cli/commands/init.ts +31 -0
  177. package/src/cli/commands/logs.ts +116 -0
  178. package/src/cli/commands/policies.ts +6 -4
  179. package/src/cli/commands/serve.test.ts +67 -0
  180. package/src/cli/commands/serve.ts +284 -0
  181. package/src/cli/commands/up.ts +122 -2
  182. package/src/cli/commands/web-api/agents.ts +3 -2
  183. package/src/cli/index.ts +4 -0
  184. package/src/cli/install-detection.test.ts +72 -0
  185. package/src/cli/install-detection.ts +48 -0
  186. package/src/cli/lite.ts +54 -22
  187. package/src/cli/manage-policies-utils.ts +104 -0
  188. package/src/cli/manage-policies.ts +291 -0
  189. package/src/cli/run-host.ts +45 -0
  190. package/src/cli/supervisor-actions.ts +267 -0
  191. package/src/cli/supervisor-control.test.ts +129 -0
  192. package/src/cli/supervisor-control.ts +155 -0
  193. package/src/cli/supervisor-pid.ts +68 -0
  194. package/src/cli/supervisor.ts +277 -0
  195. package/src/daemon/agent/agent-context.ts +11 -11
  196. package/src/daemon/agent/agent-session.ts +8 -1
  197. package/src/daemon/agent/chat-logger.test.ts +78 -9
  198. package/src/daemon/agent/chat-logger.ts +25 -5
  199. package/src/daemon/agent/turn-registry.test.ts +89 -0
  200. package/src/daemon/agent/turn-registry.ts +94 -0
  201. package/src/daemon/agent/types.ts +2 -0
  202. package/src/daemon/api/agent-policy-endpoints.ts +263 -0
  203. package/src/daemon/api/agent-router.ts +47 -126
  204. package/src/daemon/api/index.test.ts +1 -0
  205. package/src/daemon/api/policy-request.test.ts +7 -5
  206. package/src/daemon/api/router-utils.ts +6 -5
  207. package/src/daemon/api/subagent-router.ts +110 -74
  208. package/src/daemon/api/subagent-utils.test.ts +60 -0
  209. package/src/daemon/api/subagent-utils.ts +113 -87
  210. package/src/daemon/api/user-router.ts +34 -8
  211. package/src/daemon/auth.ts +1 -0
  212. package/src/daemon/cron.test.ts +62 -4
  213. package/src/daemon/cron.ts +42 -16
  214. package/src/daemon/events.ts +65 -0
  215. package/src/daemon/index.ts +24 -1
  216. package/src/daemon/message-interruption.test.ts +1 -0
  217. package/src/daemon/message-jobs.test.ts +1 -0
  218. package/src/daemon/message.ts +78 -14
  219. package/src/daemon/observation.test.ts +26 -18
  220. package/src/daemon/pending-replies.test.ts +112 -0
  221. package/src/daemon/pending-replies.ts +162 -0
  222. package/src/daemon/policy-request-service.ts +3 -1
  223. package/src/daemon/policy-utils.test.ts +66 -1
  224. package/src/daemon/policy-utils.ts +126 -1
  225. package/src/daemon/request-store.ts +31 -0
  226. package/src/daemon/routers/session-timeout.ts +4 -0
  227. package/src/daemon/routers/slash-model.test.ts +344 -0
  228. package/src/daemon/routers/slash-model.ts +207 -0
  229. package/src/daemon/routers/slash-policies.test.ts +38 -32
  230. package/src/daemon/routers/slash-policies.ts +84 -33
  231. package/src/daemon/routers/slash-restart.test.ts +69 -0
  232. package/src/daemon/routers/slash-restart.ts +36 -0
  233. package/src/daemon/routers/slash-shutdown.test.ts +50 -0
  234. package/src/daemon/routers/slash-shutdown.ts +28 -0
  235. package/src/daemon/routers/slash-upgrade.test.ts +116 -0
  236. package/src/daemon/routers/slash-upgrade.ts +76 -0
  237. package/src/daemon/routers/types.ts +7 -0
  238. package/src/daemon/routers.ts +16 -0
  239. package/src/shared/adapters/blockquote.test.ts +28 -0
  240. package/src/shared/adapters/blockquote.ts +20 -0
  241. package/src/shared/adapters/filtering.test.ts +224 -10
  242. package/src/shared/adapters/filtering.ts +95 -7
  243. package/src/shared/adapters/inbound-cache.test.ts +48 -0
  244. package/src/shared/adapters/inbound-cache.ts +54 -0
  245. package/src/shared/adapters/turn-log-buffer.ts +266 -0
  246. package/src/shared/adapters/turn-log.test.ts +389 -0
  247. package/src/shared/adapters/turn-log.ts +357 -0
  248. package/src/shared/agent-utils.ts +12 -5
  249. package/src/shared/chats.test.ts +4 -0
  250. package/src/shared/chats.ts +9 -0
  251. package/src/shared/config.ts +16 -1
  252. package/src/shared/lite.ts +76 -2
  253. package/src/shared/policies.ts +26 -0
  254. package/src/shared/template-manifest.ts +267 -0
  255. package/src/shared/utils/shell.ts +61 -0
  256. package/src/shared/version.ts +34 -0
  257. package/src/shared/workspace.test.ts +217 -0
  258. package/src/shared/workspace.ts +626 -48
  259. package/templates/environments/cladding/allowlist-domain.mjs +125 -0
  260. package/templates/environments/cladding/env.json +21 -1
  261. package/templates/environments/cladding/run-with-network.mjs +54 -0
  262. package/templates/environments/macos-proxy/allowlist-domain.mjs +95 -0
  263. package/templates/environments/macos-proxy/env.json +8 -1
  264. package/templates/environments/macos-proxy/proxy.mjs +42 -13
  265. package/templates/gemini/template.json +5 -0
  266. package/templates/gemini-claw/template.json +13 -0
  267. package/templates/skills/clawmini-requests/SKILL.md +69 -10
  268. package/templates/skills/run-host/SKILL.md +51 -0
  269. package/templates/skills/skill-creator/SKILL.md +4 -3
  270. package/templates/skills/skill-creator/scripts/validate.sh +52 -0
  271. package/tsdown.config.ts +10 -1
  272. package/vitest.config.ts +2 -2
  273. package/web/.svelte-kit/ambient.d.ts +292 -176
  274. package/web/.svelte-kit/generated/server/internal.js +1 -1
  275. package/web/.svelte-kit/output/client/.vite/manifest.json +127 -137
  276. package/web/.svelte-kit/output/client/_app/immutable/chunks/{Drm9vgeP.js → 3AZlWB6U.js} +1 -1
  277. package/web/.svelte-kit/output/client/_app/immutable/chunks/BhRSsUCh.js +2 -0
  278. package/web/.svelte-kit/output/client/_app/immutable/chunks/BiLeM2i1.js +1 -0
  279. package/{dist/web/_app/immutable/chunks/CME08kGM.js → web/.svelte-kit/output/client/_app/immutable/chunks/BmBj85Ll.js} +1 -1
  280. package/web/.svelte-kit/output/client/_app/immutable/chunks/BrERcKAH.js +1 -0
  281. package/web/.svelte-kit/output/client/_app/immutable/chunks/Bv9252RM.js +1 -0
  282. package/web/.svelte-kit/output/client/_app/immutable/chunks/CIXNBPKi.js +1 -0
  283. package/web/.svelte-kit/output/client/_app/immutable/chunks/DISKL3GN.js +2 -0
  284. package/web/.svelte-kit/output/client/_app/immutable/chunks/{Zeh-C-mx.js → DcpaLzmX.js} +1 -1
  285. package/web/.svelte-kit/output/client/_app/immutable/chunks/DnQ3vS13.js +1 -0
  286. package/web/.svelte-kit/output/client/_app/immutable/chunks/KsloHTKS.js +1 -0
  287. package/{dist/web/_app/immutable/chunks/Ck-be5J2.js → web/.svelte-kit/output/client/_app/immutable/chunks/RsHsUj-8.js} +2 -2
  288. package/web/.svelte-kit/output/client/_app/immutable/chunks/{G_zz-Gou.js → wpfV79dV.js} +1 -1
  289. package/web/.svelte-kit/output/client/_app/immutable/entry/app.CIw1Qj0n.js +2 -0
  290. package/web/.svelte-kit/output/client/_app/immutable/entry/start.Di0-Jhte.js +1 -0
  291. package/web/.svelte-kit/output/client/_app/immutable/nodes/{0.CYS8iApT.js → 0.DYyUA1au.js} +1 -1
  292. package/web/.svelte-kit/output/client/_app/immutable/nodes/1.D-3QEMMZ.js +1 -0
  293. package/web/.svelte-kit/output/client/_app/immutable/nodes/{2.BnwnD1Ki.js → 2.4olHnH7U.js} +1 -1
  294. package/{dist/web/_app/immutable/nodes/3.Dr0ot9sV.js → web/.svelte-kit/output/client/_app/immutable/nodes/3.4w0bE-m2.js} +3 -3
  295. package/web/.svelte-kit/output/client/_app/immutable/nodes/4.CZvjhVHt.js +60 -0
  296. package/web/.svelte-kit/output/client/_app/immutable/nodes/{5.BBGQ_i84.js → 5.DLbPVJY2.js} +1 -1
  297. package/web/.svelte-kit/output/client/_app/version.json +1 -1
  298. package/web/.svelte-kit/output/server/.vite/manifest.json +12 -10
  299. package/web/.svelte-kit/output/server/chunks/Icon.js +1 -1
  300. package/web/.svelte-kit/output/server/chunks/client.js +1 -1
  301. package/web/.svelte-kit/output/server/chunks/exports.js +1 -1
  302. package/web/.svelte-kit/output/server/chunks/index-server.js +2 -1
  303. package/web/.svelte-kit/output/server/chunks/internal.js +1 -1
  304. package/web/.svelte-kit/output/server/chunks/render-context.js +77 -0
  305. package/web/.svelte-kit/output/server/chunks/root.js +739 -788
  306. package/web/.svelte-kit/output/server/chunks/shared.js +234 -21
  307. package/web/.svelte-kit/output/server/index.js +126 -90
  308. package/web/.svelte-kit/output/server/manifest-full.js +1 -1
  309. package/web/.svelte-kit/output/server/manifest.js +1 -1
  310. package/web/.svelte-kit/output/server/nodes/0.js +1 -1
  311. package/web/.svelte-kit/output/server/nodes/1.js +1 -1
  312. package/web/.svelte-kit/output/server/nodes/2.js +1 -1
  313. package/web/.svelte-kit/output/server/nodes/3.js +1 -1
  314. package/web/.svelte-kit/output/server/nodes/4.js +1 -1
  315. package/web/.svelte-kit/output/server/nodes/5.js +1 -1
  316. package/web/.svelte-kit/output/server/remote-entry.js +245 -81
  317. package/web/.svelte-kit/tsconfig.json +4 -1
  318. package/dist/cli/propose-policy.mjs.map +0 -1
  319. package/dist/lite-CBxOT1y5.mjs +0 -241
  320. package/dist/lite-CBxOT1y5.mjs.map +0 -1
  321. package/dist/routing-D8rTxtaV.mjs +0 -245
  322. package/dist/routing-D8rTxtaV.mjs.map +0 -1
  323. package/dist/web/_app/immutable/chunks/B6YN0Nuq.js +0 -1
  324. package/dist/web/_app/immutable/chunks/BmRlVmv6.js +0 -1
  325. package/dist/web/_app/immutable/chunks/CK9JZLaG.js +0 -2
  326. package/dist/web/_app/immutable/chunks/Ck3rYNON.js +0 -1
  327. package/dist/web/_app/immutable/chunks/DMtIqaiV.js +0 -2
  328. package/dist/web/_app/immutable/chunks/DhD271EB.js +0 -1
  329. package/dist/web/_app/immutable/chunks/DpuLqk8d.js +0 -1
  330. package/dist/web/_app/immutable/chunks/DsIToJCP.js +0 -1
  331. package/dist/web/_app/immutable/chunks/bBmtyQMj.js +0 -1
  332. package/dist/web/_app/immutable/entry/app.CJmSwntr.js +0 -2
  333. package/dist/web/_app/immutable/entry/start.ZpUrT2ak.js +0 -1
  334. package/dist/web/_app/immutable/nodes/1.Bli0Hqzn.js +0 -1
  335. package/dist/web/_app/immutable/nodes/4.oBhvQhcA.js +0 -60
  336. package/dist/workspace-BJmJBfKi.mjs +0 -456
  337. package/dist/workspace-BJmJBfKi.mjs.map +0 -1
  338. package/src/cli/e2e/agents.test.ts +0 -140
  339. package/src/cli/e2e/basic.test.ts +0 -43
  340. package/src/cli/e2e/cron.test.ts +0 -132
  341. package/src/cli/e2e/export-lite-func.test.ts +0 -206
  342. package/src/cli/e2e/fallbacks.test.ts +0 -175
  343. package/src/cli/e2e/init.test.ts +0 -77
  344. package/src/cli/e2e/messages.test.ts +0 -332
  345. package/src/cli/e2e/propose-policy.test.ts +0 -203
  346. package/src/cli/e2e/requests.test.ts +0 -180
  347. package/src/cli/e2e/session-timeout.test.ts +0 -192
  348. package/src/cli/e2e/slash-new.test.ts +0 -93
  349. package/src/cli/e2e/subagents.test.ts +0 -106
  350. package/src/cli/e2e/utils.ts +0 -66
  351. package/src/cli/propose-policy.ts +0 -91
  352. package/web/.svelte-kit/output/client/_app/immutable/chunks/B6YN0Nuq.js +0 -1
  353. package/web/.svelte-kit/output/client/_app/immutable/chunks/BmRlVmv6.js +0 -1
  354. package/web/.svelte-kit/output/client/_app/immutable/chunks/CK9JZLaG.js +0 -2
  355. package/web/.svelte-kit/output/client/_app/immutable/chunks/Ck3rYNON.js +0 -1
  356. package/web/.svelte-kit/output/client/_app/immutable/chunks/DMtIqaiV.js +0 -2
  357. package/web/.svelte-kit/output/client/_app/immutable/chunks/DhD271EB.js +0 -1
  358. package/web/.svelte-kit/output/client/_app/immutable/chunks/DpuLqk8d.js +0 -1
  359. package/web/.svelte-kit/output/client/_app/immutable/chunks/DsIToJCP.js +0 -1
  360. package/web/.svelte-kit/output/client/_app/immutable/chunks/bBmtyQMj.js +0 -1
  361. package/web/.svelte-kit/output/client/_app/immutable/entry/app.CJmSwntr.js +0 -2
  362. package/web/.svelte-kit/output/client/_app/immutable/entry/start.ZpUrT2ak.js +0 -1
  363. package/web/.svelte-kit/output/client/_app/immutable/nodes/1.Bli0Hqzn.js +0 -1
  364. package/web/.svelte-kit/output/client/_app/immutable/nodes/4.oBhvQhcA.js +0 -60
  365. package/web/.svelte-kit/output/server/chunks/false.js +0 -4
  366. /package/dist/cli/{propose-policy.d.mts → manage-policies.d.mts} +0 -0
  367. /package/{src/cli/e2e → e2e/_helpers}/global-setup.ts +0 -0
@@ -8,25 +8,66 @@ import type {
8
8
  ThreadChannel,
9
9
  VoiceChannel,
10
10
  StageChannel,
11
+ Message,
11
12
  } from 'discord.js';
12
13
  import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, Colors } from 'discord.js';
13
14
  import path from 'node:path';
14
15
  import fs from 'node:fs';
15
16
  import type { getTRPCClient } from './client.js';
17
+ import type { DiscordConfig } from './config.js';
16
18
  import { readDiscordState, updateDiscordState, getDiscordStatePath } from './state.js';
19
+ import { resolveInbound } from './inbound-cache.js';
20
+ import { createTurnLogBuffer, type TurnLogBuffer } from '../shared/adapters/turn-log-buffer.js';
17
21
  import type { ChatMessage } from '../shared/chats.js';
18
22
  import { getWorkspaceRoot } from '../shared/workspace.js';
19
23
  import {
20
- shouldDisplayMessage,
24
+ routeMessage,
21
25
  formatMessage,
26
+ type Destination,
22
27
  type FilteringConfig,
23
28
  } from '../shared/adapters/filtering.js';
24
29
 
30
+ type AnyTextChannel =
31
+ | TextChannel
32
+ | DMChannel
33
+ | NewsChannel
34
+ | ThreadChannel
35
+ | VoiceChannel
36
+ | StageChannel;
37
+
38
+ interface ThreadLogOptions {
39
+ maxToolPreview: number;
40
+ maxLogMessageChars: number;
41
+ editDebounceMs: number;
42
+ }
43
+
44
+ const DEFAULT_THREAD_LOG_OPTS: ThreadLogOptions = {
45
+ maxToolPreview: 400,
46
+ // Discord caps messages at 2000 chars; leave headroom for the rollover marker.
47
+ maxLogMessageChars: 1800,
48
+ editDebounceMs: 1000,
49
+ };
50
+
51
+ function resolveThreadLogOpts(config?: DiscordConfig): ThreadLogOptions {
52
+ const v = config?.visibility?.threadLog;
53
+ return {
54
+ maxToolPreview: v?.maxToolPreview ?? DEFAULT_THREAD_LOG_OPTS.maxToolPreview,
55
+ maxLogMessageChars: v?.maxLogMessageChars ?? DEFAULT_THREAD_LOG_OPTS.maxLogMessageChars,
56
+ editDebounceMs: v?.editDebounceMs ?? DEFAULT_THREAD_LOG_OPTS.editDebounceMs,
57
+ };
58
+ }
59
+
60
+ // Suppresses every form of mention (@everyone, @here, role, user) on bot
61
+ // posts. Tool payloads, agent output, and policy descriptions can contain
62
+ // arbitrary text; without this, an `@everyone` substring in (e.g.) a shell
63
+ // command echoed into the activity log would page the entire channel.
64
+ const NO_MENTIONS = { allowedMentions: { parse: [] as [] } } as const;
65
+
25
66
  async function resolveDiscordDestination(
26
67
  client: Client,
27
68
  discordUserId: string,
28
69
  chatId: string
29
- ): Promise<TextChannel | DMChannel | NewsChannel | ThreadChannel | VoiceChannel | StageChannel> {
70
+ ): Promise<AnyTextChannel> {
30
71
  const state = await readDiscordState();
31
72
  const channelChatMap = state.channelChatMap || {};
32
73
 
@@ -64,11 +105,14 @@ export async function startDaemonToDiscordForwarder(
64
105
  chatId?: string;
65
106
  signal?: AbortSignal;
66
107
  config?: FilteringConfig;
108
+ discordConfig?: DiscordConfig;
67
109
  } = {}
68
110
  ) {
69
111
  const defaultChatId = options.chatId ?? 'default';
70
112
  const signal = options.signal;
71
113
  const config = options.config ?? {};
114
+ const threadLogOpts = resolveThreadLogOpts(options.discordConfig);
115
+ const threadsGloballyEnabled = options.discordConfig?.visibility?.threads !== false;
72
116
 
73
117
  const activeSubscriptions = new Map<string, { unsubscribe: () => void }>();
74
118
  const activeTypingSubscriptions = new Map<string, { unsubscribe: () => void }>();
@@ -84,6 +128,258 @@ export async function startDaemonToDiscordForwarder(
84
128
  }));
85
129
  };
86
130
 
131
+ const postThreaded = async (anchor: ThreadChannel, text: string): Promise<string | undefined> => {
132
+ const sent = await anchor.send({ content: text || '​', ...NO_MENTIONS });
133
+ return sent.id;
134
+ };
135
+
136
+ const editThreaded = async (
137
+ anchor: ThreadChannel,
138
+ messageId: string,
139
+ text: string
140
+ ): Promise<void> => {
141
+ const msg = await anchor.messages.fetch(messageId);
142
+ await msg.edit({ content: text || '​', ...NO_MENTIONS });
143
+ };
144
+
145
+ // Discord returns 10008 (Unknown Message) when an activity-log message has
146
+ // been deleted by the user; Cloudflare/HTTP layers may surface a generic
147
+ // 404. Either case means the same thing: open a fresh log message.
148
+ const isMissingMessageError = (err: unknown): boolean => {
149
+ const code = (err as { code?: number; status?: number })?.code ?? 0;
150
+ return code === 404 || code === 10008;
151
+ };
152
+
153
+ const turnLog: TurnLogBuffer<ThreadChannel> = createTurnLogBuffer<ThreadChannel>({
154
+ postThreaded,
155
+ editThreaded,
156
+ isMissingMessageError,
157
+ options: threadLogOpts,
158
+ threadsEnabled: threadsGloballyEnabled,
159
+ });
160
+
161
+ const collapseDestination = (dest: Destination, turnId?: string): Destination => {
162
+ // Both the global `visibility.threads: false` kill switch and the
163
+ // per-channel `threadsDisabled` flag mean "quiet bot": drop thread-log
164
+ // activity rather than promoting it top-level. Top-level spam is only
165
+ // opt-in via `filters` (e.g. `/show`), matching pre-threaded behavior.
166
+ if (dest.kind !== 'thread-log') return dest;
167
+ if (!threadsGloballyEnabled) return { kind: 'drop' };
168
+ if (turnId && turnLog.threadsDisabledFor(turnId)) return { kind: 'drop' };
169
+ return dest;
170
+ };
171
+
172
+ const channelThreadsDisabled = async (chatId: string): Promise<boolean> => {
173
+ const state = await readDiscordState();
174
+ for (const [, entry] of Object.entries(state.channelChatMap || {})) {
175
+ if (entry?.chatId === chatId) return entry.threadsDisabled === true;
176
+ }
177
+ return false;
178
+ };
179
+
180
+ const openThreadForTurn = async (
181
+ externalRef: string | undefined
182
+ ): Promise<ThreadChannel | undefined> => {
183
+ if (!externalRef) return undefined;
184
+ const inbound = resolveInbound(externalRef);
185
+ if (!inbound) return undefined;
186
+ let channel: AnyTextChannel | null;
187
+ try {
188
+ channel = (await client.channels.fetch(inbound.channelId)) as AnyTextChannel | null;
189
+ } catch (err) {
190
+ console.warn(`Failed to fetch channel ${inbound.channelId} for turn anchor:`, err);
191
+ return undefined;
192
+ }
193
+ if (!channel || !channel.isTextBased() || channel.isDMBased() || channel.isThread()) {
194
+ // DMs and existing threads can't host a new thread. Skip silently —
195
+ // proactive turns and DM-only flows simply have no activity log.
196
+ return undefined;
197
+ }
198
+ const guildChannel = channel as TextChannel | NewsChannel;
199
+ let userMessage: Message;
200
+ try {
201
+ userMessage = await guildChannel.messages.fetch(inbound.messageId);
202
+ } catch (err) {
203
+ console.warn(`Failed to fetch user message ${inbound.messageId} for turn anchor:`, err);
204
+ return undefined;
205
+ }
206
+ // Discord allows only one thread per message. The same inbound can anchor
207
+ // multiple turns (e.g. a follow-up turn fanned out from the original
208
+ // request), so reuse an existing thread rather than failing the second
209
+ // turn's activity log.
210
+ if (userMessage.hasThread && userMessage.thread) {
211
+ return userMessage.thread as ThreadChannel;
212
+ }
213
+ try {
214
+ return await userMessage.startThread({
215
+ name: 'Activity log',
216
+ // 1 day. Long agent runs (refactors, builds) outlive the previous
217
+ // 60-minute archive window and end up posting into archived threads
218
+ // that fall off the channel sidebar.
219
+ autoArchiveDuration: 1440,
220
+ });
221
+ } catch (err) {
222
+ // Race: another turn for the same inbound created the thread between
223
+ // our `hasThread` check and `startThread`. Discord returns 160004
224
+ // (THREAD_ALREADY_CREATED_FOR_MESSAGE). Re-fetch and reuse.
225
+ const code = (err as { code?: number })?.code;
226
+ if (code === 160004) {
227
+ try {
228
+ const refreshed = await guildChannel.messages.fetch(inbound.messageId);
229
+ if (refreshed.hasThread && refreshed.thread) {
230
+ return refreshed.thread as ThreadChannel;
231
+ }
232
+ } catch (refetchErr) {
233
+ console.warn(
234
+ `Failed to refetch user message ${inbound.messageId} after thread-exists race:`,
235
+ refetchErr
236
+ );
237
+ }
238
+ }
239
+ console.warn(`Failed to start thread on message ${inbound.messageId}:`, err);
240
+ return undefined;
241
+ }
242
+ };
243
+
244
+ const handleTurnStarted = async (chatId: string, turnId: string, externalRef?: string) => {
245
+ // Single source of truth for "is the activity log on for this turn":
246
+ // global kill switch OR per-channel opt-out. The buffer's `engaged()`
247
+ // and `collapseDestination`'s `threadsDisabledFor()` both consult the
248
+ // ctx flag set here, so we don't have to re-derive it later.
249
+ const threadsDisabled = !threadsGloballyEnabled || (await channelThreadsDisabled(chatId));
250
+ // Skip the API roundtrip when we already know the log is off.
251
+ const anchor = threadsDisabled ? undefined : await openThreadForTurn(externalRef);
252
+ // No anchor and threads enabled: proactive turn (cron, subagent, CLI),
253
+ // DM-only flow, or thread creation failed. Skip start entirely so the
254
+ // buffer doesn't accrue entries it can never flush.
255
+ if (!anchor && !threadsDisabled) return;
256
+ turnLog.start({ turnId, threadsDisabled, anchorThread: anchor });
257
+ };
258
+
259
+ const handleTurnEnded = async (turnId: string) => {
260
+ await turnLog.end(turnId);
261
+ };
262
+
263
+ const sendPolicyCard = async (chatId: string, message: ChatMessage): Promise<boolean> => {
264
+ if (message.role !== 'policy' || message.status !== 'pending') return false;
265
+ try {
266
+ const dm = await resolveDiscordDestination(client, discordUserId, chatId);
267
+
268
+ const embed = new EmbedBuilder()
269
+ .setTitle('Action Required: Policy Request')
270
+ .setDescription(message.content || 'A pending policy request requires your attention.')
271
+ .setColor(Colors.Yellow);
272
+
273
+ const policyId = ('requestId' in message && message.requestId) || message.id;
274
+ const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
275
+ new ButtonBuilder()
276
+ .setCustomId(`approve|${policyId}|${chatId}`)
277
+ .setLabel('Approve')
278
+ .setStyle(ButtonStyle.Success),
279
+ new ButtonBuilder()
280
+ .setCustomId(`reject|${policyId}|${chatId}`)
281
+ .setLabel('Reject')
282
+ .setStyle(ButtonStyle.Danger)
283
+ );
284
+
285
+ const optionsMsg: MessageCreateOptions = {
286
+ embeds: [embed],
287
+ components: [row],
288
+ ...NO_MENTIONS,
289
+ };
290
+
291
+ try {
292
+ await dm.send(optionsMsg);
293
+ } catch (richError) {
294
+ console.warn(
295
+ `Failed to send rich message to Discord user ${discordUserId}, falling back to plain text:`,
296
+ richError
297
+ );
298
+ await dm.send({
299
+ content: `Action Required: Policy Request\n\n${
300
+ message.content || 'A pending policy request requires your attention.'
301
+ }\n\nApprove: \`/approve ${policyId}\`\nReject: \`/reject ${policyId} <optional_rationale>\``,
302
+ ...NO_MENTIONS,
303
+ });
304
+ }
305
+ } catch (error) {
306
+ console.error(`Failed to send message to Discord user ${discordUserId}:`, error);
307
+ }
308
+ return true;
309
+ };
310
+
311
+ const sendTopLevel = async (chatId: string, message: ChatMessage): Promise<void> => {
312
+ if ('level' in message && (message as { level?: string }).level === 'verbose') return;
313
+
314
+ const hasContent = !!message.content?.trim();
315
+ const files = 'files' in message ? ((message as { files?: string[] }).files ?? []) : [];
316
+ const hasFiles = Array.isArray(files) && files.length > 0;
317
+
318
+ let absoluteFiles: string[] = [];
319
+ if (hasFiles) {
320
+ const workspaceRoot = getWorkspaceRoot(process.cwd());
321
+ absoluteFiles = files.map((f) => path.resolve(workspaceRoot, f));
322
+ }
323
+
324
+ if (!hasContent && !hasFiles) return;
325
+
326
+ try {
327
+ const dm = await resolveDiscordDestination(client, discordUserId, chatId);
328
+ const formattedContent = formatMessage(message);
329
+
330
+ if (formattedContent && formattedContent.length > 2000) {
331
+ const chunks = chunkString(formattedContent, 2000);
332
+ for (let i = 0; i < chunks.length; i++) {
333
+ if (signal?.aborted) break;
334
+ const chunkOptions: MessageCreateOptions = {
335
+ content: chunks[i] as string,
336
+ ...NO_MENTIONS,
337
+ };
338
+ if (i === chunks.length - 1 && hasFiles) {
339
+ chunkOptions.files = absoluteFiles;
340
+ }
341
+ await dm.send(chunkOptions);
342
+ }
343
+ } else {
344
+ const optionsMsg: MessageCreateOptions = { ...NO_MENTIONS };
345
+ if (formattedContent) optionsMsg.content = formattedContent;
346
+ if (hasFiles) optionsMsg.files = absoluteFiles;
347
+ await dm.send(optionsMsg);
348
+ }
349
+ } catch (error) {
350
+ console.error(`Failed to send message to Discord user ${discordUserId}:`, error);
351
+ throw error;
352
+ }
353
+ };
354
+
355
+ const handleMessageForChat = async (chatId: string, message: ChatMessage): Promise<void> => {
356
+ const routed = routeMessage(message, config);
357
+ const effective = collapseDestination(routed, message.turnId);
358
+
359
+ if (effective.kind === 'drop') return;
360
+
361
+ if (effective.kind === 'thread-log') {
362
+ if (!message.turnId) {
363
+ console.warn(`thread-log event for ${message.role} has no turnId — dropping.`);
364
+ return;
365
+ }
366
+ // No turn context: turnStarted may have been missed (adapter restart,
367
+ // subscription reconnect) or the turn had no anchor (proactive / DM).
368
+ // Drop silently rather than flooding the chat.
369
+ if (!turnLog.has(message.turnId)) return;
370
+ turnLog.append(message.turnId, message);
371
+ return;
372
+ }
373
+
374
+ // Top-level.
375
+ if (message.role === 'policy' && message.status === 'pending') {
376
+ await sendPolicyCard(chatId, message);
377
+ return;
378
+ }
379
+
380
+ await sendTopLevel(chatId, message);
381
+ };
382
+
87
383
  const startSubscriptionForChat = async (chatId: string) => {
88
384
  if (activeSubscriptions.has(chatId)) return;
89
385
  if (signal?.aborted) return;
@@ -116,6 +412,15 @@ export async function startDaemonToDiscordForwarder(
116
412
  let subscription: { unsubscribe: () => void } | null = null;
117
413
  let messageQueue = Promise.resolve();
118
414
 
415
+ type StreamItem =
416
+ | { kind: 'message'; message: ChatMessage }
417
+ | {
418
+ kind: 'turn';
419
+ event:
420
+ | { type: 'started'; turnId: string; rootMessageId: string; externalRef?: string }
421
+ | { type: 'ended'; turnId: string; outcome: 'ok' | 'error' };
422
+ };
423
+
119
424
  const connect = () => {
120
425
  if (signal?.aborted || !activeSubscriptions.has(chatId)) {
121
426
  return;
@@ -124,141 +429,54 @@ export async function startDaemonToDiscordForwarder(
124
429
  subscription = trpc.waitForMessages.subscribe(
125
430
  { chatId, lastMessageId },
126
431
  {
127
- onData: (messages) => {
432
+ onData: (items) => {
128
433
  retryDelay = 1000; // Reset retry delay on successful data
129
434
 
130
- if (!Array.isArray(messages) || messages.length === 0) {
435
+ if (!Array.isArray(items) || items.length === 0) {
131
436
  return;
132
437
  }
133
438
 
134
- // Queue processing to ensure sequential execution
135
- messageQueue = messageQueue.then(async () => {
136
- for (const rawMessage of messages) {
137
- if (signal?.aborted || !activeSubscriptions.has(chatId)) break;
138
-
139
- const message = rawMessage as ChatMessage;
140
-
141
- const isDisplayed = shouldDisplayMessage(message, config);
142
-
143
- if (isDisplayed) {
144
- const logMessage = message;
145
- const isPolicyRequest =
146
- logMessage.role === 'policy' && logMessage.status === 'pending';
147
-
148
- if (isPolicyRequest) {
439
+ messageQueue = messageQueue
440
+ .then(async () => {
441
+ for (const raw of items) {
442
+ if (signal?.aborted || !activeSubscriptions.has(chatId)) break;
443
+
444
+ const item = raw as StreamItem;
445
+ if (item.kind === 'turn') {
446
+ // Turn events do disk reads (state.json) and Discord API
447
+ // fetches; either can throw transiently. Catch here so a
448
+ // single bad event doesn't reject the .then and poison
449
+ // the chain — every subsequent batch would silently no-op.
149
450
  try {
150
- const dm = await resolveDiscordDestination(client, discordUserId, chatId);
151
-
152
- const embed = new EmbedBuilder()
153
- .setTitle('Action Required: Policy Request')
154
- .setDescription(
155
- logMessage.content || 'A pending policy request requires your attention.'
156
- )
157
- .setColor(Colors.Yellow);
158
-
159
- const policyId =
160
- ('requestId' in logMessage && logMessage.requestId) || logMessage.id;
161
- const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
162
- new ButtonBuilder()
163
- .setCustomId(`approve|${policyId}|${chatId}`)
164
- .setLabel('Approve')
165
- .setStyle(ButtonStyle.Success),
166
- new ButtonBuilder()
167
- .setCustomId(`reject|${policyId}|${chatId}`)
168
- .setLabel('Reject')
169
- .setStyle(ButtonStyle.Danger)
170
- );
171
-
172
- const optionsMsg: MessageCreateOptions = {
173
- embeds: [embed],
174
- components: [row],
175
- };
176
-
177
- try {
178
- await dm.send(optionsMsg);
179
- } catch (richError) {
180
- console.warn(
181
- `Failed to send rich message to Discord user ${discordUserId}, falling back to plain text:`,
182
- richError
183
- );
184
- await dm.send({
185
- content: `Action Required: Policy Request\n\n${logMessage.content || 'A pending policy request requires your attention.'}\n\nApprove: \`/approve ${policyId}\`\nReject: \`/reject ${policyId} <optional_rationale>\``,
186
- });
451
+ if (item.event.type === 'started') {
452
+ await handleTurnStarted(chatId, item.event.turnId, item.event.externalRef);
453
+ } else {
454
+ await handleTurnEnded(item.event.turnId);
187
455
  }
188
- } catch (error) {
189
- console.error(
190
- `Failed to send message to Discord user ${discordUserId}:`,
191
- error
192
- );
456
+ } catch (err) {
457
+ console.error('Failed to handle turn event:', err);
193
458
  }
194
-
195
- await saveLastMessageId(chatId, logMessage.id).catch(console.error);
196
- lastMessageId = logMessage.id;
197
- continue;
198
- }
199
-
200
- if ('level' in logMessage && logMessage.level === 'verbose') {
201
- await saveLastMessageId(chatId, logMessage.id).catch(console.error);
202
- lastMessageId = logMessage.id;
203
- continue;
204
- }
205
-
206
- const hasContent = !!logMessage.content?.trim();
207
- const files = 'files' in logMessage ? (logMessage.files as string[]) : undefined;
208
- const hasFiles = Array.isArray(files) && files.length > 0;
209
-
210
- // The daemon stores logMessage.files as paths relative to the WORKSPACE directory
211
- // (the directory containing .clawmini). We must resolve these against the current
212
- // workspace root so discord.js can successfully locate and read the files.
213
- let absoluteFiles: string[] = [];
214
- if (hasFiles && files) {
215
- const workspaceRoot = getWorkspaceRoot(process.cwd());
216
- absoluteFiles = files.map((f) => path.resolve(workspaceRoot, f));
217
- }
218
-
219
- if (!hasContent && !hasFiles) {
220
- await saveLastMessageId(chatId, logMessage.id).catch(console.error);
221
- lastMessageId = logMessage.id;
222
459
  continue;
223
460
  }
224
461
 
462
+ const message = item.message;
225
463
  try {
226
- const dm = await resolveDiscordDestination(client, discordUserId, chatId);
227
- const formattedContent = formatMessage(message);
228
-
229
- if (formattedContent && formattedContent.length > 2000) {
230
- const chunks = chunkString(formattedContent, 2000);
231
- for (let i = 0; i < chunks.length; i++) {
232
- if (signal?.aborted || !activeSubscriptions.has(chatId)) break;
233
- const chunkOptions: MessageCreateOptions = { content: chunks[i] as string };
234
- if (i === chunks.length - 1 && hasFiles) {
235
- chunkOptions.files = absoluteFiles;
236
- }
237
- await dm.send(chunkOptions);
238
- }
239
- } else {
240
- const optionsMsg: MessageCreateOptions = {};
241
- if (formattedContent) {
242
- optionsMsg.content = formattedContent;
243
- }
244
- if (hasFiles) {
245
- optionsMsg.files = absoluteFiles;
246
- }
247
- await dm.send(optionsMsg);
248
- }
249
- } catch (error) {
250
- console.error(
251
- `Failed to send message to Discord user ${discordUserId}:`,
252
- error
253
- );
254
- break; // don't advance lastMessageId
464
+ await handleMessageForChat(chatId, message);
465
+ } catch (err) {
466
+ console.error('Failed to handle message:', err);
467
+ // Don't advance lastMessageId on a hard error so we retry on
468
+ // reconnect; matches prior behavior.
469
+ break;
255
470
  }
256
- }
257
471
 
258
- await saveLastMessageId(chatId, message.id).catch(console.error);
259
- lastMessageId = message.id;
260
- }
261
- });
472
+ await saveLastMessageId(chatId, message.id).catch(console.error);
473
+ lastMessageId = message.id;
474
+ }
475
+ })
476
+ // Belt-and-suspenders: anything that escapes the per-item
477
+ // try/catches above (sync throw before the loop, etc.) must
478
+ // not leave the chain in a rejected state.
479
+ .catch((err) => console.error('Message queue chain error:', err));
262
480
  },
263
481
  onError: (error) => {
264
482
  console.error(
@@ -299,7 +517,7 @@ export async function startDaemonToDiscordForwarder(
299
517
  { chatId },
300
518
  {
301
519
  onData: async (event) => {
302
- typingRetryDelay = 1000; // Reset retry delay on successful data
520
+ typingRetryDelay = 1000;
303
521
  if (!event) return;
304
522
 
305
523
  try {
@@ -416,6 +634,7 @@ export async function startDaemonToDiscordForwarder(
416
634
  watcher.close();
417
635
  for (const sub of activeSubscriptions.values()) sub.unsubscribe();
418
636
  for (const sub of activeTypingSubscriptions.values()) sub.unsubscribe();
637
+ turnLog.shutdown();
419
638
  resolve();
420
639
  });
421
640
  });
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import {
3
+ recordInbound,
4
+ resolveInbound,
5
+ INBOUND_TTL_MS,
6
+ _resetInboundCacheForTests,
7
+ } from './inbound-cache.js';
8
+
9
+ describe('discord inbound-cache', () => {
10
+ beforeEach(() => {
11
+ _resetInboundCacheForTests();
12
+ });
13
+
14
+ afterEach(() => {
15
+ vi.useRealTimers();
16
+ });
17
+
18
+ it('records and resolves an inbound by message id', () => {
19
+ recordInbound({ messageId: 'msg-1', channelId: 'chan-1' });
20
+ expect(resolveInbound('msg-1')).toMatchObject({
21
+ messageId: 'msg-1',
22
+ channelId: 'chan-1',
23
+ });
24
+ });
25
+
26
+ it('returns null for unknown keys', () => {
27
+ expect(resolveInbound('unknown')).toBeNull();
28
+ });
29
+
30
+ it('expires entries older than INBOUND_TTL_MS on resolve', () => {
31
+ vi.useFakeTimers();
32
+ recordInbound({ messageId: 'msg-1', channelId: 'chan-1' });
33
+ expect(resolveInbound('msg-1')).not.toBeNull();
34
+
35
+ vi.advanceTimersByTime(INBOUND_TTL_MS + 1000);
36
+ expect(resolveInbound('msg-1')).toBeNull();
37
+ });
38
+
39
+ it('sweeps expired entries on every insert', () => {
40
+ vi.useFakeTimers();
41
+ recordInbound({ messageId: 'msg-1', channelId: 'chan-1' });
42
+ vi.advanceTimersByTime(INBOUND_TTL_MS + 1000);
43
+ recordInbound({ messageId: 'msg-2', channelId: 'chan-2' });
44
+ expect(resolveInbound('msg-1')).toBeNull();
45
+ expect(resolveInbound('msg-2')).not.toBeNull();
46
+ });
47
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Discord-side wrapper around the shared inbound-message TTL cache.
3
+ *
4
+ * On every inbound user message, the gateway records `{ messageId, channelId
5
+ * }`. The same `messageId` is sent to the daemon as `externalRef` on the
6
+ * `sendMessage` mutation. When the forwarder later sees `turnStarted` with
7
+ * that `externalRef`, it resolves the channel + message id and starts a
8
+ * Discord thread anchored on the user's message.
9
+ */
10
+ import { createInboundCache } from '../shared/adapters/inbound-cache.js';
11
+
12
+ export const INBOUND_TTL_MS = 10 * 60 * 1000; // 10 minutes
13
+
14
+ interface DiscordInboundValue {
15
+ channelId: string;
16
+ }
17
+
18
+ const cache = createInboundCache<DiscordInboundValue>(INBOUND_TTL_MS);
19
+
20
+ export interface DiscordInboundRecord {
21
+ messageId: string;
22
+ channelId: string;
23
+ }
24
+
25
+ export function recordInbound(entry: DiscordInboundRecord): void {
26
+ cache.record(entry.messageId, { channelId: entry.channelId });
27
+ }
28
+
29
+ export function resolveInbound(messageId: string): DiscordInboundRecord | null {
30
+ const value = cache.resolve(messageId);
31
+ return value ? { messageId, channelId: value.channelId } : null;
32
+ }
33
+
34
+ /** Test hook: drop all cached records. */
35
+ export function _resetInboundCacheForTests(): void {
36
+ cache.reset();
37
+ }