discoclaw 0.1.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 (393) hide show
  1. package/.context/README.md +42 -0
  2. package/.context/architecture.md +58 -0
  3. package/.context/bot-setup.md +24 -0
  4. package/.context/dev.md +230 -0
  5. package/.context/discord.md +144 -0
  6. package/.context/memory.md +257 -0
  7. package/.context/ops.md +59 -0
  8. package/.context/pa-safety.md +47 -0
  9. package/.context/pa.md +118 -0
  10. package/.context/project.md +43 -0
  11. package/.context/runtime.md +253 -0
  12. package/.context/tasks.md +71 -0
  13. package/.context/tools.md +75 -0
  14. package/.env.example +88 -0
  15. package/.env.example.full +378 -0
  16. package/LICENSE +21 -0
  17. package/README.md +220 -0
  18. package/dist/beads/auto-tag.js +2 -0
  19. package/dist/beads/auto-tag.test.js +62 -0
  20. package/dist/beads/bd-cli.js +9 -0
  21. package/dist/beads/bd-cli.test.js +495 -0
  22. package/dist/beads/bead-hooks-cli.js +149 -0
  23. package/dist/beads/bead-sync-cli.js +5 -0
  24. package/dist/beads/bead-sync-cli.test.js +72 -0
  25. package/dist/beads/bead-sync-coordinator.js +4 -0
  26. package/dist/beads/bead-sync-coordinator.test.js +239 -0
  27. package/dist/beads/bead-sync-watcher.js +2 -0
  28. package/dist/beads/bead-sync-watcher.test.js +96 -0
  29. package/dist/beads/bead-sync.js +7 -0
  30. package/dist/beads/bead-sync.test.js +876 -0
  31. package/dist/beads/bead-thread-cache.js +8 -0
  32. package/dist/beads/bead-thread-cache.test.js +91 -0
  33. package/dist/beads/discord-sync.js +18 -0
  34. package/dist/beads/discord-sync.test.js +782 -0
  35. package/dist/beads/find-bead-by-thread.test.js +36 -0
  36. package/dist/beads/forum-guard.js +2 -0
  37. package/dist/beads/forum-guard.test.js +204 -0
  38. package/dist/beads/initialize.js +3 -0
  39. package/dist/beads/initialize.test.js +304 -0
  40. package/dist/beads/types.js +10 -0
  41. package/dist/cli/daemon-installer.js +225 -0
  42. package/dist/cli/daemon-installer.test.js +289 -0
  43. package/dist/cli/index.js +42 -0
  44. package/dist/cli/init-wizard.js +374 -0
  45. package/dist/cli/init-wizard.test.js +191 -0
  46. package/dist/config.js +385 -0
  47. package/dist/config.test.js +589 -0
  48. package/dist/cron/auto-tag.js +100 -0
  49. package/dist/cron/auto-tag.test.js +91 -0
  50. package/dist/cron/cadence.js +74 -0
  51. package/dist/cron/cadence.test.js +53 -0
  52. package/dist/cron/cron-sync-coordinator.js +66 -0
  53. package/dist/cron/cron-sync-coordinator.test.js +118 -0
  54. package/dist/cron/cron-sync.js +165 -0
  55. package/dist/cron/cron-sync.test.js +228 -0
  56. package/dist/cron/cron-tag-map-watcher.js +128 -0
  57. package/dist/cron/cron-tag-map-watcher.test.js +155 -0
  58. package/dist/cron/default-timezone.js +23 -0
  59. package/dist/cron/default-timezone.test.js +30 -0
  60. package/dist/cron/discord-sync.js +205 -0
  61. package/dist/cron/discord-sync.test.js +353 -0
  62. package/dist/cron/executor.js +303 -0
  63. package/dist/cron/executor.test.js +614 -0
  64. package/dist/cron/forum-sync.js +347 -0
  65. package/dist/cron/forum-sync.test.js +539 -0
  66. package/dist/cron/job-lock.js +164 -0
  67. package/dist/cron/job-lock.test.js +178 -0
  68. package/dist/cron/parser.js +68 -0
  69. package/dist/cron/parser.test.js +115 -0
  70. package/dist/cron/run-control.js +24 -0
  71. package/dist/cron/run-control.test.js +27 -0
  72. package/dist/cron/run-stats.js +265 -0
  73. package/dist/cron/run-stats.test.js +160 -0
  74. package/dist/cron/scheduler.js +97 -0
  75. package/dist/cron/scheduler.test.js +112 -0
  76. package/dist/cron/tag-map.js +47 -0
  77. package/dist/cron/tag-map.test.js +64 -0
  78. package/dist/cron/types.js +1 -0
  79. package/dist/discoclaw-plan-format.test.js +137 -0
  80. package/dist/discoclaw-recipe-format.test.js +137 -0
  81. package/dist/discord/abort-registry.js +70 -0
  82. package/dist/discord/action-categories.js +36 -0
  83. package/dist/discord/action-types.js +1 -0
  84. package/dist/discord/action-utils.js +58 -0
  85. package/dist/discord/action-utils.test.js +58 -0
  86. package/dist/discord/actions-beads.js +1 -0
  87. package/dist/discord/actions-beads.test.js +372 -0
  88. package/dist/discord/actions-bot-profile.js +107 -0
  89. package/dist/discord/actions-bot-profile.test.js +138 -0
  90. package/dist/discord/actions-channels.js +427 -0
  91. package/dist/discord/actions-channels.test.js +697 -0
  92. package/dist/discord/actions-config.js +173 -0
  93. package/dist/discord/actions-config.test.js +322 -0
  94. package/dist/discord/actions-crons.js +586 -0
  95. package/dist/discord/actions-crons.test.js +499 -0
  96. package/dist/discord/actions-defer.js +60 -0
  97. package/dist/discord/actions-defer.test.js +134 -0
  98. package/dist/discord/actions-forge.js +134 -0
  99. package/dist/discord/actions-forge.test.js +206 -0
  100. package/dist/discord/actions-guild.js +301 -0
  101. package/dist/discord/actions-guild.test.js +386 -0
  102. package/dist/discord/actions-memory.js +106 -0
  103. package/dist/discord/actions-memory.test.js +248 -0
  104. package/dist/discord/actions-messaging.js +401 -0
  105. package/dist/discord/actions-messaging.test.js +738 -0
  106. package/dist/discord/actions-moderation.js +65 -0
  107. package/dist/discord/actions-moderation.test.js +88 -0
  108. package/dist/discord/actions-plan.js +445 -0
  109. package/dist/discord/actions-plan.test.js +610 -0
  110. package/dist/discord/actions-poll.js +38 -0
  111. package/dist/discord/actions-poll.test.js +93 -0
  112. package/dist/discord/actions-tasks.js +3 -0
  113. package/dist/discord/actions-tasks.test.js +418 -0
  114. package/dist/discord/actions.js +600 -0
  115. package/dist/discord/actions.test.js +522 -0
  116. package/dist/discord/allowed-mentions.js +3 -0
  117. package/dist/discord/allowed-mentions.test.js +17 -0
  118. package/dist/discord/allowlist.js +29 -0
  119. package/dist/discord/allowlist.test.js +24 -0
  120. package/dist/discord/audit-handler.js +191 -0
  121. package/dist/discord/audit-handler.test.js +361 -0
  122. package/dist/discord/bot.js +141 -0
  123. package/dist/discord/channel-context.js +181 -0
  124. package/dist/discord/defer-scheduler.js +45 -0
  125. package/dist/discord/destructive-confirmation.js +128 -0
  126. package/dist/discord/destructive-confirmation.test.js +49 -0
  127. package/dist/discord/discord-plan-auto-implement.test.js +18 -0
  128. package/dist/discord/durable-memory.js +145 -0
  129. package/dist/discord/durable-memory.test.js +281 -0
  130. package/dist/discord/durable-write-queue.js +4 -0
  131. package/dist/discord/file-download.js +308 -0
  132. package/dist/discord/file-download.test.js +303 -0
  133. package/dist/discord/forge-audit-verdict.js +140 -0
  134. package/dist/discord/forge-auto-implement.js +80 -0
  135. package/dist/discord/forge-auto-implement.test.js +110 -0
  136. package/dist/discord/forge-commands.js +698 -0
  137. package/dist/discord/forge-commands.test.js +1606 -0
  138. package/dist/discord/forge-plan-registry.js +68 -0
  139. package/dist/discord/forge-plan-registry.test.js +127 -0
  140. package/dist/discord/forum-count-sync.js +130 -0
  141. package/dist/discord/forum-count-sync.test.js +200 -0
  142. package/dist/discord/health-command.js +98 -0
  143. package/dist/discord/health-command.test.js +195 -0
  144. package/dist/discord/help-command.js +22 -0
  145. package/dist/discord/help-command.test.js +49 -0
  146. package/dist/discord/image-download.js +201 -0
  147. package/dist/discord/image-download.test.js +499 -0
  148. package/dist/discord/inflight-replies.js +228 -0
  149. package/dist/discord/inflight-replies.test.js +295 -0
  150. package/dist/discord/json-extract.js +110 -0
  151. package/dist/discord/keyed-queue.js +22 -0
  152. package/dist/discord/memory-commands.js +85 -0
  153. package/dist/discord/memory-commands.test.js +159 -0
  154. package/dist/discord/memory-timing.integration.test.js +159 -0
  155. package/dist/discord/message-coordinator.js +2347 -0
  156. package/dist/discord/message-coordinator.onboarding.test.js +183 -0
  157. package/dist/discord/message-coordinator.plan-run.test.js +264 -0
  158. package/dist/discord/message-history.js +53 -0
  159. package/dist/discord/message-history.test.js +95 -0
  160. package/dist/discord/models-command.js +59 -0
  161. package/dist/discord/models-command.test.js +150 -0
  162. package/dist/discord/nickname.test.js +76 -0
  163. package/dist/discord/onboarding-completion.js +55 -0
  164. package/dist/discord/onboarding-completion.test.js +176 -0
  165. package/dist/discord/output-common.js +178 -0
  166. package/dist/discord/output-common.test.js +198 -0
  167. package/dist/discord/output-utils.js +156 -0
  168. package/dist/discord/parse-identity-name.test.js +129 -0
  169. package/dist/discord/plan-commands.js +612 -0
  170. package/dist/discord/plan-commands.test.js +1622 -0
  171. package/dist/discord/plan-manager.js +1491 -0
  172. package/dist/discord/plan-manager.test.js +2380 -0
  173. package/dist/discord/plan-parser.js +110 -0
  174. package/dist/discord/plan-parser.test.js +63 -0
  175. package/dist/discord/plan-run-phase-start.js +20 -0
  176. package/dist/discord/plan-run-phase-start.test.js +29 -0
  177. package/dist/discord/platform-message.js +45 -0
  178. package/dist/discord/platform-message.test.js +110 -0
  179. package/dist/discord/prompt-common.js +240 -0
  180. package/dist/discord/prompt-common.test.js +423 -0
  181. package/dist/discord/reaction-handler.js +691 -0
  182. package/dist/discord/reaction-handler.test.js +1574 -0
  183. package/dist/discord/reaction-prompts.js +118 -0
  184. package/dist/discord/reaction-prompts.test.js +253 -0
  185. package/dist/discord/reply-reference.js +66 -0
  186. package/dist/discord/reply-reference.test.js +125 -0
  187. package/dist/discord/restart-command.js +143 -0
  188. package/dist/discord/restart-command.test.js +196 -0
  189. package/dist/discord/runtime-utils.js +43 -0
  190. package/dist/discord/runtime-utils.test.js +112 -0
  191. package/dist/discord/session-key.js +7 -0
  192. package/dist/discord/session-key.test.js +13 -0
  193. package/dist/discord/shortterm-memory.js +166 -0
  194. package/dist/discord/shortterm-memory.test.js +345 -0
  195. package/dist/discord/shutdown-context.js +122 -0
  196. package/dist/discord/shutdown-context.test.js +279 -0
  197. package/dist/discord/startup-profile.test.js +214 -0
  198. package/dist/discord/status-channel.js +190 -0
  199. package/dist/discord/status-channel.test.js +282 -0
  200. package/dist/discord/status-command.js +206 -0
  201. package/dist/discord/status-command.test.js +341 -0
  202. package/dist/discord/streaming-progress.js +107 -0
  203. package/dist/discord/streaming-progress.test.js +93 -0
  204. package/dist/discord/summarizer.js +89 -0
  205. package/dist/discord/summarizer.test.js +245 -0
  206. package/dist/discord/system-bootstrap.js +396 -0
  207. package/dist/discord/system-bootstrap.test.js +724 -0
  208. package/dist/discord/thread-context.js +169 -0
  209. package/dist/discord/thread-context.test.js +386 -0
  210. package/dist/discord/tool-aware-queue.js +116 -0
  211. package/dist/discord/tool-aware-queue.test.js +180 -0
  212. package/dist/discord/update-command.js +127 -0
  213. package/dist/discord/update-command.test.js +275 -0
  214. package/dist/discord/user-errors.js +40 -0
  215. package/dist/discord/user-errors.test.js +31 -0
  216. package/dist/discord/user-turn-to-durable.js +111 -0
  217. package/dist/discord/user-turn-to-durable.test.js +273 -0
  218. package/dist/discord-followup.test.js +677 -0
  219. package/dist/discord.channel-context.test.js +95 -0
  220. package/dist/discord.fail-closed.test.js +199 -0
  221. package/dist/discord.health-command.integration.test.js +140 -0
  222. package/dist/discord.js +190 -0
  223. package/dist/discord.prompt-context.test.js +1431 -0
  224. package/dist/discord.render.test.js +621 -0
  225. package/dist/discord.status-wiring.test.js +187 -0
  226. package/dist/engine/claudeCli.js +137 -0
  227. package/dist/engine/types.js +1 -0
  228. package/dist/group-queue.js +25 -0
  229. package/dist/health/credential-check.js +175 -0
  230. package/dist/health/credential-check.test.js +401 -0
  231. package/dist/health/startup-healing.js +139 -0
  232. package/dist/health/startup-healing.test.js +298 -0
  233. package/dist/identity.js +36 -0
  234. package/dist/index.js +1378 -0
  235. package/dist/logging/logger-like.js +1 -0
  236. package/dist/observability/memory-sampler.js +51 -0
  237. package/dist/observability/memory-sampler.test.js +93 -0
  238. package/dist/observability/metrics.js +88 -0
  239. package/dist/observability/metrics.test.js +42 -0
  240. package/dist/onboarding/onboarding-flow.js +246 -0
  241. package/dist/onboarding/onboarding-flow.test.js +238 -0
  242. package/dist/onboarding/onboarding-writer.js +102 -0
  243. package/dist/onboarding/onboarding-writer.test.js +143 -0
  244. package/dist/pidlock.js +187 -0
  245. package/dist/pidlock.test.js +128 -0
  246. package/dist/pipeline/engine.js +206 -0
  247. package/dist/pipeline/engine.test.js +771 -0
  248. package/dist/root-policy.js +21 -0
  249. package/dist/root-policy.test.js +55 -0
  250. package/dist/runtime/claude-code-cli.js +35 -0
  251. package/dist/runtime/claude-code-cli.test.js +1199 -0
  252. package/dist/runtime/cli-adapter.js +584 -0
  253. package/dist/runtime/cli-output-parsers.js +108 -0
  254. package/dist/runtime/cli-shared.js +96 -0
  255. package/dist/runtime/cli-shared.test.js +104 -0
  256. package/dist/runtime/cli-strategy.js +6 -0
  257. package/dist/runtime/codex-cli.js +16 -0
  258. package/dist/runtime/codex-cli.test.js +862 -0
  259. package/dist/runtime/concurrency-limit.js +80 -0
  260. package/dist/runtime/concurrency-limit.test.js +137 -0
  261. package/dist/runtime/gemini-cli.js +16 -0
  262. package/dist/runtime/gemini-cli.test.js +413 -0
  263. package/dist/runtime/long-running-process.js +415 -0
  264. package/dist/runtime/long-running-process.test.js +318 -0
  265. package/dist/runtime/model-smoke-helpers.js +160 -0
  266. package/dist/runtime/model-smoke.test.js +194 -0
  267. package/dist/runtime/model-tiers.js +33 -0
  268. package/dist/runtime/model-tiers.test.js +65 -0
  269. package/dist/runtime/openai-auth.js +151 -0
  270. package/dist/runtime/openai-auth.test.js +361 -0
  271. package/dist/runtime/openai-compat.js +178 -0
  272. package/dist/runtime/openai-compat.test.js +449 -0
  273. package/dist/runtime/process-pool.js +93 -0
  274. package/dist/runtime/process-pool.test.js +148 -0
  275. package/dist/runtime/registry.js +15 -0
  276. package/dist/runtime/registry.test.js +47 -0
  277. package/dist/runtime/session-scanner.js +186 -0
  278. package/dist/runtime/session-scanner.test.js +257 -0
  279. package/dist/runtime/strategies/claude-strategy.js +193 -0
  280. package/dist/runtime/strategies/codex-strategy.js +161 -0
  281. package/dist/runtime/strategies/gemini-strategy.js +64 -0
  282. package/dist/runtime/strategies/template-strategy.js +85 -0
  283. package/dist/runtime/tool-capabilities.js +27 -0
  284. package/dist/runtime/tool-capabilities.test.js +24 -0
  285. package/dist/runtime/tool-labels.js +48 -0
  286. package/dist/runtime/types.js +2 -0
  287. package/dist/sessionManager.js +47 -0
  288. package/dist/sessions.js +18 -0
  289. package/dist/tasks/architecture-contract.js +33 -0
  290. package/dist/tasks/architecture-contract.test.js +90 -0
  291. package/dist/tasks/auto-tag.js +50 -0
  292. package/dist/tasks/auto-tag.test.js +64 -0
  293. package/dist/tasks/bd-cli.js +164 -0
  294. package/dist/tasks/bd-cli.test.js +359 -0
  295. package/dist/tasks/bead-sync.js +1 -0
  296. package/dist/tasks/context-summary.js +27 -0
  297. package/dist/tasks/discord-sync.js +3 -0
  298. package/dist/tasks/discord-sync.test.js +685 -0
  299. package/dist/tasks/discord-types.js +4 -0
  300. package/dist/tasks/find-task-by-thread.test.js +36 -0
  301. package/dist/tasks/forum-guard.js +81 -0
  302. package/dist/tasks/forum-guard.test.js +192 -0
  303. package/dist/tasks/initialize.js +77 -0
  304. package/dist/tasks/initialize.test.js +263 -0
  305. package/dist/tasks/logger-types.js +1 -0
  306. package/dist/tasks/metrics-types.js +3 -0
  307. package/dist/tasks/migrate.js +33 -0
  308. package/dist/tasks/migrate.test.js +156 -0
  309. package/dist/tasks/path-defaults.js +67 -0
  310. package/dist/tasks/path-defaults.test.js +73 -0
  311. package/dist/tasks/runtime-types.js +1 -0
  312. package/dist/tasks/service.js +33 -0
  313. package/dist/tasks/service.test.js +51 -0
  314. package/dist/tasks/store.js +238 -0
  315. package/dist/tasks/store.test.js +417 -0
  316. package/dist/tasks/sync-context.js +1 -0
  317. package/dist/tasks/sync-contract.js +24 -0
  318. package/dist/tasks/sync-contract.test.js +25 -0
  319. package/dist/tasks/sync-coordinator-metrics.js +41 -0
  320. package/dist/tasks/sync-coordinator-retries.js +71 -0
  321. package/dist/tasks/sync-coordinator.js +96 -0
  322. package/dist/tasks/sync-coordinator.test.js +501 -0
  323. package/dist/tasks/sync-types.js +1 -0
  324. package/dist/tasks/sync-watcher.js +27 -0
  325. package/dist/tasks/sync-watcher.test.js +92 -0
  326. package/dist/tasks/tag-map.js +36 -0
  327. package/dist/tasks/tag-map.test.js +54 -0
  328. package/dist/tasks/task-action-contract.js +16 -0
  329. package/dist/tasks/task-action-contract.test.js +16 -0
  330. package/dist/tasks/task-action-executor.js +18 -0
  331. package/dist/tasks/task-action-executor.test.js +420 -0
  332. package/dist/tasks/task-action-mutation-helpers.js +17 -0
  333. package/dist/tasks/task-action-mutations.js +151 -0
  334. package/dist/tasks/task-action-prompt.js +62 -0
  335. package/dist/tasks/task-action-read-ops.js +73 -0
  336. package/dist/tasks/task-action-runner-types.js +1 -0
  337. package/dist/tasks/task-action-thread-sync.js +82 -0
  338. package/dist/tasks/task-actions.js +3 -0
  339. package/dist/tasks/task-cli.js +227 -0
  340. package/dist/tasks/task-context.js +1 -0
  341. package/dist/tasks/task-lifecycle.js +46 -0
  342. package/dist/tasks/task-lifecycle.test.js +35 -0
  343. package/dist/tasks/task-sync-apply-plan.js +95 -0
  344. package/dist/tasks/task-sync-apply-types.js +12 -0
  345. package/dist/tasks/task-sync-apply.js +319 -0
  346. package/dist/tasks/task-sync-cli.js +89 -0
  347. package/dist/tasks/task-sync-cli.test.js +70 -0
  348. package/dist/tasks/task-sync-engine.js +88 -0
  349. package/dist/tasks/task-sync-engine.test.js +934 -0
  350. package/dist/tasks/task-sync-phase-apply.js +171 -0
  351. package/dist/tasks/task-sync-pipeline.js +2 -0
  352. package/dist/tasks/task-sync-pipeline.test.js +265 -0
  353. package/dist/tasks/task-sync-reconcile-plan.js +182 -0
  354. package/dist/tasks/task-sync-reconcile.js +144 -0
  355. package/dist/tasks/task-sync.js +56 -0
  356. package/dist/tasks/task-sync.test.js +86 -0
  357. package/dist/tasks/thread-cache.js +42 -0
  358. package/dist/tasks/thread-cache.test.js +89 -0
  359. package/dist/tasks/thread-contracts.test.js +711 -0
  360. package/dist/tasks/thread-forum-ops.js +68 -0
  361. package/dist/tasks/thread-helpers.js +86 -0
  362. package/dist/tasks/thread-helpers.test.js +33 -0
  363. package/dist/tasks/thread-lifecycle-ops.js +144 -0
  364. package/dist/tasks/thread-ops-shared.js +21 -0
  365. package/dist/tasks/thread-ops.js +2 -0
  366. package/dist/tasks/types.js +20 -0
  367. package/dist/tasks/types.test.js +60 -0
  368. package/dist/test-setup.js +11 -0
  369. package/dist/test-setup.test.js +42 -0
  370. package/dist/transport/types.js +1 -0
  371. package/dist/validate.js +41 -0
  372. package/dist/validate.test.js +94 -0
  373. package/dist/version.js +15 -0
  374. package/dist/version.test.js +31 -0
  375. package/dist/webhook/server.js +199 -0
  376. package/dist/webhook/server.test.js +460 -0
  377. package/dist/workspace-bootstrap.js +135 -0
  378. package/dist/workspace-bootstrap.test.js +514 -0
  379. package/dist/workspace-permissions.js +134 -0
  380. package/dist/workspace-permissions.test.js +181 -0
  381. package/package.json +74 -0
  382. package/scripts/cron/cron-tag-map.json +9 -0
  383. package/scripts/tasks/tag-map.json +10 -0
  384. package/systemd/discoclaw.service +19 -0
  385. package/templates/recipes/integration.discoclaw-recipe.md +171 -0
  386. package/templates/workspace/AGENTS.md +217 -0
  387. package/templates/workspace/BOOTSTRAP.md +1 -0
  388. package/templates/workspace/HEARTBEAT.md +10 -0
  389. package/templates/workspace/IDENTITY.md +16 -0
  390. package/templates/workspace/MEMORY.md +24 -0
  391. package/templates/workspace/SOUL.md +52 -0
  392. package/templates/workspace/TOOLS.md +304 -0
  393. package/templates/workspace/USER.md +37 -0
@@ -0,0 +1,2347 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { MAX_IMAGES_PER_INVOCATION } from '../runtime/types.js';
4
+ import { isAllowlisted } from './allowlist.js';
5
+ import { KeyedQueue } from '../group-queue.js';
6
+ import { ensureIndexedDiscordChannelContext, resolveDiscordChannelContext } from './channel-context.js';
7
+ import { discordSessionKey } from './session-key.js';
8
+ import { parseDiscordActions, executeDiscordActions, discordActionsPromptSection, buildDisplayResultLines, buildAllResultLines } from './actions.js';
9
+ import { hasQueryAction, QUERY_ACTION_TYPES } from './action-categories.js';
10
+ import { executePlanAction } from './actions-plan.js';
11
+ import { autoImplementForgePlan } from './forge-auto-implement.js';
12
+ import { fetchMessageHistory } from './message-history.js';
13
+ import { loadSummary, saveSummary, generateSummary } from './summarizer.js';
14
+ import { parseMemoryCommand, handleMemoryCommand } from './memory-commands.js';
15
+ import { parsePlanCommand, handlePlanCommand, preparePlanRun, handlePlanSkip, closePlanIfComplete, NO_PHASES_SENTINEL, findPlanFile, looksLikePlanId } from './plan-commands.js';
16
+ import { handlePlanAudit } from './audit-handler.js';
17
+ import { parseForgeCommand, ForgeOrchestrator, buildPlanImplementationMessage } from './forge-commands.js';
18
+ import { runNextPhase, resolveProjectCwd, readPhasesFile, buildPostRunSummary } from './plan-manager.js';
19
+ import { acquireWriterLock as registryAcquireWriterLock, setActiveOrchestrator, getActiveOrchestrator, addRunningPlan, removeRunningPlan, isPlanRunning, } from './forge-plan-registry.js';
20
+ import { applyUserTurnToDurable } from './user-turn-to-durable.js';
21
+ import { sanitizeErrorMessage, sanitizePhaseError } from './status-channel.js';
22
+ import { ToolAwareQueue } from './tool-aware-queue.js';
23
+ import { createStreamingProgress } from './streaming-progress.js';
24
+ import { NO_MENTIONS } from './allowed-mentions.js';
25
+ import { registerInFlightReply, isShuttingDown } from './inflight-replies.js';
26
+ import { registerAbort, tryAbortAll } from './abort-registry.js';
27
+ import { splitDiscord, truncateCodeBlocks, renderDiscordTail, renderActivityTail, formatBoldLabel, thinkingLabel, selectStreamingOutput, formatElapsed } from './output-utils.js';
28
+ import { buildContextFiles, inlineContextFiles, buildDurableMemorySection, buildShortTermMemorySection, buildTaskThreadSection, loadWorkspacePaFiles, loadWorkspaceMemoryFile, loadDailyLogFiles, resolveEffectiveTools, buildPromptPreamble } from './prompt-common.js';
29
+ import { taskThreadCache } from '../tasks/thread-cache.js';
30
+ import { buildTaskContextSummary } from '../tasks/context-summary.js';
31
+ import { TaskStore } from '../tasks/store.js';
32
+ import { isChannelPublic, appendEntry, buildExcerptSummary } from './shortterm-memory.js';
33
+ import { editThenSendChunks, shouldSuppressFollowUp, appendUnavailableActionTypesNotice } from './output-common.js';
34
+ import { downloadMessageImages, resolveMediaType } from './image-download.js';
35
+ import { resolveReplyReference } from './reply-reference.js';
36
+ import { resolveThreadContext } from './thread-context.js';
37
+ import { downloadTextAttachments } from './file-download.js';
38
+ import { messageContentIntentHint, mapRuntimeErrorToUserMessage } from './user-errors.js';
39
+ import { parseHelpCommand, handleHelpCommand } from './help-command.js';
40
+ import { parseHealthCommand, renderHealthReport, renderHealthToolsReport } from './health-command.js';
41
+ import { parseStatusCommand, collectStatusSnapshot, renderStatusReport } from './status-command.js';
42
+ import { parseRestartCommand, handleRestartCommand } from './restart-command.js';
43
+ import { parseModelsCommand, handleModelsCommand } from './models-command.js';
44
+ import { parseUpdateCommand, handleUpdateCommand } from './update-command.js';
45
+ import { consumeDestructiveConfirmation } from './destructive-confirmation.js';
46
+ import { globalMetrics } from '../observability/metrics.js';
47
+ import { OnboardingFlow } from '../onboarding/onboarding-flow.js';
48
+ import { completeOnboarding } from './onboarding-completion.js';
49
+ import { isOnboardingComplete } from '../workspace-bootstrap.js';
50
+ import { resolveModel } from '../runtime/model-tiers.js';
51
+ import { getDefaultTimezone } from '../cron/default-timezone.js';
52
+ // Re-export output-utils symbols for consumers that import them from discord.ts.
53
+ export { splitDiscord, truncateCodeBlocks, renderDiscordTail, renderActivityTail, formatBoldLabel, thinkingLabel, selectStreamingOutput, formatElapsed };
54
+ const turnCounters = new Map();
55
+ const summaryWorkQueue = new KeyedQueue();
56
+ const latestSummarySequence = new Map();
57
+ /** Timestamp of the most recent allowlisted message; read by the !status dashboard. */
58
+ let lastProcessedMessage = null;
59
+ const acquireWriterLock = registryAcquireWriterLock;
60
+ const MAX_PLAN_RUN_PHASES = 50;
61
+ async function gatherConversationContext(opts) {
62
+ const { msg, params, isThread, threadId, threadParentId } = opts;
63
+ const taskCtx = params.taskCtx;
64
+ let existingTaskId;
65
+ if (isThread && threadId && threadParentId && taskCtx) {
66
+ if (threadParentId === taskCtx.forumId) {
67
+ try {
68
+ const task = await taskThreadCache.get(threadId, taskCtx.store);
69
+ if (task)
70
+ existingTaskId = task.id;
71
+ }
72
+ catch {
73
+ // best-effort — fall through to create a new task.
74
+ }
75
+ }
76
+ }
77
+ const contextParts = [];
78
+ const replyRef = await resolveReplyReference(msg, params.botDisplayName, params.log);
79
+ if (replyRef?.section) {
80
+ contextParts.push(`Context (replied-to message):\n${replyRef.section}`);
81
+ }
82
+ const threadCtx = await resolveThreadContext(msg.channel, msg.id, { botDisplayName: params.botDisplayName, log: params.log });
83
+ if (threadCtx?.section) {
84
+ contextParts.push(threadCtx.section);
85
+ }
86
+ if (contextParts.length === 0 && params.messageHistoryBudget > 0) {
87
+ try {
88
+ const history = await fetchMessageHistory(msg.channel, msg.id, { budgetChars: params.messageHistoryBudget, botDisplayName: params.botDisplayName });
89
+ if (history) {
90
+ contextParts.push(`Context (recent channel messages):\n${history}`);
91
+ }
92
+ }
93
+ catch (err) {
94
+ params.log?.warn({ err }, 'discord:context history fallback failed');
95
+ }
96
+ }
97
+ const pinnedSummary = await resolvePinnedMessagesSummary(msg.channel, params.botDisplayName, params.log);
98
+ const context = contextParts.length > 0 ? contextParts.join('\n\n') : undefined;
99
+ return { context, pinnedSummary, existingTaskId };
100
+ }
101
+ async function resolvePinnedMessagesSummary(channel, botDisplayName, log, maxChars = 600) {
102
+ const fetchPinned = channel?.messages?.fetchPinned;
103
+ if (typeof fetchPinned !== 'function')
104
+ return undefined;
105
+ try {
106
+ const pinned = await channel.messages.fetchPinned();
107
+ if (!pinned || pinned.size === 0)
108
+ return undefined;
109
+ const lines = [];
110
+ let remaining = maxChars;
111
+ const maxMessages = 3;
112
+ for (const pinnedMsg of pinned.values()) {
113
+ if (lines.length >= maxMessages)
114
+ break;
115
+ let content = String(pinnedMsg.content ?? '').replace(/\s+/g, ' ').trim();
116
+ if (!content) {
117
+ if (pinnedMsg.attachments?.size) {
118
+ content = '[attachment]';
119
+ }
120
+ else if (Array.isArray(pinnedMsg.embeds) && pinnedMsg.embeds.length > 0) {
121
+ content = '[embed]';
122
+ }
123
+ else {
124
+ continue;
125
+ }
126
+ }
127
+ if (content.length > 200) {
128
+ content = content.slice(0, 200) + '…';
129
+ }
130
+ const author = pinnedMsg.author?.bot
131
+ ? (botDisplayName ?? 'Discoclaw')
132
+ : (pinnedMsg.author?.displayName || pinnedMsg.author?.username || 'Unknown');
133
+ const line = `[${author}]: ${content} (pinned id:${pinnedMsg.id})`;
134
+ if (remaining - line.length <= 0 && lines.length > 0)
135
+ break;
136
+ lines.push(line);
137
+ remaining -= line.length + 1;
138
+ }
139
+ if (lines.length === 0)
140
+ return undefined;
141
+ const header = pinned.size === 1 ? 'Pinned message:' : `Pinned messages (${pinned.size} total):`;
142
+ return [header, ...lines].join('\n');
143
+ }
144
+ catch (err) {
145
+ log?.warn({ err }, 'discord:context pinned fetch failed');
146
+ return undefined;
147
+ }
148
+ }
149
+ function parseConfirmToken(text) {
150
+ const m = /^!confirm\s+([a-z0-9_-]{6,64})\s*$/i.exec(text.trim());
151
+ return m?.[1] ?? null;
152
+ }
153
+ export function groupDirNameFromSessionKey(sessionKey) {
154
+ // Keep it filesystem-safe and easy to inspect.
155
+ return sessionKey.replace(/[^a-zA-Z0-9:_-]+/g, '-');
156
+ }
157
+ export async function ensureGroupDir(groupsDir, sessionKey, botDisplayName) {
158
+ const name = botDisplayName ?? 'Discoclaw';
159
+ const dir = path.join(groupsDir, groupDirNameFromSessionKey(sessionKey));
160
+ await fs.mkdir(dir, { recursive: true });
161
+ const claudeMd = path.join(dir, 'CLAUDE.md');
162
+ try {
163
+ await fs.stat(claudeMd);
164
+ }
165
+ catch (err) {
166
+ const code = err.code;
167
+ if (code !== 'ENOENT')
168
+ throw err;
169
+ // Minimal per-group instructions, mirroring the nanoclaw style.
170
+ const body = `# ${name} Group\n\n` +
171
+ `Session key: \`${sessionKey}\`\n\n` +
172
+ `This directory scopes conversation instructions for this Discord context.\n\n` +
173
+ `Notes:\n` +
174
+ `- The main workspace is mounted separately (see ${name} service env).\n` +
175
+ `- Keep instructions short and specific; prefer referencing files in the workspace.\n`;
176
+ await fs.writeFile(claudeMd, body, 'utf8');
177
+ }
178
+ return dir;
179
+ }
180
+ export function createMessageCreateHandler(params, queue, statusRef) {
181
+ // --- Onboarding state ---
182
+ let onboardingSession = null;
183
+ let activeOnboardingUserId = null;
184
+ const sessionCreationGuards = new Map();
185
+ const ONBOARDING_TIMEOUT_MS = 24 * 60 * 60 * 1000;
186
+ let onboardingTimeoutHandle = null;
187
+ let onboardingDisplayName = null;
188
+ let onboardingCtxRef = null;
189
+ function destroyOnboardingSession() {
190
+ onboardingSession = null;
191
+ activeOnboardingUserId = null;
192
+ onboardingDisplayName = null;
193
+ onboardingCtxRef = null;
194
+ if (onboardingTimeoutHandle) {
195
+ clearTimeout(onboardingTimeoutHandle);
196
+ onboardingTimeoutHandle = null;
197
+ }
198
+ }
199
+ function resetOnboardingTimeout() {
200
+ if (onboardingTimeoutHandle)
201
+ clearTimeout(onboardingTimeoutHandle);
202
+ onboardingTimeoutHandle = setTimeout(() => {
203
+ void (async () => {
204
+ const session = onboardingSession;
205
+ const displayName = onboardingDisplayName ?? 'there';
206
+ const ctxRef = onboardingCtxRef;
207
+ try {
208
+ if (!session || !ctxRef)
209
+ return;
210
+ const values = session.getValuesWithDefaults(displayName, getDefaultTimezone());
211
+ const sendTarget = ctxRef.client.channels.cache.get(ctxRef.channelId)
212
+ ?? { send: () => Promise.resolve() };
213
+ const cronDispatch = (params.cronCtx && ctxRef.guild) ? {
214
+ cronCtx: params.cronCtx,
215
+ actionCtx: {
216
+ guild: ctxRef.guild,
217
+ client: ctxRef.client,
218
+ channelId: ctxRef.channelId,
219
+ messageId: ctxRef.messageId,
220
+ },
221
+ log: params.log,
222
+ } : undefined;
223
+ await completeOnboarding(values, params.workspaceCwd, sendTarget, cronDispatch);
224
+ params.log?.info({ workspaceCwd: params.workspaceCwd }, 'onboarding:timeout-defaults:complete');
225
+ }
226
+ catch (err) {
227
+ params.log?.warn({ err }, 'onboarding:timeout-defaults:write failed');
228
+ }
229
+ finally {
230
+ destroyOnboardingSession();
231
+ }
232
+ })();
233
+ }, ONBOARDING_TIMEOUT_MS);
234
+ }
235
+ return async (msg) => {
236
+ try {
237
+ if (!msg?.author || msg.author.bot)
238
+ return;
239
+ // Skip system messages (joins, pins, boosts, etc.) — can't reply to them.
240
+ // Default = 0, Reply = 19; everything else is a system message.
241
+ const t = msg.type;
242
+ if (t != null && t !== 0 && t !== 19)
243
+ return;
244
+ const metrics = params.metrics ?? globalMetrics;
245
+ metrics.increment('discord.message.received');
246
+ if (!isAllowlisted(params.allowUserIds, msg.author.id))
247
+ return;
248
+ // Track last allowlisted message timestamp for !status dashboard.
249
+ lastProcessedMessage = Date.now();
250
+ const isDm = msg.guildId == null;
251
+ const actionFlags = {
252
+ channels: params.discordActionsChannels,
253
+ messaging: params.discordActionsMessaging,
254
+ guild: params.discordActionsGuild,
255
+ moderation: params.discordActionsModeration,
256
+ polls: params.discordActionsPolls,
257
+ tasks: params.discordActionsTasks ?? false,
258
+ crons: params.discordActionsCrons ?? false,
259
+ botProfile: params.discordActionsBotProfile ?? false,
260
+ forge: params.discordActionsForge ?? false,
261
+ plan: params.discordActionsPlan ?? false,
262
+ memory: params.discordActionsMemory ?? false,
263
+ config: params.discordActionsConfig ?? false,
264
+ defer: !isDm && (params.discordActionsDefer ?? false),
265
+ };
266
+ if (!isDm && params.allowChannelIds) {
267
+ const ch = msg.channel;
268
+ const isThread = typeof ch?.isThread === 'function' ? ch.isThread() : false;
269
+ const parentId = isThread ? String(ch.parentId ?? '') : '';
270
+ const allowed = params.allowChannelIds.has(msg.channelId) ||
271
+ (parentId && params.allowChannelIds.has(parentId));
272
+ if (!allowed)
273
+ return;
274
+ }
275
+ // Heuristic: detect missing Message Content Intent and return actionable guidance.
276
+ // This runs after channel gating so restricted channels remain silent.
277
+ if (msg.guildId != null &&
278
+ !msg.content &&
279
+ (!msg.attachments || msg.attachments.size === 0) &&
280
+ (!msg.stickers || msg.stickers.size === 0) &&
281
+ (!msg.embeds || msg.embeds.length === 0) &&
282
+ msg.mentions?.has(msg.client.user)) {
283
+ params.log?.warn({ channelId: msg.channelId, authorId: msg.author.id }, 'Received empty message content in guild — is Message Content Intent enabled in the Developer Portal?');
284
+ await msg.reply({ content: messageContentIntentHint(), allowedMentions: NO_MENTIONS });
285
+ return;
286
+ }
287
+ if (parseHelpCommand(String(msg.content ?? ''))) {
288
+ await msg.reply({ content: handleHelpCommand(), allowedMentions: NO_MENTIONS });
289
+ return;
290
+ }
291
+ // Handle !stop — abort all active AI streams and cancel any running forge.
292
+ if (String(msg.content ?? '').trim().toLowerCase() === '!stop') {
293
+ const aborted = tryAbortAll();
294
+ const orch = getActiveOrchestrator();
295
+ const forgeRunning = orch?.isRunning ?? false;
296
+ if (forgeRunning)
297
+ orch.requestCancel();
298
+ const parts = [];
299
+ if (aborted > 0)
300
+ parts.push(`Aborted ${aborted} active stream${aborted === 1 ? '' : 's'}.`);
301
+ if (forgeRunning)
302
+ parts.push('Forge cancel requested.');
303
+ if (parts.length === 0)
304
+ parts.push('Nothing active to stop.');
305
+ await msg.reply({ content: parts.join(' '), allowedMentions: NO_MENTIONS });
306
+ return;
307
+ }
308
+ // Handle !status command — at-a-glance runtime dashboard (live connectivity probes).
309
+ if (parseStatusCommand(String(msg.content ?? '')) && params.statusCommandContext) {
310
+ const ctx = params.statusCommandContext;
311
+ const snapshot = await collectStatusSnapshot({
312
+ startedAt: ctx.startedAt,
313
+ lastMessageAt: lastProcessedMessage,
314
+ scheduler: params.cronCtx?.scheduler ?? null,
315
+ taskStore: params.taskCtx?.store ?? null,
316
+ durableDataDir: params.durableDataDir,
317
+ summaryDataDir: params.summaryDataDir,
318
+ discordToken: ctx.discordToken,
319
+ openaiApiKey: ctx.openaiApiKey,
320
+ openaiBaseUrl: ctx.openaiBaseUrl,
321
+ openrouterApiKey: ctx.openrouterApiKey,
322
+ openrouterBaseUrl: ctx.openrouterBaseUrl,
323
+ paFilePaths: ctx.paFilePaths,
324
+ apiCheckTimeoutMs: ctx.apiCheckTimeoutMs,
325
+ activeProviders: ctx.activeProviders,
326
+ });
327
+ const report = renderStatusReport(snapshot, params.botDisplayName);
328
+ await msg.reply({ content: report, allowedMentions: NO_MENTIONS });
329
+ return;
330
+ }
331
+ const healthMode = (params.healthCommandsEnabled ?? true)
332
+ ? parseHealthCommand(String(msg.content ?? ''))
333
+ : null;
334
+ if (healthMode) {
335
+ if (healthMode === 'tools') {
336
+ const liveTools = await resolveEffectiveTools({
337
+ workspaceCwd: params.workspaceCwd,
338
+ runtimeTools: params.runtimeTools,
339
+ runtimeCapabilities: params.runtime.capabilities,
340
+ runtimeId: params.runtime.id,
341
+ log: params.log,
342
+ });
343
+ const toolsReport = renderHealthToolsReport({
344
+ permissionTier: liveTools.permissionTier,
345
+ effectiveTools: liveTools.effectiveTools,
346
+ configuredRuntimeTools: params.runtimeTools,
347
+ botDisplayName: params.botDisplayName,
348
+ });
349
+ await msg.reply({ content: toolsReport, allowedMentions: NO_MENTIONS });
350
+ return;
351
+ }
352
+ const verboseAllowed = !params.healthVerboseAllowlist
353
+ || params.healthVerboseAllowlist.size === 0
354
+ || params.healthVerboseAllowlist.has(msg.author.id);
355
+ const mode = healthMode === 'verbose' && verboseAllowed ? 'verbose' : 'basic';
356
+ // Fallback: dead code — healthConfigSnapshot is always provided by index.ts.
357
+ // Kept for type safety; task state fields may disagree with actual state.
358
+ const healthConfig = params.healthConfigSnapshot ?? {
359
+ runtimeModel: params.runtimeModel,
360
+ runtimeTimeoutMs: params.runtimeTimeoutMs,
361
+ runtimeTools: params.runtimeTools,
362
+ useRuntimeSessions: params.useRuntimeSessions,
363
+ toolAwareStreaming: Boolean(params.toolAwareStreaming),
364
+ maxConcurrentInvocations: 0,
365
+ discordActionsEnabled: params.discordActionsEnabled,
366
+ summaryEnabled: params.summaryEnabled,
367
+ durableMemoryEnabled: params.durableMemoryEnabled,
368
+ messageHistoryBudget: params.messageHistoryBudget,
369
+ reactionHandlerEnabled: params.reactionHandlerEnabled,
370
+ reactionRemoveHandlerEnabled: params.reactionRemoveHandlerEnabled,
371
+ cronEnabled: Boolean(params.cronCtx),
372
+ tasksEnabled: Boolean(params.taskCtx),
373
+ tasksActive: Boolean(params.taskCtx),
374
+ tasksSyncFailureRetryEnabled: true,
375
+ tasksSyncFailureRetryDelayMs: 30_000,
376
+ tasksSyncDeferredRetryDelayMs: 30_000,
377
+ requireChannelContext: params.requireChannelContext,
378
+ autoIndexChannelContext: params.autoIndexChannelContext,
379
+ };
380
+ const report = renderHealthReport({
381
+ metrics,
382
+ queueDepth: queue.size?.() ?? 0,
383
+ config: healthConfig,
384
+ mode,
385
+ botDisplayName: params.botDisplayName,
386
+ });
387
+ await msg.reply({ content: report, allowedMentions: NO_MENTIONS });
388
+ return;
389
+ }
390
+ // Handle !models commands — fast, synchronous, no queue needed.
391
+ const modelsCmd = parseModelsCommand(String(msg.content ?? ''));
392
+ if (modelsCmd) {
393
+ const response = handleModelsCommand(modelsCmd, {
394
+ configCtx: params.configCtx,
395
+ configEnabled: params.discordActionsEnabled && (params.discordActionsConfig ?? false),
396
+ });
397
+ await msg.reply({ content: response, allowedMentions: NO_MENTIONS });
398
+ return;
399
+ }
400
+ // Handle !restart commands before queue/session — this is a system command.
401
+ const restartCmd = parseRestartCommand(String(msg.content ?? ''));
402
+ if (restartCmd) {
403
+ const result = await handleRestartCommand(restartCmd, {
404
+ log: params.log,
405
+ dataDir: params.dataDir,
406
+ userId: msg.author.id,
407
+ activeForge: getActiveOrchestrator()?.activePlanId,
408
+ });
409
+ await msg.reply({ content: result.reply, allowedMentions: NO_MENTIONS });
410
+ // Deferred action (e.g., restart) runs after the reply is sent.
411
+ // The process will likely die during this call.
412
+ result.deferred?.();
413
+ return;
414
+ }
415
+ // Handle !update commands before queue/session — this is a system command.
416
+ const updateCmd = parseUpdateCommand(String(msg.content ?? ''));
417
+ if (updateCmd) {
418
+ const result = await handleUpdateCommand(updateCmd, {
419
+ log: params.log,
420
+ projectCwd: params.projectCwd,
421
+ dataDir: params.dataDir,
422
+ restartCmd: process.env.DC_RESTART_CMD,
423
+ });
424
+ await msg.reply({ content: result.reply, allowedMentions: NO_MENTIONS });
425
+ // Deferred action (e.g., restart after apply) runs after the reply is sent.
426
+ result.deferred?.();
427
+ return;
428
+ }
429
+ // --- Onboarding intercept ---
430
+ // When onboarding is incomplete, intercept messages before normal bot operation.
431
+ {
432
+ const messageText = String(msg.content ?? '').trim();
433
+ const userId = String(msg.author.id);
434
+ // 1. !cancel during active session → destroy session
435
+ if (messageText === '!cancel' && onboardingSession && activeOnboardingUserId === userId) {
436
+ destroyOnboardingSession();
437
+ await msg.reply({ content: 'Onboarding cancelled. Send me a message whenever you\'re ready to try again.', allowedMentions: NO_MENTIONS });
438
+ return;
439
+ }
440
+ // 2. Active session → check timeout, then forward to flow
441
+ if (onboardingSession && activeOnboardingUserId === userId) {
442
+ // Check timeout
443
+ if (Date.now() - onboardingSession.lastActivityTimestamp > ONBOARDING_TIMEOUT_MS) {
444
+ const session = onboardingSession;
445
+ const displayName = onboardingDisplayName ?? 'there';
446
+ const ctxRef = onboardingCtxRef;
447
+ const channelMode = onboardingSession.channelMode;
448
+ destroyOnboardingSession();
449
+ if (session && ctxRef) {
450
+ const values = session.getValuesWithDefaults(displayName, getDefaultTimezone());
451
+ const sendTarget = channelMode === 'dm' ? msg.author : msg.channel;
452
+ const cronDispatch = (params.cronCtx && ctxRef.guild) ? {
453
+ cronCtx: params.cronCtx,
454
+ actionCtx: {
455
+ guild: ctxRef.guild,
456
+ client: ctxRef.client,
457
+ channelId: ctxRef.channelId,
458
+ messageId: ctxRef.messageId,
459
+ },
460
+ log: params.log,
461
+ } : undefined;
462
+ try {
463
+ await completeOnboarding(values, params.workspaceCwd, sendTarget, cronDispatch);
464
+ params.log?.info({ workspaceCwd: params.workspaceCwd }, 'onboarding:restart-timeout-defaults:complete');
465
+ }
466
+ catch (err) {
467
+ params.log?.warn({ err }, 'onboarding:restart-timeout-defaults:write failed');
468
+ }
469
+ }
470
+ return;
471
+ }
472
+ // Route: only accept input from the correct channel.
473
+ // If the message is in the wrong channel, send a one-time redirect notice
474
+ // and fall through to normal bot operation (non-blocking passthrough).
475
+ let passThroughToNormal = false;
476
+ if (onboardingSession.channelMode === 'dm' && !isDm) {
477
+ // Message is in a guild channel but onboarding is in DMs
478
+ if (!onboardingSession.hasRedirected) {
479
+ onboardingSession.hasRedirected = true;
480
+ await msg.reply({ content: 'I\'m setting things up with you in DMs — check your messages!', allowedMentions: NO_MENTIONS });
481
+ }
482
+ passThroughToNormal = true;
483
+ }
484
+ else if (onboardingSession.channelMode === 'guild' && msg.channelId !== onboardingSession.channelId) {
485
+ // Message is in a different guild channel than where onboarding is happening
486
+ if (!onboardingSession.hasRedirected) {
487
+ onboardingSession.hasRedirected = true;
488
+ await msg.reply({ content: `I'm setting things up with you in <#${onboardingSession.channelId}> — head over there to continue!`, allowedMentions: NO_MENTIONS });
489
+ }
490
+ passThroughToNormal = true;
491
+ }
492
+ if (!passThroughToNormal) {
493
+ // Forward to flow
494
+ resetOnboardingTimeout();
495
+ const result = onboardingSession.handleInput(messageText);
496
+ if (result.writeResult === 'pending') {
497
+ // Send the "writing..." message first
498
+ await msg.reply({ content: result.reply, allowedMentions: NO_MENTIONS });
499
+ // Call the writer
500
+ try {
501
+ const values = onboardingSession.getValues();
502
+ const sendTarget = onboardingSession.channelMode === 'dm' ? msg.author : msg.channel;
503
+ const cronDispatch = (params.cronCtx && onboardingCtxRef?.guild) ? {
504
+ cronCtx: params.cronCtx,
505
+ actionCtx: {
506
+ guild: onboardingCtxRef.guild,
507
+ client: onboardingCtxRef.client,
508
+ channelId: onboardingCtxRef.channelId,
509
+ messageId: onboardingCtxRef.messageId,
510
+ },
511
+ log: params.log,
512
+ } : undefined;
513
+ const { writeResult } = await completeOnboarding(values, params.workspaceCwd, sendTarget, cronDispatch);
514
+ if (writeResult.errors.length > 0) {
515
+ onboardingSession.markWriteFailed(writeResult.errors.join('; '));
516
+ }
517
+ else {
518
+ onboardingSession.markWriteComplete();
519
+ destroyOnboardingSession();
520
+ params.log?.info({ workspaceCwd: params.workspaceCwd }, 'onboarding:complete');
521
+ }
522
+ }
523
+ catch (err) {
524
+ params.log?.error({ err }, 'onboarding:write failed');
525
+ onboardingSession.markWriteFailed(String(err));
526
+ const sendTarget = onboardingSession.channelMode === 'dm' ? msg.author : msg.channel;
527
+ try {
528
+ await sendTarget.send({
529
+ content: `Something went wrong writing your files: ${String(err)}\nType **retry** to try again or \`!cancel\` to give up.`,
530
+ allowedMentions: NO_MENTIONS,
531
+ });
532
+ }
533
+ catch {
534
+ // If we can't even send the error, destroy the session
535
+ destroyOnboardingSession();
536
+ }
537
+ }
538
+ }
539
+ else if (result.reply) {
540
+ // Normal flow step — send the reply (guard against empty content from DONE state)
541
+ await msg.channel.send({ content: result.reply, allowedMentions: NO_MENTIONS });
542
+ }
543
+ return;
544
+ }
545
+ }
546
+ // 3. Active session for a different user → tell them to wait, then fall through to normal operation
547
+ if (onboardingSession && activeOnboardingUserId && activeOnboardingUserId !== userId) {
548
+ const onboarded = await isOnboardingComplete(params.workspaceCwd);
549
+ if (!onboarded) {
550
+ await msg.reply({ content: 'Someone else is already setting me up — hang tight and try again in a minute.', allowedMentions: NO_MENTIONS });
551
+ // Fall through to normal bot operation (non-blocking passthrough)
552
+ }
553
+ else {
554
+ // If somehow onboarding completed externally, clear the stale session
555
+ destroyOnboardingSession();
556
+ }
557
+ }
558
+ // 4. No active session → check if onboarding is needed
559
+ if (!onboardingSession) {
560
+ const onboarded = await isOnboardingComplete(params.workspaceCwd);
561
+ // Only start onboarding if the workspace was bootstrapped (IDENTITY.md exists).
562
+ // If IDENTITY.md doesn't exist at all, the workspace wasn't set up — skip.
563
+ const identityExists = await fs.access(path.join(params.workspaceCwd, 'IDENTITY.md')).then(() => true, () => false);
564
+ if (!onboarded && identityExists) {
565
+ // Ignore !cancel when no session exists
566
+ if (messageText === '!cancel') {
567
+ await msg.reply({ content: 'Nothing to cancel.', allowedMentions: NO_MENTIONS });
568
+ return;
569
+ }
570
+ // Race guard: prevent duplicate session creation from rapid messages
571
+ const existingGuard = sessionCreationGuards.get(userId);
572
+ if (existingGuard) {
573
+ await existingGuard;
574
+ // Re-check after guard resolves — session may now exist
575
+ if (onboardingSession)
576
+ return;
577
+ }
578
+ const guard = (async () => {
579
+ // Re-check after acquiring guard
580
+ if (onboardingSession)
581
+ return;
582
+ activeOnboardingUserId = userId;
583
+ onboardingSession = new OnboardingFlow();
584
+ const displayName = msg.author.displayName || msg.author.username || 'there';
585
+ onboardingDisplayName = displayName;
586
+ onboardingCtxRef = {
587
+ guild: msg.guild,
588
+ client: msg.client,
589
+ channelId: msg.channelId,
590
+ messageId: msg.id,
591
+ channelName: msg.channel?.name ?? msg.channelId,
592
+ };
593
+ resetOnboardingTimeout();
594
+ const startResult = onboardingSession.start(displayName);
595
+ if (isDm) {
596
+ // Already in DMs — just send the greeting
597
+ onboardingSession.channelMode = 'dm';
598
+ await msg.reply({ content: startResult.reply, allowedMentions: NO_MENTIONS });
599
+ }
600
+ else {
601
+ // Try to DM the user
602
+ try {
603
+ await msg.author.send({ content: startResult.reply, allowedMentions: NO_MENTIONS });
604
+ onboardingSession.channelMode = 'dm';
605
+ await msg.reply({ content: 'Let\'s set up in DMs — check your messages!', allowedMentions: NO_MENTIONS });
606
+ }
607
+ catch (dmErr) {
608
+ // DM failed — fall back to guild channel
609
+ params.log?.info({ userId, channelId: msg.channelId, error: dmErr?.message }, 'onboarding:dm-failed, falling back to guild channel');
610
+ onboardingSession.channelMode = 'guild';
611
+ onboardingSession.channelId = msg.channelId;
612
+ try {
613
+ await msg.reply({
614
+ content: 'I can\'t DM you — looks like your DMs are disabled for this server. No worries, we can set up right here!\n\n' + startResult.reply,
615
+ allowedMentions: NO_MENTIONS,
616
+ });
617
+ }
618
+ catch {
619
+ // Both DM and guild reply failed — destroy session
620
+ destroyOnboardingSession();
621
+ }
622
+ }
623
+ }
624
+ })().finally(() => sessionCreationGuards.delete(userId));
625
+ sessionCreationGuards.set(userId, guard);
626
+ await guard;
627
+ return;
628
+ }
629
+ }
630
+ }
631
+ const isThread = typeof msg.channel?.isThread === 'function' ? msg.channel.isThread() : false;
632
+ const threadId = isThread ? String(msg.channel.id ?? '') : null;
633
+ const threadParentId = isThread ? String(msg.channel.parentId ?? '') : null;
634
+ const shouldSendManualPlanCta = (result) => !result.error && !!result.planId && !result.reachedMaxRounds && result.finalVerdict !== 'CANCELLED';
635
+ async function sendForgeImplementationFollowup(result) {
636
+ const planId = result.planId;
637
+ const manualEligible = shouldSendManualPlanCta(result);
638
+ let attemptResult;
639
+ if (params.forgeAutoImplement && manualEligible && planId) {
640
+ attemptResult = await sendAutoImplementOutcome(result);
641
+ if (attemptResult.autoStarted) {
642
+ return;
643
+ }
644
+ }
645
+ if (!manualEligible || !planId)
646
+ return;
647
+ const skipReason = attemptResult?.skipReason;
648
+ if (skipReason) {
649
+ params.log?.info({ planId, skipReason }, 'forge:auto-implement:skipped');
650
+ }
651
+ const manualMessage = buildPlanImplementationMessage(skipReason, planId);
652
+ try {
653
+ await msg.channel.send({ content: manualMessage, allowedMentions: NO_MENTIONS });
654
+ }
655
+ catch (err) {
656
+ params.log?.warn({ err, planId }, 'forge:auto-implement: manual CTA send failed');
657
+ }
658
+ }
659
+ async function sendAutoImplementOutcome(result) {
660
+ const planId = result.planId;
661
+ const plansDir = path.join(params.workspaceCwd, 'plans');
662
+ // Deferred promise: onRunComplete waits for the outcome message to exist before editing it,
663
+ // eliminating the race where "Plan run complete" could appear before "Plan run started".
664
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
665
+ let resolveOutcomeMsg;
666
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
667
+ const outcomeMsgPromise = new Promise((resolve) => { resolveOutcomeMsg = resolve; });
668
+ const planCtx = {
669
+ plansDir,
670
+ workspaceCwd: params.workspaceCwd,
671
+ taskStore: params.planCtx?.taskStore ?? (params.taskCtx)?.store ?? new TaskStore(),
672
+ log: params.log,
673
+ depth: 0,
674
+ runtime: params.runtime,
675
+ model: resolveModel(params.runtimeModel, params.runtime.id),
676
+ phaseTimeoutMs: params.planPhaseTimeoutMs ?? 5 * 60_000,
677
+ maxAuditFixAttempts: params.planPhaseMaxAuditFixAttempts,
678
+ maxPlanRunPhases: MAX_PLAN_RUN_PHASES,
679
+ skipCompletionNotify: true,
680
+ onProgress: async (progressMsg) => {
681
+ params.log?.info({ planId: result.planId, progress: progressMsg }, 'plan:auto-implement:progress');
682
+ },
683
+ onRunComplete: async (finalContent) => {
684
+ const sentMsg = await outcomeMsgPromise;
685
+ if (sentMsg) {
686
+ try {
687
+ await sentMsg.edit({ content: finalContent, allowedMentions: NO_MENTIONS });
688
+ }
689
+ catch {
690
+ // best-effort
691
+ }
692
+ }
693
+ else {
694
+ try {
695
+ await msg.channel.send({ content: finalContent, allowedMentions: NO_MENTIONS });
696
+ }
697
+ catch {
698
+ // best-effort
699
+ }
700
+ }
701
+ },
702
+ };
703
+ const actionCtx = {
704
+ guild: msg.guild ?? {},
705
+ client: msg.client,
706
+ channelId: msg.channelId,
707
+ messageId: msg.id,
708
+ threadParentId,
709
+ deferScheduler: params.deferScheduler,
710
+ };
711
+ const deps = {
712
+ planApprove: async (planId) => {
713
+ const approveResult = await executePlanAction({ type: 'planApprove', planId }, actionCtx, planCtx);
714
+ if (!approveResult.ok) {
715
+ throw new Error(approveResult.error ?? 'plan approval failed');
716
+ }
717
+ },
718
+ planRun: async (planId) => {
719
+ const runResult = await executePlanAction({ type: 'planRun', planId }, actionCtx, planCtx);
720
+ if (!runResult.ok) {
721
+ throw new Error(runResult.error ?? 'plan run failed');
722
+ }
723
+ return { summary: runResult.summary ?? '' };
724
+ },
725
+ isPlanRunning,
726
+ log: params.log,
727
+ };
728
+ let content;
729
+ let autoStarted = false;
730
+ let skipReason;
731
+ try {
732
+ const outcome = await autoImplementForgePlan({ planId, result }, deps);
733
+ if (outcome.status === 'auto') {
734
+ content = outcome.summary;
735
+ autoStarted = true;
736
+ }
737
+ else {
738
+ content = outcome.message;
739
+ skipReason = outcome.message;
740
+ }
741
+ }
742
+ catch (err) {
743
+ params.log?.error({ err, planId }, 'forge:auto-implement: handler failed');
744
+ const fallbackMessage = planId
745
+ ? buildPlanImplementationMessage(undefined, planId)
746
+ : 'Review the plan manually, then use `!plan approve <id>` and `!plan run <id>` to continue.';
747
+ content = fallbackMessage;
748
+ skipReason = content;
749
+ }
750
+ try {
751
+ const sentMsg = await msg.channel.send({ content, allowedMentions: NO_MENTIONS });
752
+ resolveOutcomeMsg(sentMsg);
753
+ }
754
+ catch (err) {
755
+ params.log?.warn({ err, planId }, 'forge:auto-implement: follow-up send failed');
756
+ resolveOutcomeMsg(null);
757
+ }
758
+ return { autoStarted, skipReason };
759
+ }
760
+ const sessionKey = discordSessionKey({
761
+ channelId: msg.channelId,
762
+ authorId: msg.author.id,
763
+ isDm,
764
+ threadId: threadId || null,
765
+ });
766
+ let pendingSummaryWork = null;
767
+ let pendingShortTermAppend = null;
768
+ await queue.run(sessionKey, async () => {
769
+ let reply = null;
770
+ let abortSignal;
771
+ try {
772
+ // Handle !memory commands before session creation or the "..." placeholder.
773
+ if (params.memoryCommandsEnabled) {
774
+ const cmd = parseMemoryCommand(String(msg.content ?? ''));
775
+ if (cmd) {
776
+ const ch = msg.channel;
777
+ const channelName = String(ch?.name ?? '');
778
+ const response = await handleMemoryCommand(cmd, {
779
+ userId: msg.author.id,
780
+ sessionKey,
781
+ durableDataDir: params.durableDataDir,
782
+ durableMaxItems: params.durableMaxItems,
783
+ durableInjectMaxChars: params.durableInjectMaxChars,
784
+ summaryDataDir: params.summaryDataDir,
785
+ channelId: msg.channelId,
786
+ messageId: msg.id,
787
+ guildId: msg.guildId ?? undefined,
788
+ channelName: channelName || undefined,
789
+ });
790
+ if (cmd.action === 'reset-rolling') {
791
+ turnCounters.delete(sessionKey);
792
+ }
793
+ await msg.reply({ content: response, allowedMentions: NO_MENTIONS });
794
+ return;
795
+ }
796
+ }
797
+ // Handle !plan commands before session creation.
798
+ if (params.planCommandsEnabled) {
799
+ const planCmd = parsePlanCommand(String(msg.content ?? ''));
800
+ if (planCmd) {
801
+ const planOpts = {
802
+ workspaceCwd: params.workspaceCwd,
803
+ taskStore: params.planCtx?.taskStore ?? (params.taskCtx)?.store ?? new TaskStore(),
804
+ maxContextFiles: params.planPhaseMaxContextFiles,
805
+ };
806
+ // Phase-related commands require PLAN_PHASES_ENABLED
807
+ if (planCmd.action === 'run' || planCmd.action === 'run-one' || planCmd.action === 'skip' || planCmd.action === 'phases') {
808
+ if (!(params.planPhasesEnabled ?? true)) {
809
+ await msg.reply({
810
+ content: 'Phase decomposition is disabled. Set PLAN_PHASES_ENABLED=true to enable.',
811
+ allowedMentions: NO_MENTIONS,
812
+ });
813
+ return;
814
+ }
815
+ }
816
+ // --- !plan run / !plan run-one --- (shared handler, async, fire-and-forget)
817
+ if (planCmd.action === 'run' || planCmd.action === 'run-one') {
818
+ const isRunOne = planCmd.action === 'run-one';
819
+ const maxPhases = isRunOne ? 1 : MAX_PLAN_RUN_PHASES;
820
+ const usageCmd = isRunOne ? 'run-one' : 'run';
821
+ if (!planCmd.args) {
822
+ await msg.reply({ content: `Usage: \`!plan ${usageCmd} <plan-id>\``, allowedMentions: NO_MENTIONS });
823
+ return;
824
+ }
825
+ const planId = planCmd.args;
826
+ // Concurrency guard: reject if a multi-phase run is already active for this plan
827
+ if (isPlanRunning(planId)) {
828
+ await msg.reply({ content: `A multi-phase run is already in progress for ${planId}.`, allowedMentions: NO_MENTIONS });
829
+ return;
830
+ }
831
+ addRunningPlan(planId);
832
+ try { // outer try: guarantees addRunningPlan cleanup
833
+ // Acquire lock for initial validation only
834
+ let phasesFilePath;
835
+ let planFilePath;
836
+ let projectCwd;
837
+ let progressReply;
838
+ const validationLock = await acquireWriterLock();
839
+ try {
840
+ const prepResult = await preparePlanRun(planId, planOpts);
841
+ if ('error' in prepResult) {
842
+ // Distinguish "all done" from actual errors via NO_PHASES_SENTINEL
843
+ const isAllDone = prepResult.error.startsWith(NO_PHASES_SENTINEL);
844
+ const content = isAllDone
845
+ ? `All phases already complete for ${planId}.`
846
+ : prepResult.error;
847
+ await msg.reply({ content, allowedMentions: NO_MENTIONS });
848
+ validationLock();
849
+ removeRunningPlan(planId);
850
+ return;
851
+ }
852
+ phasesFilePath = prepResult.phasesFilePath;
853
+ planFilePath = prepResult.planFilePath;
854
+ try {
855
+ projectCwd = resolveProjectCwd(prepResult.planContent, params.workspaceCwd);
856
+ }
857
+ catch (err) {
858
+ await msg.reply({
859
+ content: `Failed to resolve project directory: ${String(err instanceof Error ? err.message : err)}`,
860
+ allowedMentions: NO_MENTIONS,
861
+ });
862
+ validationLock();
863
+ removeRunningPlan(planId);
864
+ return;
865
+ }
866
+ const startMsg = isRunOne
867
+ ? `Running ${prepResult.nextPhase.id}: ${prepResult.nextPhase.title}...`
868
+ : `Running all phases for **${planId}** — starting ${prepResult.nextPhase.id}: ${prepResult.nextPhase.title}...`;
869
+ progressReply = await msg.reply({ content: startMsg, allowedMentions: NO_MENTIONS });
870
+ }
871
+ catch (err) {
872
+ validationLock();
873
+ throw err; // outer catch cleans up running plan tracking
874
+ }
875
+ validationLock(); // release validation lock before phase execution
876
+ const planRunStreaming = createStreamingProgress(progressReply, params.forgeProgressThrottleMs ?? 3000);
877
+ const postedPhaseStarts = new Set();
878
+ const phaseStartMessages = new Map();
879
+ const postPhaseStart = async (event) => {
880
+ if (event.type === 'phase_start') {
881
+ if (postedPhaseStarts.has(event.phase.id))
882
+ return;
883
+ postedPhaseStarts.add(event.phase.id);
884
+ try {
885
+ const phaseMsg = await msg.channel.send({
886
+ content: `**${event.phase.title}**...`,
887
+ allowedMentions: NO_MENTIONS,
888
+ });
889
+ phaseStartMessages.set(event.phase.id, phaseMsg);
890
+ }
891
+ catch (err) {
892
+ params.log?.warn({ err, planId, phaseId: event.phase.id }, 'plan-run: phase-start post failed');
893
+ }
894
+ }
895
+ else if (event.type === 'phase_complete') {
896
+ const phaseMsg = phaseStartMessages.get(event.phase.id);
897
+ if (!phaseMsg)
898
+ return;
899
+ const indicator = event.status === 'done' ? '[x]' : event.status === 'failed' ? '[!]' : '[-]';
900
+ try {
901
+ await phaseMsg.edit({
902
+ content: `${indicator} **${event.phase.title}**`,
903
+ allowedMentions: NO_MENTIONS,
904
+ });
905
+ }
906
+ catch (err) {
907
+ params.log?.warn({ err, planId, phaseId: event.phase.id }, 'plan-run: phase-complete edit failed');
908
+ }
909
+ }
910
+ };
911
+ const onProgress = async (progressMsg, opts) => {
912
+ // Always force so phase-start/boundary messages are never throttled away
913
+ await planRunStreaming.onProgress(progressMsg, { force: opts?.force ?? true });
914
+ };
915
+ const onPlanRunEvent = params.toolAwareStreaming
916
+ ? planRunStreaming.onEvent
917
+ : undefined;
918
+ const timeoutMs = params.planPhaseTimeoutMs ?? 5 * 60_000;
919
+ // Register plan run with abort registry so !stop can kill it.
920
+ const planAbort = registerAbort(msg.id);
921
+ const phaseOpts = {
922
+ runtime: params.runtime,
923
+ model: resolveModel(params.runtimeModel, params.runtime.id),
924
+ projectCwd,
925
+ addDirs: [],
926
+ timeoutMs,
927
+ workspaceCwd: params.workspaceCwd,
928
+ log: params.log,
929
+ maxAuditFixAttempts: params.planPhaseMaxAuditFixAttempts,
930
+ onEvent: onPlanRunEvent,
931
+ onPlanEvent: postPhaseStart,
932
+ signal: planAbort.signal,
933
+ };
934
+ const editSummary = async (content) => {
935
+ try {
936
+ await progressReply.edit({ content, allowedMentions: NO_MENTIONS });
937
+ }
938
+ catch (editErr) {
939
+ if (editErr?.code === 10008) {
940
+ try {
941
+ await msg.channel.send({ content, allowedMentions: NO_MENTIONS });
942
+ }
943
+ catch { /* best-effort */ }
944
+ }
945
+ }
946
+ };
947
+ // Fire-and-forget: phase execution loop
948
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
949
+ (async () => {
950
+ const phaseResults = [];
951
+ let phasesRun = 0;
952
+ let stopReason = null;
953
+ let stopMessage = '';
954
+ let i = 0;
955
+ try {
956
+ for (; i < maxPhases; i++) {
957
+ if (isShuttingDown()) {
958
+ stopReason = 'shutdown';
959
+ break;
960
+ }
961
+ const releaseLock = await acquireWriterLock();
962
+ let phaseResult;
963
+ const phaseStart = Date.now();
964
+ try {
965
+ phaseResult = await runNextPhase(phasesFilePath, planFilePath, phaseOpts, onProgress);
966
+ }
967
+ finally {
968
+ releaseLock();
969
+ }
970
+ if (phaseResult.result === 'done') {
971
+ phasesRun++;
972
+ phaseResults.push({ id: phaseResult.phase.id, title: phaseResult.phase.title, elapsedMs: Date.now() - phaseStart });
973
+ // Between-phase progress update (bypass throttle)
974
+ try {
975
+ const nextNote = phaseResult.nextPhase
976
+ ? ` Next: ${phaseResult.nextPhase.id}: ${phaseResult.nextPhase.title}...`
977
+ : '';
978
+ await onProgress(`Phase **${phaseResult.phase.id}** done.${nextNote}`);
979
+ }
980
+ catch { /* edit failure doesn't break the loop */ }
981
+ }
982
+ else if (phaseResult.result === 'nothing_to_run') {
983
+ break;
984
+ }
985
+ else if (phaseResult.result === 'failed') {
986
+ stopReason = 'error';
987
+ stopMessage = sanitizePhaseError(phaseResult.phase.id, phaseResult.error, timeoutMs);
988
+ break;
989
+ }
990
+ else if (phaseResult.result === 'audit_failed') {
991
+ stopReason = 'error';
992
+ const fixNote = phaseResult.fixAttemptsUsed != null
993
+ ? ` after ${phaseResult.fixAttemptsUsed} automatic fix attempt(s)`
994
+ : '';
995
+ stopMessage = `Audit phase **${phaseResult.phase.id}** found **${phaseResult.verdict.maxSeverity}** severity deviations${fixNote}. Use \`!plan run ${planId}\` to re-run the audit, \`!plan skip ${planId}\` to skip it, or \`!plan phases --regenerate ${planId}\` to regenerate phases.`;
996
+ break;
997
+ }
998
+ else if (phaseResult.result === 'stale') {
999
+ stopReason = 'error';
1000
+ stopMessage = phaseResult.message;
1001
+ break;
1002
+ }
1003
+ else if (phaseResult.result === 'corrupt') {
1004
+ stopReason = 'error';
1005
+ stopMessage = phaseResult.message;
1006
+ break;
1007
+ }
1008
+ else if (phaseResult.result === 'retry_blocked') {
1009
+ stopReason = 'error';
1010
+ stopMessage = `Phase **${phaseResult.phase.id}** retry blocked. Use \`!plan skip ${planId}\` or \`!plan phases --regenerate ${planId}\`.`;
1011
+ break;
1012
+ }
1013
+ else {
1014
+ break;
1015
+ }
1016
+ // Yield between phases to prevent writer lock starvation
1017
+ await new Promise(resolve => setImmediate(resolve));
1018
+ }
1019
+ if (i >= maxPhases && !stopReason)
1020
+ stopReason = 'limit';
1021
+ }
1022
+ catch (loopErr) {
1023
+ stopReason = 'error';
1024
+ stopMessage = `Unexpected error: ${sanitizeErrorMessage(String(loopErr))}`;
1025
+ params.log?.error({ err: loopErr, phasesRun, planId }, 'plan-run: crash in phase loop');
1026
+ }
1027
+ planRunStreaming.dispose();
1028
+ // Build summary — always runs regardless of how the loop terminated
1029
+ const fmtElapsed = (ms) => ms < 1000 ? `${ms}ms` : `${Math.round(ms / 1000)}s`;
1030
+ const phaseList = phaseResults.map(p => `[x] ${p.id}: ${p.title} (${fmtElapsed(p.elapsedMs)})`).join('\n');
1031
+ let summaryMsg;
1032
+ if (isRunOne) {
1033
+ // Single-phase format (matches old !plan run UX)
1034
+ if (stopReason === 'error') {
1035
+ summaryMsg = `${stopMessage}\nUse \`!plan run-one ${planId}\` to retry or \`!plan skip ${planId}\` to skip.`;
1036
+ }
1037
+ else if (phasesRun > 0) {
1038
+ const p = phaseResults[0];
1039
+ summaryMsg = `Phase **${p.id}** done: ${p.title}`;
1040
+ }
1041
+ else {
1042
+ summaryMsg = 'All phases are done (or dependencies unmet).';
1043
+ }
1044
+ }
1045
+ else if (stopReason === null && phasesRun > 0) {
1046
+ const totalMs = phaseResults.reduce((s, p) => s + p.elapsedMs, 0);
1047
+ summaryMsg = `Plan run complete for **${planId}**: ${phasesRun} phase${phasesRun !== 1 ? 's' : ''} executed (${fmtElapsed(totalMs)})\n${phaseList}`;
1048
+ }
1049
+ else if (stopReason === null && phasesRun === 0) {
1050
+ summaryMsg = `All phases already complete for ${planId}.`;
1051
+ }
1052
+ else if (stopReason === 'error') {
1053
+ summaryMsg = `Plan run stopped: ${stopMessage}. ${phasesRun}/${phasesRun + 1} phases completed.\nUse \`!plan run ${planId}\` to retry or \`!plan skip ${planId}\` to skip.`;
1054
+ if (phaseList)
1055
+ summaryMsg += `\n${phaseList}`;
1056
+ }
1057
+ else if (stopReason === 'limit') {
1058
+ summaryMsg = `Plan run stopped after ${MAX_PLAN_RUN_PHASES} phases (safety limit). Use \`!plan run ${planId}\` to continue.\n${phaseList}`;
1059
+ }
1060
+ else {
1061
+ // shutdown
1062
+ summaryMsg = `Plan run interrupted (bot shutting down). ${phasesRun} phase${phasesRun !== 1 ? 's' : ''} completed.`;
1063
+ if (phaseList)
1064
+ summaryMsg += `\n${phaseList}`;
1065
+ }
1066
+ if (!isRunOne && (phasesRun > 0 || stopReason === null)) {
1067
+ try {
1068
+ const phases = readPhasesFile(phasesFilePath, { log: params.log });
1069
+ const budget = 2000 - summaryMsg.length - 50;
1070
+ const postRunSummary = buildPostRunSummary(phases, budget);
1071
+ if (postRunSummary) {
1072
+ summaryMsg += `\n${postRunSummary}`;
1073
+ }
1074
+ }
1075
+ catch (summaryErr) {
1076
+ params.log?.error({ err: summaryErr }, 'plan-run: failed to build post-run summary');
1077
+ }
1078
+ }
1079
+ await editSummary(summaryMsg);
1080
+ // Post a separate final summary message in the channel flow (full runs only)
1081
+ if (!isRunOne) {
1082
+ try {
1083
+ await msg.channel.send({ content: summaryMsg, allowedMentions: NO_MENTIONS });
1084
+ }
1085
+ catch (err) {
1086
+ params.log?.warn({ err, planId }, 'plan-run: final summary channel post failed');
1087
+ }
1088
+ }
1089
+ // Auto-close plan if all phases are terminal
1090
+ const closeResult = await closePlanIfComplete(phasesFilePath, planFilePath, planOpts.taskStore, acquireWriterLock, params.log);
1091
+ if (closeResult.closed) {
1092
+ await editSummary(summaryMsg + '\n\nPlan and backing task auto-closed.');
1093
+ }
1094
+ })().then(() => { }, (err) => {
1095
+ params.log?.error({ err }, 'plan-run:unhandled error');
1096
+ (async () => {
1097
+ try {
1098
+ const errMsg = `Plan run crashed: ${sanitizeErrorMessage(String(err))}`;
1099
+ await progressReply.edit({ content: errMsg, allowedMentions: NO_MENTIONS });
1100
+ }
1101
+ catch (editErr) {
1102
+ if (editErr?.code === 10008) {
1103
+ try {
1104
+ await msg.channel.send({ content: `Plan run crashed: ${sanitizeErrorMessage(String(err))}`, allowedMentions: NO_MENTIONS });
1105
+ }
1106
+ catch { /* best-effort */ }
1107
+ }
1108
+ }
1109
+ })().catch(() => { });
1110
+ }).catch((err) => {
1111
+ params.log?.error({ err }, 'plan-run: unhandled rejection in callback');
1112
+ }).finally(() => {
1113
+ planAbort.dispose();
1114
+ removeRunningPlan(planId);
1115
+ });
1116
+ }
1117
+ catch (err) {
1118
+ removeRunningPlan(planId);
1119
+ throw err;
1120
+ }
1121
+ return;
1122
+ }
1123
+ // --- !plan skip ---
1124
+ if (planCmd.action === 'skip') {
1125
+ if (!planCmd.args) {
1126
+ await msg.reply({ content: 'Usage: `!plan skip <plan-id>`', allowedMentions: NO_MENTIONS });
1127
+ return;
1128
+ }
1129
+ const releaseLock = await acquireWriterLock();
1130
+ try {
1131
+ const response = await handlePlanSkip(planCmd.args, planOpts);
1132
+ await msg.reply({ content: response, allowedMentions: NO_MENTIONS });
1133
+ }
1134
+ finally {
1135
+ releaseLock();
1136
+ }
1137
+ return;
1138
+ }
1139
+ // --- !plan audit --- (async, fire-and-forget — AI audit can take 30-60s)
1140
+ if (planCmd.action === 'audit') {
1141
+ if (!planCmd.args) {
1142
+ await msg.reply({ content: 'Usage: `!plan audit <plan-id>`', allowedMentions: NO_MENTIONS });
1143
+ return;
1144
+ }
1145
+ const auditPlanId = planCmd.args;
1146
+ const progressReply = await msg.reply({
1147
+ content: `Auditing **${auditPlanId}**...`,
1148
+ allowedMentions: NO_MENTIONS,
1149
+ });
1150
+ const plansDir = path.join(params.workspaceCwd, 'plans');
1151
+ const rawAuditorModel = params.forgeAuditorModel ?? params.runtimeModel;
1152
+ const timeoutMs = params.forgeTimeoutMs ?? 5 * 60_000;
1153
+ const auditRt = params.auditorRuntime ?? params.runtime;
1154
+ const hasExplicitAuditorModel = Boolean(params.forgeAuditorModel);
1155
+ const effectiveAuditModel = auditRt.id === 'claude_code'
1156
+ ? resolveModel(rawAuditorModel, auditRt.id)
1157
+ : (hasExplicitAuditorModel ? resolveModel(rawAuditorModel, auditRt.id) : '');
1158
+ // Resolve project root so the auditor can read source code
1159
+ let auditProjectCwd;
1160
+ try {
1161
+ const auditFound = await findPlanFile(plansDir, auditPlanId);
1162
+ if (!auditFound) {
1163
+ await progressReply.edit({ content: `Audit failed: plan not found: ${auditPlanId}`, allowedMentions: NO_MENTIONS });
1164
+ return;
1165
+ }
1166
+ const auditPlanContent = await fs.readFile(auditFound.filePath, 'utf-8');
1167
+ auditProjectCwd = resolveProjectCwd(auditPlanContent, params.workspaceCwd);
1168
+ }
1169
+ catch (err) {
1170
+ await progressReply.edit({ content: `Audit failed: ${String(err instanceof Error ? err.message : err)}`, allowedMentions: NO_MENTIONS });
1171
+ return;
1172
+ }
1173
+ handlePlanAudit({
1174
+ planId: auditPlanId,
1175
+ plansDir,
1176
+ cwd: auditProjectCwd,
1177
+ workspaceCwd: params.workspaceCwd,
1178
+ runtime: params.runtime,
1179
+ auditorRuntime: params.auditorRuntime,
1180
+ auditorModel: effectiveAuditModel,
1181
+ timeoutMs,
1182
+ acquireWriterLock,
1183
+ }).then(async (result) => {
1184
+ try {
1185
+ if (result.ok) {
1186
+ const verdictText = result.verdict.shouldLoop ? 'needs revision' : 'ready to approve';
1187
+ await progressReply.edit({
1188
+ content: `Audit complete for **${result.planId}** — review ${result.round}, verdict: **${result.verdict.maxSeverity}** (${verdictText}). See \`!plan show ${result.planId}\` for details.`,
1189
+ allowedMentions: NO_MENTIONS,
1190
+ });
1191
+ }
1192
+ else {
1193
+ await progressReply.edit({
1194
+ content: `Audit failed for **${auditPlanId}**: ${result.error}`,
1195
+ allowedMentions: NO_MENTIONS,
1196
+ });
1197
+ }
1198
+ }
1199
+ catch {
1200
+ // edit failure (message deleted, etc.) — best-effort
1201
+ }
1202
+ }, async (err) => {
1203
+ try {
1204
+ await progressReply.edit({
1205
+ content: `Audit failed for **${auditPlanId}**: ${String(err)}`,
1206
+ allowedMentions: NO_MENTIONS,
1207
+ });
1208
+ }
1209
+ catch {
1210
+ // best-effort
1211
+ }
1212
+ });
1213
+ return;
1214
+ }
1215
+ // --- !plan phases --- (acquires lock for write, releases early for read)
1216
+ if (planCmd.action === 'phases') {
1217
+ const releaseLock = await acquireWriterLock();
1218
+ try {
1219
+ const response = await handlePlanCommand(planCmd, planOpts);
1220
+ await msg.reply({ content: response, allowedMentions: NO_MENTIONS });
1221
+ }
1222
+ finally {
1223
+ releaseLock();
1224
+ }
1225
+ return;
1226
+ }
1227
+ // All other plan actions pass through.
1228
+ // For create, include reply context so "!plan fix this" knows what "this" is.
1229
+ // Context travels separately so slug/task/title stay clean.
1230
+ let effectivePlanCmd = planCmd;
1231
+ if (planCmd.action === 'create' && planCmd.args) {
1232
+ const ctxResult = await gatherConversationContext({
1233
+ msg,
1234
+ params,
1235
+ isThread,
1236
+ threadId,
1237
+ threadParentId,
1238
+ });
1239
+ let planContext = ctxResult.context;
1240
+ if (ctxResult.pinnedSummary) {
1241
+ planContext = planContext
1242
+ ? `${planContext}\n\n${ctxResult.pinnedSummary}`
1243
+ : ctxResult.pinnedSummary;
1244
+ }
1245
+ if (planContext) {
1246
+ effectivePlanCmd = {
1247
+ ...planCmd,
1248
+ context: planContext,
1249
+ existingTaskId: ctxResult.existingTaskId,
1250
+ };
1251
+ }
1252
+ else if (ctxResult.existingTaskId) {
1253
+ effectivePlanCmd = { ...planCmd, existingTaskId: ctxResult.existingTaskId };
1254
+ }
1255
+ }
1256
+ const response = await handlePlanCommand(effectivePlanCmd, planOpts);
1257
+ await msg.reply({ content: response, allowedMentions: NO_MENTIONS });
1258
+ return;
1259
+ }
1260
+ }
1261
+ // Handle !forge commands — long-running, async plan creation.
1262
+ if (params.forgeCommandsEnabled) {
1263
+ const forgeCmd = parseForgeCommand(String(msg.content ?? ''));
1264
+ if (forgeCmd) {
1265
+ if (forgeCmd.action === 'help') {
1266
+ await msg.reply({
1267
+ content: [
1268
+ '**!forge commands:**',
1269
+ '- `!forge <description>` — auto-draft and audit a plan',
1270
+ '- `!forge status` — check if a forge is running',
1271
+ '- `!forge cancel` — cancel the running forge',
1272
+ ].join('\n'),
1273
+ allowedMentions: NO_MENTIONS,
1274
+ });
1275
+ return;
1276
+ }
1277
+ if (forgeCmd.action === 'status') {
1278
+ const running = getActiveOrchestrator()?.isRunning ?? false;
1279
+ await msg.reply({
1280
+ content: running ? 'A forge is currently running.' : 'No forge running.',
1281
+ allowedMentions: NO_MENTIONS,
1282
+ });
1283
+ return;
1284
+ }
1285
+ if (forgeCmd.action === 'cancel') {
1286
+ const orch = getActiveOrchestrator();
1287
+ if (orch?.isRunning) {
1288
+ orch.requestCancel();
1289
+ await msg.reply({ content: 'Forge cancel requested.', allowedMentions: NO_MENTIONS });
1290
+ }
1291
+ else {
1292
+ await msg.reply({ content: 'No forge running to cancel.', allowedMentions: NO_MENTIONS });
1293
+ }
1294
+ return;
1295
+ }
1296
+ // action === 'create'
1297
+ if (getActiveOrchestrator()?.isRunning) {
1298
+ await msg.reply({
1299
+ content: 'A forge is already running. Use `!forge cancel` to stop it first.',
1300
+ allowedMentions: NO_MENTIONS,
1301
+ });
1302
+ return;
1303
+ }
1304
+ // --- Detect plan-ID references (resume existing plan) ---
1305
+ if (looksLikePlanId(forgeCmd.args)) {
1306
+ const plansDir = path.join(params.workspaceCwd, 'plans');
1307
+ const found = await findPlanFile(plansDir, forgeCmd.args);
1308
+ if (!found) {
1309
+ await msg.reply({
1310
+ content: `No plan found matching "${forgeCmd.args}". Use \`!forge <description>\` to create a new plan.`,
1311
+ allowedMentions: NO_MENTIONS,
1312
+ });
1313
+ return;
1314
+ }
1315
+ // Resume path — resolve project root from existing plan content
1316
+ let resumeProjectCwd;
1317
+ try {
1318
+ const resumePlanContent = await fs.readFile(found.filePath, 'utf-8');
1319
+ resumeProjectCwd = resolveProjectCwd(resumePlanContent, params.workspaceCwd);
1320
+ }
1321
+ catch (err) {
1322
+ await msg.reply({
1323
+ content: `Failed to resolve project directory: ${String(err instanceof Error ? err.message : err)}`,
1324
+ allowedMentions: NO_MENTIONS,
1325
+ });
1326
+ return;
1327
+ }
1328
+ const forgeReleaseLock = await acquireWriterLock();
1329
+ const resumeOrchestrator = new ForgeOrchestrator({
1330
+ runtime: params.runtime,
1331
+ drafterRuntime: params.drafterRuntime,
1332
+ auditorRuntime: params.auditorRuntime,
1333
+ model: resolveModel(params.runtimeModel, params.runtime.id),
1334
+ cwd: resumeProjectCwd,
1335
+ workspaceCwd: params.workspaceCwd,
1336
+ taskStore: params.forgeCtx?.taskStore ?? (params.taskCtx)?.store ?? new TaskStore(),
1337
+ plansDir,
1338
+ maxAuditRounds: params.forgeMaxAuditRounds ?? 5,
1339
+ progressThrottleMs: params.forgeProgressThrottleMs ?? 3000,
1340
+ timeoutMs: params.forgeTimeoutMs ?? 5 * 60_000,
1341
+ drafterModel: params.forgeDrafterModel,
1342
+ auditorModel: params.forgeAuditorModel,
1343
+ log: params.log,
1344
+ });
1345
+ setActiveOrchestrator(resumeOrchestrator);
1346
+ const progressReply = await msg.reply({
1347
+ content: `Re-auditing **${found.header.planId}**...`,
1348
+ allowedMentions: NO_MENTIONS,
1349
+ });
1350
+ const forgeResumeStreaming = createStreamingProgress(progressReply, params.forgeProgressThrottleMs ?? 3000);
1351
+ const onProgress = async (progressMsg, opts) => {
1352
+ await forgeResumeStreaming.onProgress(progressMsg, opts);
1353
+ };
1354
+ const forgeResumeOnEvent = params.toolAwareStreaming
1355
+ ? forgeResumeStreaming.onEvent
1356
+ : undefined;
1357
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
1358
+ resumeOrchestrator.resume(found.header.planId, found.filePath, found.header.title, onProgress, forgeResumeOnEvent).then(async (result) => {
1359
+ forgeResumeStreaming.dispose();
1360
+ setActiveOrchestrator(null);
1361
+ forgeReleaseLock();
1362
+ // On message-gone (10008), onProgress already handled the channel.send fallback;
1363
+ // if result has an error, the orchestrator's error path already called onProgress.
1364
+ if (result.planSummary && !result.error) {
1365
+ try {
1366
+ await msg.channel.send({ content: result.planSummary, allowedMentions: NO_MENTIONS });
1367
+ }
1368
+ catch {
1369
+ // best-effort
1370
+ }
1371
+ }
1372
+ await sendForgeImplementationFollowup(result);
1373
+ }, async (err) => {
1374
+ forgeResumeStreaming.dispose();
1375
+ setActiveOrchestrator(null);
1376
+ forgeReleaseLock();
1377
+ params.log?.error({ err }, 'forge:resume:unhandled error');
1378
+ try {
1379
+ const errMsg = `Forge resume crashed: ${sanitizeErrorMessage(String(err))}`;
1380
+ await progressReply.edit({ content: errMsg, allowedMentions: NO_MENTIONS });
1381
+ }
1382
+ catch (editErr) {
1383
+ if (editErr?.code === 10008) {
1384
+ try {
1385
+ await msg.channel.send({ content: `Forge resume crashed: ${sanitizeErrorMessage(String(err))}`, allowedMentions: NO_MENTIONS });
1386
+ }
1387
+ catch { /* best-effort */ }
1388
+ }
1389
+ }
1390
+ }).catch((err) => {
1391
+ params.log?.error({ err }, 'forge:resume: unhandled rejection in callback');
1392
+ });
1393
+ return;
1394
+ }
1395
+ const ctxResult = await gatherConversationContext({
1396
+ msg,
1397
+ params,
1398
+ isThread,
1399
+ threadId,
1400
+ threadParentId,
1401
+ });
1402
+ const taskSummary = buildTaskContextSummary(ctxResult.existingTaskId, (params.taskCtx)?.store);
1403
+ const forgeContextParts = [];
1404
+ if (ctxResult.context)
1405
+ forgeContextParts.push(ctxResult.context);
1406
+ if (taskSummary?.summary)
1407
+ forgeContextParts.push(taskSummary.summary);
1408
+ if (ctxResult.pinnedSummary)
1409
+ forgeContextParts.push(ctxResult.pinnedSummary);
1410
+ const forgeContext = forgeContextParts.length > 0
1411
+ ? forgeContextParts.join('\n\n')
1412
+ : undefined;
1413
+ const forgeReleaseLock = await acquireWriterLock();
1414
+ const plansDir = path.join(params.workspaceCwd, 'plans');
1415
+ const createOrchestrator = new ForgeOrchestrator({
1416
+ runtime: params.runtime,
1417
+ drafterRuntime: params.drafterRuntime,
1418
+ auditorRuntime: params.auditorRuntime,
1419
+ model: resolveModel(params.runtimeModel, params.runtime.id),
1420
+ cwd: params.projectCwd,
1421
+ workspaceCwd: params.workspaceCwd,
1422
+ taskStore: params.forgeCtx?.taskStore ?? (params.taskCtx)?.store ?? new TaskStore(),
1423
+ plansDir,
1424
+ maxAuditRounds: params.forgeMaxAuditRounds ?? 5,
1425
+ progressThrottleMs: params.forgeProgressThrottleMs ?? 3000,
1426
+ timeoutMs: params.forgeTimeoutMs ?? 5 * 60_000,
1427
+ drafterModel: params.forgeDrafterModel,
1428
+ auditorModel: params.forgeAuditorModel,
1429
+ log: params.log,
1430
+ existingTaskId: ctxResult.existingTaskId,
1431
+ taskDescription: taskSummary?.description,
1432
+ pinnedThreadSummary: ctxResult.pinnedSummary,
1433
+ });
1434
+ setActiveOrchestrator(createOrchestrator);
1435
+ // Send initial progress message
1436
+ const progressReply = await msg.reply({
1437
+ content: `Starting forge: ${forgeCmd.args}`,
1438
+ allowedMentions: NO_MENTIONS,
1439
+ });
1440
+ const forgeCreateStreaming = createStreamingProgress(progressReply, params.forgeProgressThrottleMs ?? 3000);
1441
+ const onProgress = async (progressMsg, opts) => {
1442
+ await forgeCreateStreaming.onProgress(progressMsg, opts);
1443
+ };
1444
+ const forgeCreateOnEvent = params.toolAwareStreaming
1445
+ ? forgeCreateStreaming.onEvent
1446
+ : undefined;
1447
+ // Run forge in the background — don't block the queue
1448
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
1449
+ createOrchestrator.run(forgeCmd.args, onProgress, forgeContext, forgeCreateOnEvent).then(async (result) => {
1450
+ forgeCreateStreaming.dispose();
1451
+ setActiveOrchestrator(null);
1452
+ forgeReleaseLock();
1453
+ // Send plan summary as a follow-up message
1454
+ if (result.planSummary && !result.error) {
1455
+ try {
1456
+ await msg.channel.send({ content: result.planSummary, allowedMentions: NO_MENTIONS });
1457
+ }
1458
+ catch {
1459
+ // best-effort
1460
+ }
1461
+ }
1462
+ await sendForgeImplementationFollowup(result);
1463
+ }, async (err) => {
1464
+ forgeCreateStreaming.dispose();
1465
+ setActiveOrchestrator(null);
1466
+ forgeReleaseLock();
1467
+ params.log?.error({ err }, 'forge:unhandled error');
1468
+ try {
1469
+ const errMsg = `Forge crashed: ${sanitizeErrorMessage(String(err))}`;
1470
+ await progressReply.edit({ content: errMsg, allowedMentions: NO_MENTIONS });
1471
+ }
1472
+ catch (editErr) {
1473
+ if (editErr?.code === 10008) {
1474
+ try {
1475
+ await msg.channel.send({ content: `Forge crashed: ${sanitizeErrorMessage(String(err))}`, allowedMentions: NO_MENTIONS });
1476
+ }
1477
+ catch { /* best-effort */ }
1478
+ }
1479
+ }
1480
+ }).catch((err) => {
1481
+ params.log?.error({ err }, 'forge: unhandled rejection in callback');
1482
+ });
1483
+ return;
1484
+ }
1485
+ }
1486
+ const confirmToken = parseConfirmToken(String(msg.content ?? ''));
1487
+ if (confirmToken) {
1488
+ const pending = consumeDestructiveConfirmation(confirmToken, sessionKey, msg.author.id);
1489
+ if (!pending) {
1490
+ await msg.reply({
1491
+ content: `No pending destructive action found for token \`${confirmToken}\` in this session.`,
1492
+ allowedMentions: NO_MENTIONS,
1493
+ });
1494
+ return;
1495
+ }
1496
+ if (!msg.guild) {
1497
+ await msg.reply({
1498
+ content: `Confirmed token \`${confirmToken}\`, but destructive Discord actions require a guild context.`,
1499
+ allowedMentions: NO_MENTIONS,
1500
+ });
1501
+ return;
1502
+ }
1503
+ const confirmAction = pending.action;
1504
+ const actCtx = {
1505
+ guild: msg.guild,
1506
+ client: msg.client,
1507
+ channelId: msg.channelId,
1508
+ messageId: msg.id,
1509
+ threadParentId,
1510
+ deferScheduler: params.deferScheduler,
1511
+ confirmation: {
1512
+ mode: 'interactive',
1513
+ sessionKey,
1514
+ userId: msg.author.id,
1515
+ bypassDestructive: true,
1516
+ },
1517
+ };
1518
+ const perMessageMemoryCtx = params.memoryCtx ? {
1519
+ ...params.memoryCtx,
1520
+ userId: msg.author.id,
1521
+ channelId: msg.channelId,
1522
+ messageId: msg.id,
1523
+ guildId: msg.guildId ?? undefined,
1524
+ channelName: msg.channel?.name ?? undefined,
1525
+ } : undefined;
1526
+ const actionResults = await executeDiscordActions([confirmAction], actCtx, params.log, {
1527
+ taskCtx: params.taskCtx,
1528
+ cronCtx: params.cronCtx,
1529
+ forgeCtx: params.forgeCtx,
1530
+ planCtx: params.planCtx,
1531
+ memoryCtx: perMessageMemoryCtx,
1532
+ configCtx: params.configCtx,
1533
+ });
1534
+ const displayLines = buildDisplayResultLines([confirmAction], actionResults);
1535
+ const content = displayLines.length > 0
1536
+ ? `Confirmed \`${confirmAction.type}\`.\n${displayLines.join('\n')}`
1537
+ : `Confirmed \`${confirmAction.type}\`.`;
1538
+ await msg.reply({ content, allowedMentions: NO_MENTIONS });
1539
+ return;
1540
+ }
1541
+ const sessionId = params.useRuntimeSessions
1542
+ ? await params.sessionManager.getOrCreate(sessionKey)
1543
+ : null;
1544
+ // If the message is in a thread, join it before replying so sends don't fail.
1545
+ if (params.autoJoinThreads && isThread) {
1546
+ const th = msg.channel;
1547
+ const joinable = typeof th?.joinable === 'boolean' ? th.joinable : true;
1548
+ const joined = typeof th?.joined === 'boolean' ? th.joined : false;
1549
+ if (joinable && !joined && typeof th?.join === 'function') {
1550
+ try {
1551
+ await th.join();
1552
+ params.log?.info({ threadId: String(th.id ?? ''), parentId: String(th.parentId ?? '') }, 'discord:thread joined');
1553
+ }
1554
+ catch (err) {
1555
+ params.log?.warn({ err, threadId: String(th?.id ?? '') }, 'discord:thread failed to join');
1556
+ }
1557
+ }
1558
+ }
1559
+ reply = await msg.reply({ content: formatBoldLabel(thinkingLabel(0)), allowedMentions: NO_MENTIONS });
1560
+ // Track this reply for graceful shutdown cleanup and cleanup on early error.
1561
+ let replyFinalized = false;
1562
+ let hadTextFinal = false;
1563
+ let dispose = registerInFlightReply(reply, msg.channelId, reply.id, `message:${msg.channelId}`);
1564
+ const { signal, dispose: abortDispose } = registerAbort(reply.id);
1565
+ abortSignal = signal;
1566
+ // Best-effort: add 🛑 so the user can tap it to kill the running stream.
1567
+ reply.react?.('🛑')?.catch(() => { });
1568
+ // Declared before try so they remain accessible after the finally block closes.
1569
+ let historySection = '';
1570
+ let summarySection = '';
1571
+ let processedText = '';
1572
+ try {
1573
+ const cwd = params.useGroupDirCwd
1574
+ ? await ensureGroupDir(params.groupsDir, sessionKey, params.botDisplayName)
1575
+ : params.workspaceCwd;
1576
+ // Ensure every channel has its own context file (bootstrapped on first message).
1577
+ if (!isDm && params.discordChannelContext && params.autoIndexChannelContext) {
1578
+ const id = (threadParentId && threadParentId.trim()) ? threadParentId : String(msg.channelId ?? '');
1579
+ // Best-effort: in most guild channels this will be populated; fallback uses channel-id.
1580
+ const chName = String(msg.channel?.name ?? msg.channel?.parent?.name ?? '').trim();
1581
+ try {
1582
+ await ensureIndexedDiscordChannelContext({
1583
+ ctx: params.discordChannelContext,
1584
+ channelId: id,
1585
+ channelName: chName || undefined,
1586
+ log: params.log,
1587
+ });
1588
+ }
1589
+ catch (err) {
1590
+ params.log?.error({ err, channelId: id }, 'discord:context failed to ensure channel context');
1591
+ }
1592
+ }
1593
+ const channelCtx = resolveDiscordChannelContext({
1594
+ ctx: params.discordChannelContext,
1595
+ isDm,
1596
+ channelId: msg.channelId,
1597
+ threadParentId,
1598
+ });
1599
+ if (params.requireChannelContext && !isDm && !channelCtx.contextPath) {
1600
+ await reply.edit({
1601
+ content: mapRuntimeErrorToUserMessage('Configuration error: missing required channel context file for this channel ID.'),
1602
+ allowedMentions: NO_MENTIONS,
1603
+ });
1604
+ replyFinalized = true;
1605
+ return;
1606
+ }
1607
+ const paFiles = await loadWorkspacePaFiles(params.workspaceCwd, { skip: !!params.appendSystemPrompt });
1608
+ const memoryFiles = [];
1609
+ if (isDm) {
1610
+ const memFile = await loadWorkspaceMemoryFile(params.workspaceCwd);
1611
+ if (memFile)
1612
+ memoryFiles.push(memFile);
1613
+ memoryFiles.push(...await loadDailyLogFiles(params.workspaceCwd));
1614
+ }
1615
+ const contextFiles = buildContextFiles([...paFiles, ...memoryFiles], params.discordChannelContext, channelCtx.contextPath);
1616
+ if (params.messageHistoryBudget > 0) {
1617
+ try {
1618
+ historySection = await fetchMessageHistory(msg.channel, msg.id, { budgetChars: params.messageHistoryBudget, botDisplayName: params.botDisplayName });
1619
+ }
1620
+ catch (err) {
1621
+ params.log?.warn({ err }, 'discord:history fetch failed');
1622
+ }
1623
+ }
1624
+ if (params.summaryEnabled) {
1625
+ try {
1626
+ const existing = await loadSummary(params.summaryDataDir, sessionKey);
1627
+ if (existing) {
1628
+ summarySection = existing.summary;
1629
+ if (!turnCounters.has(sessionKey)) {
1630
+ const raw = existing.turnsSinceUpdate;
1631
+ turnCounters.set(sessionKey, typeof raw === 'number' && raw >= 0 ? raw : 0);
1632
+ }
1633
+ }
1634
+ }
1635
+ catch (err) {
1636
+ params.log?.warn({ err, sessionKey }, 'discord:summary load failed');
1637
+ }
1638
+ }
1639
+ const [durableSection, shortTermSection, taskSection, replyRef] = await Promise.all([
1640
+ buildDurableMemorySection({
1641
+ enabled: params.durableMemoryEnabled,
1642
+ durableDataDir: params.durableDataDir,
1643
+ userId: msg.author.id,
1644
+ durableInjectMaxChars: params.durableInjectMaxChars,
1645
+ log: params.log,
1646
+ }),
1647
+ buildShortTermMemorySection({
1648
+ enabled: params.shortTermMemoryEnabled && !isDm,
1649
+ shortTermDataDir: params.shortTermDataDir,
1650
+ guildId: String(msg.guildId ?? ''),
1651
+ userId: msg.author.id,
1652
+ maxChars: params.shortTermInjectMaxChars,
1653
+ maxAgeMs: params.shortTermMaxAgeMs,
1654
+ log: params.log,
1655
+ }),
1656
+ buildTaskThreadSection({
1657
+ isThread,
1658
+ threadId,
1659
+ threadParentId,
1660
+ taskCtx: params.taskCtx,
1661
+ log: params.log,
1662
+ }),
1663
+ resolveReplyReference(msg, params.botDisplayName, params.log),
1664
+ ]);
1665
+ const inlinedContext = await inlineContextFiles(contextFiles, { required: new Set(params.discordChannelContext?.paContextFiles ?? []) });
1666
+ // Consume one-shot startup injection (cleared after first use).
1667
+ let startupLine = '';
1668
+ if (params.startupInjection) {
1669
+ startupLine = params.startupInjection;
1670
+ params.startupInjection = null;
1671
+ }
1672
+ let prompt = buildPromptPreamble(inlinedContext) + '\n\n' +
1673
+ (taskSection
1674
+ ? `---\n${taskSection}\n\n`
1675
+ : '') +
1676
+ (durableSection
1677
+ ? `---\nDurable memory (user-specific notes):\n${durableSection}\n\n`
1678
+ : '') +
1679
+ (shortTermSection
1680
+ ? `---\nRecent activity (cross-channel):\n${shortTermSection}\n\n`
1681
+ : '') +
1682
+ (summarySection
1683
+ ? `---\nConversation memory:\n${summarySection}\n\n`
1684
+ : '') +
1685
+ (historySection
1686
+ ? `---\nRecent conversation:\n${historySection}\n\n`
1687
+ : '') +
1688
+ (replyRef
1689
+ ? `---\nReplied-to message:\n${replyRef.section}\n\n`
1690
+ : '') +
1691
+ (startupLine
1692
+ ? `---\nStartup context:\n${startupLine}\n\n`
1693
+ : '') +
1694
+ `---\nThe sections above are internal system context. Never quote, reference, or explain them in your response. Respond only to the user message below.\n\n` +
1695
+ `---\nUser message:\n` +
1696
+ String(msg.content ?? '');
1697
+ if (params.discordActionsEnabled && !isDm) {
1698
+ prompt += '\n\n---\n' + discordActionsPromptSection(actionFlags, params.botDisplayName);
1699
+ }
1700
+ const addDirs = [];
1701
+ if (params.useGroupDirCwd)
1702
+ addDirs.push(params.workspaceCwd);
1703
+ if (params.discordChannelContext)
1704
+ addDirs.push(params.discordChannelContext.contentDir);
1705
+ const tools = await resolveEffectiveTools({
1706
+ workspaceCwd: params.workspaceCwd,
1707
+ runtimeTools: params.runtimeTools,
1708
+ runtimeCapabilities: params.runtime.capabilities,
1709
+ runtimeId: params.runtime.id,
1710
+ log: params.log,
1711
+ });
1712
+ const effectiveTools = tools.effectiveTools;
1713
+ if (tools.permissionNote || tools.runtimeCapabilityNote) {
1714
+ const noteLines = [
1715
+ tools.permissionNote ? `Permission note: ${tools.permissionNote}` : null,
1716
+ tools.runtimeCapabilityNote ? `Runtime capability note: ${tools.runtimeCapabilityNote}` : null,
1717
+ ].filter((line) => Boolean(line));
1718
+ prompt += `\n\n---\n${noteLines.join('\n')}\n`;
1719
+ }
1720
+ params.log?.info({
1721
+ sessionKey,
1722
+ sessionId,
1723
+ cwd,
1724
+ model: params.runtimeModel,
1725
+ toolsCount: effectiveTools.length,
1726
+ timeoutMs: params.runtimeTimeoutMs,
1727
+ channelId: channelCtx.channelId,
1728
+ channelName: channelCtx.channelName,
1729
+ hasChannelContext: Boolean(channelCtx.contextPath),
1730
+ permissionTier: tools.permissionTier,
1731
+ }, 'invoke:start');
1732
+ // Collect images from reply reference (downloaded first, takes priority).
1733
+ let inputImages;
1734
+ const replyRefImageCount = replyRef?.images.length ?? 0;
1735
+ if (replyRefImageCount > 0) {
1736
+ inputImages = [...replyRef.images];
1737
+ params.log?.info({ imageCount: replyRefImageCount }, 'discord:reply-ref images downloaded');
1738
+ }
1739
+ // Download image attachments from the user message (remaining budget).
1740
+ if (msg.attachments && msg.attachments.size > 0) {
1741
+ try {
1742
+ const dlResult = await downloadMessageImages([...msg.attachments.values()], MAX_IMAGES_PER_INVOCATION - replyRefImageCount);
1743
+ if (dlResult.images.length > 0) {
1744
+ inputImages = [...(inputImages ?? []), ...dlResult.images];
1745
+ params.log?.info({ imageCount: dlResult.images.length }, 'discord:images downloaded');
1746
+ }
1747
+ if (dlResult.errors.length > 0) {
1748
+ params.log?.warn({ errors: dlResult.errors }, 'discord:image download errors');
1749
+ metrics.increment('discord.image_download.errors', dlResult.errors.length);
1750
+ prompt += `\n(Note: ${dlResult.errors.length} image(s) could not be loaded: ${dlResult.errors.join('; ')})`;
1751
+ }
1752
+ }
1753
+ catch (err) {
1754
+ params.log?.warn({ err }, 'discord:image download failed');
1755
+ }
1756
+ // Download non-image text attachments.
1757
+ try {
1758
+ const nonImageAtts = [...msg.attachments.values()].filter(a => !resolveMediaType(a));
1759
+ if (nonImageAtts.length > 0) {
1760
+ const textResult = await downloadTextAttachments(nonImageAtts);
1761
+ if (textResult.texts.length > 0) {
1762
+ const sections = textResult.texts.map(t => `[Attached file: ${t.name}]\n\`\`\`\n${t.content}\n\`\`\``);
1763
+ prompt += '\n\n' + sections.join('\n\n');
1764
+ params.log?.info({ fileCount: textResult.texts.length }, 'discord:text attachments downloaded');
1765
+ }
1766
+ if (textResult.errors.length > 0) {
1767
+ prompt += '\n(' + textResult.errors.join('; ') + ')';
1768
+ params.log?.info({ errors: textResult.errors }, 'discord:text attachment notes');
1769
+ }
1770
+ }
1771
+ }
1772
+ catch (err) {
1773
+ params.log?.warn({ err }, 'discord:text attachment download failed');
1774
+ }
1775
+ }
1776
+ let currentPrompt = prompt;
1777
+ let followUpDepth = 0;
1778
+ // -- auto-follow-up loop --
1779
+ // When query actions (channelList, readMessages, etc.) succeed, re-invoke
1780
+ // Claude with the results so it can continue reasoning without user intervention.
1781
+ // eslint-disable-next-line no-constant-condition
1782
+ while (true) {
1783
+ let finalText = '';
1784
+ let deltaText = '';
1785
+ const collectedImages = [];
1786
+ let activityLabel = '';
1787
+ let statusTick = 1;
1788
+ const t0 = Date.now();
1789
+ metrics.recordInvokeStart('message');
1790
+ params.log?.info({ flow: 'message', sessionKey, followUpDepth }, 'obs.invoke.start');
1791
+ let invokeHadError = false;
1792
+ let invokeErrorMessage = '';
1793
+ let lastEditAt = 0;
1794
+ const minEditIntervalMs = 1250;
1795
+ hadTextFinal = false;
1796
+ // On follow-up iterations, send a new placeholder message.
1797
+ if (followUpDepth > 0) {
1798
+ dispose();
1799
+ reply = await msg.channel.send({ content: formatBoldLabel('(following up...)'), allowedMentions: NO_MENTIONS });
1800
+ dispose = registerInFlightReply(reply, msg.channelId, reply.id, `message:${msg.channelId}:followup-${followUpDepth}`);
1801
+ replyFinalized = false;
1802
+ params.log?.info({ sessionKey, followUpDepth }, 'followup:start');
1803
+ }
1804
+ let streamEditQueue = Promise.resolve();
1805
+ const maybeEdit = async (force = false) => {
1806
+ if (!reply)
1807
+ return;
1808
+ if (isShuttingDown())
1809
+ return;
1810
+ const now = Date.now();
1811
+ if (!force && now - lastEditAt < minEditIntervalMs)
1812
+ return;
1813
+ lastEditAt = now;
1814
+ const out = selectStreamingOutput({ deltaText, activityLabel, finalText, statusTick: statusTick++, showPreview: Date.now() - t0 >= 7000, elapsedMs: Date.now() - t0 });
1815
+ streamEditQueue = streamEditQueue
1816
+ .catch(() => undefined)
1817
+ .then(async () => {
1818
+ try {
1819
+ await reply.edit({ content: out, allowedMentions: NO_MENTIONS });
1820
+ }
1821
+ catch {
1822
+ // Ignore Discord edit errors during streaming.
1823
+ }
1824
+ });
1825
+ await streamEditQueue;
1826
+ };
1827
+ // Stream stall warning state.
1828
+ let lastEventAt = Date.now();
1829
+ let activeToolCount = 0;
1830
+ let stallWarned = false;
1831
+ // If the runtime produces no stdout/stderr (auth/network hangs), avoid leaving the
1832
+ // placeholder `...` indefinitely by periodically updating the message.
1833
+ const keepalive = setInterval(() => {
1834
+ // Stall warning: append to deltaText when events stop arriving.
1835
+ if (params.streamStallWarningMs > 0) {
1836
+ const stallElapsed = Date.now() - lastEventAt;
1837
+ if (stallElapsed > params.streamStallWarningMs && activeToolCount === 0 && !stallWarned) {
1838
+ stallWarned = true;
1839
+ deltaText += (deltaText ? '\n' : '') + `\n*Stream may be stalled (${Math.round(stallElapsed / 1000)}s no activity)...*`;
1840
+ }
1841
+ }
1842
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
1843
+ maybeEdit(true);
1844
+ }, 5000);
1845
+ // Tool-aware streaming: route events through a state machine that buffers
1846
+ // text during tool execution and streams the final answer cleanly.
1847
+ const taq = params.toolAwareStreaming
1848
+ ? new ToolAwareQueue((action) => {
1849
+ if (action.type === 'stream_text') {
1850
+ deltaText += action.text;
1851
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
1852
+ maybeEdit(false);
1853
+ }
1854
+ else if (action.type === 'set_final') {
1855
+ hadTextFinal = true;
1856
+ finalText = action.text;
1857
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
1858
+ maybeEdit(true);
1859
+ }
1860
+ else if (action.type === 'show_activity') {
1861
+ activityLabel = action.label;
1862
+ deltaText = '';
1863
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
1864
+ maybeEdit(true);
1865
+ }
1866
+ }, { flushDelayMs: 2000, postToolDelayMs: 500 })
1867
+ : null;
1868
+ try {
1869
+ for await (const evt of params.runtime.invoke({
1870
+ prompt: currentPrompt,
1871
+ model: resolveModel(params.runtimeModel, params.runtime.id),
1872
+ cwd,
1873
+ addDirs: addDirs.length > 0 ? Array.from(new Set(addDirs)) : undefined,
1874
+ sessionId,
1875
+ sessionKey,
1876
+ tools: effectiveTools,
1877
+ timeoutMs: params.runtimeTimeoutMs,
1878
+ // Images only on initial turn — follow-ups are text-only continuations
1879
+ // with action results; re-downloading would waste time and bandwidth.
1880
+ images: followUpDepth === 0 ? inputImages : undefined,
1881
+ signal: abortSignal,
1882
+ })) {
1883
+ // Track event flow for stall warning.
1884
+ lastEventAt = Date.now();
1885
+ stallWarned = false;
1886
+ if (evt.type === 'tool_start')
1887
+ activeToolCount++;
1888
+ else if (evt.type === 'tool_end')
1889
+ activeToolCount = Math.max(0, activeToolCount - 1);
1890
+ if (taq) {
1891
+ // Tool-aware mode: route relevant events through the queue.
1892
+ if (evt.type === 'text_delta' || evt.type === 'text_final' ||
1893
+ evt.type === 'tool_start' || evt.type === 'tool_end') {
1894
+ taq.handleEvent(evt);
1895
+ }
1896
+ else if (evt.type === 'error') {
1897
+ invokeHadError = true;
1898
+ invokeErrorMessage = evt.message;
1899
+ taq.handleEvent(evt);
1900
+ finalText = abortSignal.aborted
1901
+ ? '*(Response aborted.)*'
1902
+ : mapRuntimeErrorToUserMessage(evt.message);
1903
+ await maybeEdit(true);
1904
+ if (!abortSignal.aborted) {
1905
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
1906
+ statusRef?.current?.runtimeError({ sessionKey, channelName: channelCtx.channelName }, evt.message);
1907
+ params.log?.warn({ flow: 'message', sessionKey, error: evt.message }, 'obs.invoke.error');
1908
+ }
1909
+ }
1910
+ else if (evt.type === 'log_line') {
1911
+ // Bypass queue for log lines.
1912
+ const prefix = evt.stream === 'stderr' ? '[stderr] ' : '[stdout] ';
1913
+ deltaText += (deltaText && !deltaText.endsWith('\n') ? '\n' : '') + prefix + evt.line + '\n';
1914
+ await maybeEdit(false);
1915
+ }
1916
+ else if (evt.type === 'image_data') {
1917
+ collectedImages.push(evt.image);
1918
+ }
1919
+ }
1920
+ else {
1921
+ // Flat mode: existing behavior unchanged.
1922
+ if (evt.type === 'text_final') {
1923
+ hadTextFinal = true;
1924
+ finalText = evt.text;
1925
+ await maybeEdit(true);
1926
+ }
1927
+ else if (evt.type === 'error') {
1928
+ invokeHadError = true;
1929
+ invokeErrorMessage = evt.message;
1930
+ finalText = abortSignal.aborted
1931
+ ? '*(Response aborted.)*'
1932
+ : mapRuntimeErrorToUserMessage(evt.message);
1933
+ await maybeEdit(true);
1934
+ if (!abortSignal.aborted) {
1935
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
1936
+ statusRef?.current?.runtimeError({ sessionKey, channelName: channelCtx.channelName }, evt.message);
1937
+ params.log?.warn({ flow: 'message', sessionKey, error: evt.message }, 'obs.invoke.error');
1938
+ }
1939
+ }
1940
+ else if (evt.type === 'text_delta') {
1941
+ deltaText += evt.text;
1942
+ await maybeEdit(false);
1943
+ }
1944
+ else if (evt.type === 'log_line') {
1945
+ const prefix = evt.stream === 'stderr' ? '[stderr] ' : '[stdout] ';
1946
+ deltaText += (deltaText && !deltaText.endsWith('\n') ? '\n' : '') + prefix + evt.line + '\n';
1947
+ await maybeEdit(false);
1948
+ }
1949
+ else if (evt.type === 'image_data') {
1950
+ collectedImages.push(evt.image);
1951
+ }
1952
+ }
1953
+ }
1954
+ }
1955
+ finally {
1956
+ clearInterval(keepalive);
1957
+ taq?.dispose();
1958
+ // Drain all queued streaming edits so they settle before final output.
1959
+ try {
1960
+ await streamEditQueue;
1961
+ }
1962
+ catch { /* ignore */ }
1963
+ streamEditQueue = Promise.resolve();
1964
+ }
1965
+ metrics.recordInvokeResult('message', Date.now() - t0, !invokeHadError, invokeErrorMessage);
1966
+ params.log?.info({ flow: 'message', sessionKey, followUpDepth, ms: Date.now() - t0, ok: !invokeHadError }, 'obs.invoke.end');
1967
+ if (followUpDepth > 0) {
1968
+ params.log?.info({ sessionKey, followUpDepth, ms: Date.now() - t0 }, 'followup:end');
1969
+ }
1970
+ else {
1971
+ params.log?.info({ sessionKey, sessionId, ms: Date.now() - t0 }, 'invoke:end');
1972
+ }
1973
+ processedText = finalText || deltaText || (collectedImages.length > 0 ? '' : '(no output)');
1974
+ let actions = [];
1975
+ let actionResults = [];
1976
+ let strippedUnrecognizedTypes = [];
1977
+ // Gate action execution on successful stream completion — do not execute
1978
+ // actions against partial or error output, which could cause side effects
1979
+ // based on incomplete model responses. Relax the hadTextFinal requirement
1980
+ // when the stream completed without error — some runtime modes (long-running
1981
+ // process, tool-aware queue timing) may deliver complete text via deltaText
1982
+ // without a discrete text_final event.
1983
+ const streamCompletedForActions = !invokeHadError && !abortSignal.aborted;
1984
+ if (!hadTextFinal && streamCompletedForActions && processedText.includes('<discord-action>')) {
1985
+ params.log?.warn({ flow: 'message', sessionKey, textLen: processedText.length }, 'discord:action fallback — hadTextFinal=false but text contains action markers');
1986
+ }
1987
+ const canParseActions = streamCompletedForActions
1988
+ && (hadTextFinal || processedText.includes('<discord-action>'));
1989
+ if (params.discordActionsEnabled && msg.guild && canParseActions) {
1990
+ const parsed = parseDiscordActions(processedText, actionFlags);
1991
+ if (parsed.actions.length > 0) {
1992
+ actions = parsed.actions;
1993
+ strippedUnrecognizedTypes = parsed.strippedUnrecognizedTypes;
1994
+ const actCtx = {
1995
+ guild: msg.guild,
1996
+ client: msg.client,
1997
+ channelId: msg.channelId,
1998
+ messageId: msg.id,
1999
+ threadParentId,
2000
+ deferScheduler: params.deferScheduler,
2001
+ confirmation: {
2002
+ mode: 'interactive',
2003
+ sessionKey,
2004
+ userId: msg.author.id,
2005
+ },
2006
+ };
2007
+ // Construct per-message memoryCtx with real user ID and Discord metadata.
2008
+ const perMessageMemoryCtx = params.memoryCtx ? {
2009
+ ...params.memoryCtx,
2010
+ userId: msg.author.id,
2011
+ channelId: msg.channelId,
2012
+ messageId: msg.id,
2013
+ guildId: msg.guildId ?? undefined,
2014
+ channelName: msg.channel?.name ?? undefined,
2015
+ } : undefined;
2016
+ actionResults = await executeDiscordActions(parsed.actions, actCtx, params.log, {
2017
+ taskCtx: params.taskCtx,
2018
+ cronCtx: params.cronCtx,
2019
+ forgeCtx: params.forgeCtx,
2020
+ planCtx: params.planCtx,
2021
+ memoryCtx: perMessageMemoryCtx,
2022
+ configCtx: params.configCtx,
2023
+ });
2024
+ for (const result of actionResults) {
2025
+ metrics.recordActionResult(result.ok);
2026
+ params.log?.info({ flow: 'message', sessionKey, ok: result.ok }, 'obs.action.result');
2027
+ }
2028
+ const displayLines = buildDisplayResultLines(actions, actionResults);
2029
+ const anyActionSucceeded = actionResults.some((r) => r.ok);
2030
+ processedText = displayLines.length > 0
2031
+ ? parsed.cleanText.trimEnd() + '\n\n' + displayLines.join('\n')
2032
+ : parsed.cleanText.trimEnd();
2033
+ // When all display lines were suppressed (e.g. sendMessage-only) and there's
2034
+ // no prose, delete the placeholder instead of posting "(no output)".
2035
+ if (!processedText.trim()
2036
+ && anyActionSucceeded
2037
+ && collectedImages.length === 0
2038
+ && strippedUnrecognizedTypes.length === 0) {
2039
+ try {
2040
+ await reply.delete();
2041
+ }
2042
+ catch { /* ignore */ }
2043
+ replyFinalized = true;
2044
+ params.log?.info({ sessionKey }, 'discord:reply suppressed (actions-only, no display text)');
2045
+ break;
2046
+ }
2047
+ if (statusRef?.current) {
2048
+ for (let i = 0; i < actionResults.length; i++) {
2049
+ const r = actionResults[i];
2050
+ if (!r.ok) {
2051
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
2052
+ statusRef.current.actionFailed(actions[i].type, r.error);
2053
+ }
2054
+ }
2055
+ }
2056
+ }
2057
+ else {
2058
+ processedText = parsed.cleanText;
2059
+ strippedUnrecognizedTypes = parsed.strippedUnrecognizedTypes;
2060
+ }
2061
+ }
2062
+ processedText = appendUnavailableActionTypesNotice(processedText, strippedUnrecognizedTypes);
2063
+ // Suppression: if a follow-up response is trivially short and has no further
2064
+ // actions, suppress it to avoid posting empty messages like "Got it."
2065
+ // Skip suppression when images are present, or when unrecognized action blocks
2066
+ // were stripped (the AI tried to act — the user must see "(no output)").
2067
+ if (followUpDepth > 0) {
2068
+ if (shouldSuppressFollowUp(processedText, actions.length, collectedImages.length, strippedUnrecognizedTypes.length)) {
2069
+ const stripped = processedText.replace(/\s+/g, ' ').trim();
2070
+ try {
2071
+ await reply.delete();
2072
+ }
2073
+ catch { /* ignore */ }
2074
+ replyFinalized = true;
2075
+ params.log?.info({ sessionKey, followUpDepth, chars: stripped.length }, 'followup:suppressed');
2076
+ break;
2077
+ }
2078
+ else if (strippedUnrecognizedTypes.length > 0 && actions.length === 0 && collectedImages.length === 0) {
2079
+ params.log?.info({ sessionKey, followUpDepth, types: strippedUnrecognizedTypes }, 'followup:suppression-bypassed');
2080
+ }
2081
+ }
2082
+ else if (strippedUnrecognizedTypes.length > 0 && actions.length === 0) {
2083
+ params.log?.info({ sessionKey, types: strippedUnrecognizedTypes }, 'discord:unrecognized-action-types-stripped');
2084
+ }
2085
+ if (!isShuttingDown()) {
2086
+ try {
2087
+ await editThenSendChunks(reply, msg.channel, processedText, collectedImages);
2088
+ replyFinalized = true;
2089
+ }
2090
+ catch (editErr) {
2091
+ // Thread archived by a taskClose action — the close summary was already
2092
+ // posted inside closeTaskThread, so the only thing lost is Claude's
2093
+ // conversational wrapper ("Done. Closing it out now."). Swallow gracefully.
2094
+ if (editErr?.code === 50083) {
2095
+ params.log?.info({ sessionKey }, 'discord:reply skipped (thread archived by action)');
2096
+ try {
2097
+ await reply.delete();
2098
+ }
2099
+ catch { /* best-effort cleanup */ }
2100
+ replyFinalized = true;
2101
+ }
2102
+ else {
2103
+ throw editErr;
2104
+ }
2105
+ }
2106
+ }
2107
+ else {
2108
+ replyFinalized = true;
2109
+ }
2110
+ // -- auto-follow-up check --
2111
+ if (followUpDepth >= params.actionFollowupDepth)
2112
+ break;
2113
+ if (actions.length === 0)
2114
+ break;
2115
+ const actionTypes = actions.map((a) => a.type);
2116
+ if (!hasQueryAction(actionTypes))
2117
+ break;
2118
+ // At least one query action must have succeeded.
2119
+ const anyQuerySucceeded = actions.some((a, i) => QUERY_ACTION_TYPES.has(a.type) && actionResults[i]?.ok);
2120
+ if (!anyQuerySucceeded)
2121
+ break;
2122
+ // Build follow-up prompt with action results.
2123
+ const followUpLines = buildAllResultLines(actionResults);
2124
+ currentPrompt =
2125
+ `[Auto-follow-up] Your previous response included Discord actions. Here are the results:\n\n` +
2126
+ followUpLines.join('\n') +
2127
+ `\n\nContinue your analysis based on these results. If you need additional information, you may emit further query actions.`;
2128
+ followUpDepth++;
2129
+ }
2130
+ }
2131
+ catch (innerErr) {
2132
+ // Inner catch: attempt to show the error in the reply before the finally
2133
+ // block runs dispose(). Setting replyFinalized = true on success prevents
2134
+ // the finally's safety-net delete from removing the error message.
2135
+ try {
2136
+ if (reply && !isShuttingDown()) {
2137
+ await reply.edit({
2138
+ content: abortSignal.aborted
2139
+ ? '*(Response aborted.)*'
2140
+ : mapRuntimeErrorToUserMessage(String(innerErr)),
2141
+ allowedMentions: NO_MENTIONS,
2142
+ });
2143
+ replyFinalized = true;
2144
+ }
2145
+ }
2146
+ catch {
2147
+ // Ignore secondary errors; outer catch will handle logging.
2148
+ }
2149
+ throw innerErr;
2150
+ }
2151
+ finally {
2152
+ // Safety net runs before dispose() so cold-start recovery can still see
2153
+ // the in-flight entry if the delete fails.
2154
+ if (!replyFinalized && reply && !isShuttingDown()) {
2155
+ try {
2156
+ await reply.delete();
2157
+ }
2158
+ catch { /* best-effort */ }
2159
+ }
2160
+ abortDispose();
2161
+ // Best-effort: remove the 🛑 reaction added at stream start.
2162
+ try {
2163
+ await reply?.reactions?.resolve?.('🛑')?.remove?.();
2164
+ }
2165
+ catch { /* best-effort */ }
2166
+ dispose();
2167
+ }
2168
+ if (params.summaryEnabled) {
2169
+ const count = (turnCounters.get(sessionKey) ?? 0) + 1;
2170
+ turnCounters.set(sessionKey, count);
2171
+ if (count >= params.summaryEveryNTurns) {
2172
+ turnCounters.set(sessionKey, 0);
2173
+ const summarySeq = (latestSummarySequence.get(sessionKey) ?? 0) + 1;
2174
+ latestSummarySequence.set(sessionKey, summarySeq);
2175
+ let taskStatusContext;
2176
+ if (params.taskCtx?.store) {
2177
+ const activeTasks = params.taskCtx.store.list();
2178
+ const RECENT_CLOSED_WINDOW_MS = 6 * 60 * 60 * 1000;
2179
+ const nowMs = Date.now();
2180
+ const recentlyClosed = params.taskCtx.store
2181
+ .list({ status: 'closed' })
2182
+ .filter((t) => {
2183
+ const closedAt = t.closed_at ? new Date(t.closed_at).getTime() : 0;
2184
+ return nowMs - closedAt < RECENT_CLOSED_WINDOW_MS;
2185
+ });
2186
+ const TASK_SNAPSHOT_LIMIT = 500;
2187
+ const CLOSED_SNAPSHOT_LIMIT = 200;
2188
+ const TRUNCATION_TRAILER = '(list truncated — only reconcile tasks explicitly listed above)';
2189
+ const activeLines = [];
2190
+ let activeTotalLen = 0;
2191
+ let activeTruncated = false;
2192
+ for (const t of activeTasks) {
2193
+ const line = `${t.id}: ${t.status}, "${t.title}"`;
2194
+ if (activeTotalLen + line.length + 1 > TASK_SNAPSHOT_LIMIT) {
2195
+ activeTruncated = true;
2196
+ break;
2197
+ }
2198
+ activeLines.push(line);
2199
+ activeTotalLen += line.length + 1;
2200
+ }
2201
+ const parts = [];
2202
+ if (activeLines.length > 0) {
2203
+ parts.push(activeLines.join('\n') + (activeTruncated ? '\n' + TRUNCATION_TRAILER : ''));
2204
+ }
2205
+ else {
2206
+ parts.push('No active tasks.');
2207
+ }
2208
+ if (recentlyClosed.length > 0) {
2209
+ const closedLines = [];
2210
+ let closedLen = 0;
2211
+ let closedTruncated = false;
2212
+ for (const t of recentlyClosed) {
2213
+ const line = `${t.id}: closed, "${t.title}"`;
2214
+ if (closedLen + line.length + 1 > CLOSED_SNAPSHOT_LIMIT) {
2215
+ closedTruncated = true;
2216
+ break;
2217
+ }
2218
+ closedLines.push(line);
2219
+ closedLen += line.length + 1;
2220
+ }
2221
+ parts.push('Recently closed:\n' +
2222
+ closedLines.join('\n') +
2223
+ (closedTruncated ? '\n(more closed tasks not shown)' : ''));
2224
+ }
2225
+ taskStatusContext = parts.join('\n');
2226
+ }
2227
+ pendingSummaryWork = {
2228
+ summarySeq,
2229
+ existingSummary: summarySection || null,
2230
+ exchange: (historySection ? historySection + '\n' : '') +
2231
+ `[${msg.author.displayName || msg.author.username}]: ${msg.content}\n` +
2232
+ `[${params.botDisplayName}]: ${(processedText || '').slice(0, 500)}`,
2233
+ ...(taskStatusContext !== undefined ? { taskStatusContext } : {}),
2234
+ };
2235
+ }
2236
+ else if (summarySection) {
2237
+ // Persist counter progress so restarts resume from last known count.
2238
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
2239
+ saveSummary(params.summaryDataDir, sessionKey, {
2240
+ summary: summarySection,
2241
+ updatedAt: Date.now(),
2242
+ turnsSinceUpdate: count,
2243
+ });
2244
+ }
2245
+ }
2246
+ // Stage short-term memory append for fire-and-forget after queue.
2247
+ if (params.shortTermMemoryEnabled && !isDm && msg.guildId && msg.guild) {
2248
+ const ch = msg.channel;
2249
+ if (isChannelPublic(ch, msg.guild)) {
2250
+ pendingShortTermAppend = {
2251
+ userContent: String(msg.content ?? ''),
2252
+ botResponse: (processedText || '').slice(0, 300),
2253
+ channelName: String(ch?.name ?? ch?.parent?.name ?? msg.channelId),
2254
+ channelId: msg.channelId,
2255
+ };
2256
+ }
2257
+ }
2258
+ }
2259
+ catch (err) {
2260
+ metrics.increment('discord.handler.error');
2261
+ params.log?.error({ err, sessionKey }, 'discord:handler failed');
2262
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
2263
+ statusRef?.current?.handlerError({ sessionKey }, err);
2264
+ try {
2265
+ if (!abortSignal?.aborted && reply && !isShuttingDown()) {
2266
+ await reply.edit({
2267
+ content: mapRuntimeErrorToUserMessage(String(err)),
2268
+ allowedMentions: NO_MENTIONS,
2269
+ });
2270
+ }
2271
+ }
2272
+ catch {
2273
+ // Ignore secondary errors writing to Discord.
2274
+ }
2275
+ }
2276
+ });
2277
+ // Fire-and-forget: run summary generation outside the queue so it doesn't
2278
+ // block the next message for this session key (fast-tier can take several seconds).
2279
+ if (pendingSummaryWork) {
2280
+ const work = pendingSummaryWork;
2281
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
2282
+ summaryWorkQueue.run(sessionKey, async () => {
2283
+ if (latestSummarySequence.get(sessionKey) !== work.summarySeq)
2284
+ return;
2285
+ const newSummary = await generateSummary(params.runtime, {
2286
+ previousSummary: work.existingSummary,
2287
+ recentExchange: work.exchange,
2288
+ model: resolveModel(params.summaryModel, params.runtime.id),
2289
+ cwd: params.workspaceCwd,
2290
+ maxChars: params.summaryMaxChars,
2291
+ timeoutMs: 30_000,
2292
+ ...(work.taskStatusContext !== undefined ? { taskStatusContext: work.taskStatusContext } : {}),
2293
+ });
2294
+ if (latestSummarySequence.get(sessionKey) !== work.summarySeq)
2295
+ return;
2296
+ await saveSummary(params.summaryDataDir, sessionKey, {
2297
+ summary: newSummary,
2298
+ updatedAt: Date.now(),
2299
+ turnsSinceUpdate: 0,
2300
+ });
2301
+ if (params.summaryToDurableEnabled) {
2302
+ const ch = msg.channel;
2303
+ await applyUserTurnToDurable({
2304
+ runtime: params.runtime,
2305
+ userMessageText: String(msg.content ?? ''),
2306
+ userId: msg.author.id,
2307
+ durableDataDir: params.durableDataDir,
2308
+ durableMaxItems: params.durableMaxItems,
2309
+ model: resolveModel(params.summaryModel, params.runtime.id),
2310
+ cwd: params.workspaceCwd,
2311
+ channelId: msg.channelId,
2312
+ messageId: msg.id,
2313
+ guildId: msg.guildId ?? undefined,
2314
+ channelName: String(ch?.name ?? '') || undefined,
2315
+ });
2316
+ }
2317
+ })
2318
+ .catch((err) => {
2319
+ params.log?.warn({ err, sessionKey }, 'discord:summary/durable-extraction failed');
2320
+ });
2321
+ }
2322
+ // Fire-and-forget: record short-term memory entry (cross-channel awareness).
2323
+ if (pendingShortTermAppend) {
2324
+ const stWork = pendingShortTermAppend;
2325
+ const guildUserId = `${msg.guildId}-${msg.author.id}`;
2326
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
2327
+ appendEntry(params.shortTermDataDir, guildUserId, {
2328
+ timestamp: Date.now(),
2329
+ sessionKey,
2330
+ channelId: stWork.channelId,
2331
+ channelName: stWork.channelName,
2332
+ summary: buildExcerptSummary(stWork.userContent, stWork.botResponse),
2333
+ }, {
2334
+ maxEntries: params.shortTermMaxEntries,
2335
+ maxAgeMs: params.shortTermMaxAgeMs,
2336
+ }).catch((err) => {
2337
+ params.log?.warn({ err, sessionKey }, 'discord:short-term memory append failed');
2338
+ });
2339
+ }
2340
+ }
2341
+ catch (err) {
2342
+ const metrics = params.metrics ?? globalMetrics;
2343
+ metrics.increment('discord.message.handler_wrapper_error');
2344
+ params.log?.error({ err }, 'discord:messageCreate failed');
2345
+ }
2346
+ };
2347
+ }