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
package/dist/index.js ADDED
@@ -0,0 +1,1378 @@
1
+ import 'dotenv/config';
2
+ import pino from 'pino';
3
+ import path from 'node:path';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+ import fs from 'node:fs/promises';
7
+ import { createClaudeCliRuntime } from './runtime/claude-code-cli.js';
8
+ import { killAllSubprocesses } from './runtime/cli-adapter.js';
9
+ import { RuntimeRegistry } from './runtime/registry.js';
10
+ import { createOpenAICompatRuntime } from './runtime/openai-compat.js';
11
+ import { createCodexCliRuntime } from './runtime/codex-cli.js';
12
+ import { createGeminiCliRuntime } from './runtime/gemini-cli.js';
13
+ import { createConcurrencyLimiter, withConcurrencyLimit } from './runtime/concurrency-limit.js';
14
+ import { SessionManager } from './sessions.js';
15
+ import { loadDiscordChannelContext, resolveDiscordChannelContext, validatePaContextModules } from './discord/channel-context.js';
16
+ import { parseDiscordActions, executeDiscordActions, discordActionsPromptSection, buildDisplayResultLines } from './discord/actions.js';
17
+ import { resolveChannel, fmtTime } from './discord/action-utils.js';
18
+ import { DeferScheduler } from './discord/defer-scheduler.js';
19
+ import { startDiscordBot, getActiveForgeId } from './discord.js';
20
+ import { acquirePidLock, releasePidLock } from './pidlock.js';
21
+ import { CronScheduler } from './cron/scheduler.js';
22
+ import { executeCronJob } from './cron/executor.js';
23
+ import { initCronForum } from './cron/forum-sync.js';
24
+ import { CronRunControl } from './cron/run-control.js';
25
+ import { ForgeOrchestrator } from './discord/forge-commands.js';
26
+ import { initializeTasksContext, wireTaskSync } from './tasks/initialize.js';
27
+ import { ForumCountSync } from './discord/forum-count-sync.js';
28
+ import { resolveTasksForum } from './tasks/thread-ops.js';
29
+ import { initTasksForumGuard } from './tasks/forum-guard.js';
30
+ import { reloadTagMapInPlace } from './tasks/tag-map.js';
31
+ import { ensureWorkspaceBootstrapFiles } from './workspace-bootstrap.js';
32
+ import { probeWorkspacePermissions } from './workspace-permissions.js';
33
+ import { loadRunStats } from './cron/run-stats.js';
34
+ import { seedTagMap } from './cron/discord-sync.js';
35
+ import { loadCronTagMapStrict } from './cron/tag-map.js';
36
+ import { CronSyncCoordinator } from './cron/cron-sync-coordinator.js';
37
+ import { startCronTagMapWatcher } from './cron/cron-tag-map-watcher.js';
38
+ import { ensureForumTags, isSnowflake } from './discord/system-bootstrap.js';
39
+ import { parseConfig } from './config.js';
40
+ import { startWebhookServer } from './webhook/server.js';
41
+ import { resolveModel } from './runtime/model-tiers.js';
42
+ import { resolveDisplayName } from './identity.js';
43
+ import { globalMetrics } from './observability/metrics.js';
44
+ import { MemorySampler } from './observability/memory-sampler.js';
45
+ import { setDataFilePath, drainInFlightReplies, cleanupOrphanedReplies, hasInFlightForChannel, } from './discord/inflight-replies.js';
46
+ import { writeShutdownContext, readAndClearShutdownContext, formatStartupInjection } from './discord/shutdown-context.js';
47
+ import { getGitHash } from './version.js';
48
+ import { runCredentialChecks, formatCredentialReport } from './health/credential-check.js';
49
+ import { healCorruptedJsonStores, healStaleCronRecords, healStaleTaskThreadRefs } from './health/startup-healing.js';
50
+ import { validateDiscordToken } from './validate.js';
51
+ import { buildContextFiles, buildPromptPreamble, inlineContextFiles, loadWorkspacePaFiles, resolveEffectiveTools } from './discord/prompt-common.js';
52
+ import { mapRuntimeErrorToUserMessage } from './discord/user-errors.js';
53
+ import { NO_MENTIONS } from './discord/allowed-mentions.js';
54
+ import { appendUnavailableActionTypesNotice } from './discord/output-common.js';
55
+ import { TaskStore } from './tasks/store.js';
56
+ import { migrateLegacyTaskDataFile, resolveTaskDataPath } from './tasks/path-defaults.js';
57
+ const log = pino({ level: process.env.LOG_LEVEL ?? 'info' });
58
+ const bootStartMs = Date.now();
59
+ const __filename = fileURLToPath(import.meta.url);
60
+ const __dirname = path.dirname(__filename);
61
+ const projectRoot = path.resolve(__dirname, '..');
62
+ let parsedConfig;
63
+ try {
64
+ parsedConfig = parseConfig(process.env);
65
+ }
66
+ catch (err) {
67
+ log.error({ err }, 'Invalid configuration');
68
+ process.exit(1);
69
+ }
70
+ for (const warning of parsedConfig.warnings) {
71
+ log.warn(warning);
72
+ }
73
+ for (const info of parsedConfig.infos) {
74
+ log.info(info);
75
+ }
76
+ const cfg = parsedConfig.config;
77
+ const token = cfg.token;
78
+ const allowUserIds = cfg.allowUserIds;
79
+ const allowChannelIds = cfg.allowChannelIds;
80
+ const restrictChannelIds = cfg.restrictChannelIds;
81
+ const primaryRuntimeName = cfg.primaryRuntime;
82
+ let runtimeModel = cfg.runtimeModel;
83
+ const runtimeTools = cfg.runtimeTools;
84
+ const runtimeTimeoutMs = cfg.runtimeTimeoutMs;
85
+ const dataDir = cfg.dataDir;
86
+ // --- PID lock: prevent duplicate bot instances ---
87
+ const pidLockDir = dataDir ?? path.join(__dirname, '..', 'data');
88
+ const pidLockPath = path.join(pidLockDir, 'discoclaw.pid');
89
+ const pidLockDirPath = `${pidLockPath}.lock`;
90
+ try {
91
+ await fs.mkdir(pidLockDir, { recursive: true });
92
+ await acquirePidLock(pidLockPath);
93
+ log.info({ pidLockDir: pidLockDirPath }, 'PID lock acquired (lockdir backend)');
94
+ }
95
+ catch (err) {
96
+ log.error({ err }, 'Failed to acquire PID lock');
97
+ process.exit(1);
98
+ }
99
+ // Detect first-ever boot via a stable marker file (persists across restarts).
100
+ // The PID lock dir is transient (removed on shutdown) so it can't be used here.
101
+ const bootMarkerPath = path.join(pidLockDir, '.boot-marker');
102
+ let firstBoot = false;
103
+ try {
104
+ await fs.access(bootMarkerPath);
105
+ }
106
+ catch {
107
+ firstBoot = true;
108
+ await fs.writeFile(bootMarkerPath, new Date().toISOString() + '\n', 'utf-8');
109
+ }
110
+ // --- Configure inflight reply persistence (for graceful shutdown + cold-start recovery) ---
111
+ setDataFilePath(path.join(pidLockDir, 'inflight.json'));
112
+ // --- Resolve current build hash (best-effort; null if git unavailable) ---
113
+ const gitHash = await getGitHash();
114
+ if (gitHash) {
115
+ log.info({ gitHash }, 'startup:build hash resolved');
116
+ }
117
+ // --- Read shutdown context from previous run (before bot connects to avoid race) ---
118
+ let startupInjection = null;
119
+ let startupCtx;
120
+ {
121
+ startupCtx = await readAndClearShutdownContext(pidLockDir, { firstBoot });
122
+ startupInjection = formatStartupInjection(startupCtx);
123
+ if (startupInjection) {
124
+ if (gitHash)
125
+ startupInjection = `Build: ${gitHash}. ${startupInjection}`;
126
+ log.info({ type: startupCtx.type, activeForge: startupCtx.shutdown?.activeForge }, 'startup:context loaded');
127
+ }
128
+ else {
129
+ if (gitHash)
130
+ startupInjection = `Build: ${gitHash}.`;
131
+ if (startupCtx.type === 'first-boot') {
132
+ log.info('startup:first boot detected (no prior shutdown context)');
133
+ }
134
+ }
135
+ }
136
+ let botStatus = null;
137
+ let cronScheduler = null;
138
+ let taskSyncWiring = null;
139
+ let cronTagMapWatcher = null;
140
+ let taskForumCountSync;
141
+ let cronForumCountSync;
142
+ let webhookServer = null;
143
+ let savedCronExecCtx = null;
144
+ const memorySampler = new MemorySampler();
145
+ globalMetrics.setMemorySampler(memorySampler);
146
+ memorySampler.sample();
147
+ const memorySamplerInterval = setInterval(() => {
148
+ memorySampler.sample();
149
+ }, 30_000);
150
+ memorySamplerInterval.unref?.();
151
+ const shutdown = async () => {
152
+ // Write default shutdown context (skip if !restart already wrote a richer one).
153
+ try {
154
+ await writeShutdownContext(pidLockDir, {
155
+ reason: 'unknown',
156
+ timestamp: new Date().toISOString(),
157
+ activeForge: getActiveForgeId(),
158
+ }, { skipIfExists: true });
159
+ }
160
+ catch (err) {
161
+ log.warn({ err }, 'shutdown:failed to write shutdown context');
162
+ }
163
+ // Edit all in-progress Discord replies before killing subprocesses.
164
+ await drainInFlightReplies({ timeoutMs: 3000, log });
165
+ // Kill all CLI subprocesses so they release session locks before the new instance starts.
166
+ killAllSubprocesses();
167
+ // Best-effort: may not complete before SIGKILL on short shutdown windows.
168
+ taskForumCountSync?.stop();
169
+ cronForumCountSync?.stop();
170
+ taskSyncWiring?.stop();
171
+ cronTagMapWatcher?.stop();
172
+ cronScheduler?.stopAll();
173
+ clearInterval(memorySamplerInterval);
174
+ if (webhookServer) {
175
+ await webhookServer.close().catch((err) => log.warn({ err }, 'webhook:close error'));
176
+ }
177
+ await botStatus?.offline();
178
+ await releasePidLock(pidLockPath);
179
+ process.exit(0);
180
+ };
181
+ process.on('SIGTERM', shutdown);
182
+ process.on('SIGINT', shutdown);
183
+ const contentDir = cfg.contentDirOverride || (dataDir
184
+ ? path.join(dataDir, 'content')
185
+ : path.join(__dirname, '..', 'content'));
186
+ const contextModulesDir = path.join(__dirname, '..', '.context');
187
+ // Hard requirement: PA context modules must exist.
188
+ // This runs outside the try-catch — failure crashes the process.
189
+ await validatePaContextModules(contextModulesDir);
190
+ // Best-effort: load only the channel index (small) and ensure placeholder channel files exist.
191
+ let discordChannelContext = undefined;
192
+ try {
193
+ await fs.mkdir(contentDir, { recursive: true });
194
+ discordChannelContext = await loadDiscordChannelContext({ contentDir, contextModulesDir, log });
195
+ }
196
+ catch (err) {
197
+ log.warn({ err, contentDir }, 'Failed to initialize discord channel context; continuing without it');
198
+ discordChannelContext = undefined;
199
+ }
200
+ const requireChannelContext = cfg.requireChannelContext;
201
+ const autoIndexChannelContext = cfg.autoIndexChannelContext;
202
+ const autoJoinThreads = cfg.autoJoinThreads;
203
+ const useRuntimeSessions = cfg.useRuntimeSessions;
204
+ const discordActionsEnabled = cfg.discordActionsEnabled;
205
+ const discordActionsChannels = cfg.discordActionsChannels;
206
+ const discordActionsMessaging = cfg.discordActionsMessaging;
207
+ const discordActionsGuild = cfg.discordActionsGuild;
208
+ const discordActionsModeration = cfg.discordActionsModeration;
209
+ const discordActionsPolls = cfg.discordActionsPolls;
210
+ const discordActionsBotProfile = cfg.discordActionsBotProfile;
211
+ const discordActionsForge = cfg.discordActionsForge;
212
+ const discordActionsPlan = cfg.discordActionsPlan;
213
+ const discordActionsMemory = cfg.discordActionsMemory;
214
+ const messageHistoryBudget = cfg.messageHistoryBudget;
215
+ const summaryEnabled = cfg.summaryEnabled;
216
+ let summaryModel = cfg.summaryModel;
217
+ const summaryMaxChars = cfg.summaryMaxChars;
218
+ const summaryEveryNTurns = cfg.summaryEveryNTurns;
219
+ const summaryDataDir = cfg.summaryDataDirOverride
220
+ || (dataDir ? path.join(dataDir, 'memory', 'rolling') : path.join(__dirname, '..', 'data', 'memory', 'rolling'));
221
+ const durableMemoryEnabled = cfg.durableMemoryEnabled;
222
+ const durableDataDir = cfg.durableDataDirOverride
223
+ || (dataDir ? path.join(dataDir, 'memory', 'durable') : path.join(__dirname, '..', 'data', 'memory', 'durable'));
224
+ const durableInjectMaxChars = cfg.durableInjectMaxChars;
225
+ const durableMaxItems = cfg.durableMaxItems;
226
+ const memoryCommandsEnabled = cfg.memoryCommandsEnabled;
227
+ const planCommandsEnabled = cfg.planCommandsEnabled;
228
+ const planPhasesEnabled = cfg.planPhasesEnabled;
229
+ const planPhaseMaxContextFiles = cfg.planPhaseMaxContextFiles;
230
+ const planPhaseTimeoutMs = cfg.planPhaseTimeoutMs;
231
+ const planPhaseMaxAuditFixAttempts = cfg.planPhaseMaxAuditFixAttempts;
232
+ const forgeCommandsEnabled = cfg.forgeCommandsEnabled;
233
+ const forgeMaxAuditRounds = cfg.forgeMaxAuditRounds;
234
+ const forgeDrafterModel = cfg.forgeDrafterModel;
235
+ const forgeAuditorModel = cfg.forgeAuditorModel;
236
+ const forgeTimeoutMs = cfg.forgeTimeoutMs;
237
+ const forgeProgressThrottleMs = cfg.forgeProgressThrottleMs;
238
+ const forgeAutoImplement = cfg.forgeAutoImplement;
239
+ const summaryToDurableEnabled = cfg.summaryToDurableEnabled;
240
+ const shortTermMemoryEnabled = cfg.shortTermMemoryEnabled;
241
+ const shortTermDataDir = cfg.shortTermDataDirOverride
242
+ || (dataDir ? path.join(dataDir, 'memory', 'shortterm') : path.join(__dirname, '..', 'data', 'memory', 'shortterm'));
243
+ const shortTermMaxEntries = cfg.shortTermMaxEntries;
244
+ const shortTermMaxAgeMs = cfg.shortTermMaxAgeHours * 60 * 60 * 1000;
245
+ const shortTermInjectMaxChars = cfg.shortTermInjectMaxChars;
246
+ const actionFollowupDepth = cfg.actionFollowupDepth;
247
+ const reactionHandlerEnabled = cfg.reactionHandlerEnabled;
248
+ const reactionRemoveHandlerEnabled = cfg.reactionRemoveHandlerEnabled;
249
+ const reactionMaxAgeHours = cfg.reactionMaxAgeHours;
250
+ const reactionMaxAgeMs = reactionMaxAgeHours * 60 * 60 * 1000;
251
+ const healthCommandsEnabled = cfg.healthCommandsEnabled;
252
+ const healthVerboseAllowlist = cfg.healthVerboseAllowlist;
253
+ const statusChannel = cfg.statusChannel;
254
+ const guildId = cfg.guildId;
255
+ const cronEnabled = cfg.cronEnabled;
256
+ let cronModel = cfg.cronModel;
257
+ const discordActionsCrons = cfg.discordActionsCrons;
258
+ const cronAutoTag = cfg.cronAutoTag;
259
+ let cronAutoTagModel = cfg.cronAutoTagModel;
260
+ const cronStatsDir = cfg.cronStatsDirOverride
261
+ || (dataDir ? path.join(dataDir, 'cron') : path.join(__dirname, '..', 'data', 'cron'));
262
+ const cronTagMapPath = cfg.cronTagMapPathOverride
263
+ || path.join(cronStatsDir, 'tag-map.json');
264
+ const cronTagMapSeedPath = path.join(__dirname, '..', 'scripts', 'cron', 'cron-tag-map.json');
265
+ const cronStatsPath = path.join(cronStatsDir, 'cron-run-stats.json');
266
+ if (requireChannelContext && !discordChannelContext) {
267
+ log.error({ contentDir }, 'DISCORD_REQUIRE_CHANNEL_CONTEXT=1 but channel context failed to initialize');
268
+ process.exit(1);
269
+ }
270
+ const defaultWorkspaceCwd = dataDir
271
+ ? path.join(dataDir, 'workspace')
272
+ : path.join(__dirname, '..', 'workspace');
273
+ const workspaceCwd = cfg.workspaceCwdOverride || defaultWorkspaceCwd;
274
+ const groupsDir = cfg.groupsDirOverride || path.join(__dirname, '..', 'groups');
275
+ const useGroupDirCwd = cfg.useGroupDirCwd;
276
+ // --- Scaffold workspace PA files (first run) ---
277
+ await ensureWorkspaceBootstrapFiles(workspaceCwd, log);
278
+ // --- Probe workspace permissions (startup visibility) ---
279
+ const permProbe = await probeWorkspacePermissions(workspaceCwd);
280
+ if (permProbe.status === 'missing') {
281
+ log.warn({ workspaceCwd }, 'PERMISSIONS.json not found — using env/default tools (this may grant full access). ' +
282
+ 'Run onboarding or manually create workspace/PERMISSIONS.json.');
283
+ }
284
+ else if (permProbe.status === 'invalid') {
285
+ log.error({ workspaceCwd, reason: permProbe.reason }, 'PERMISSIONS.json is invalid — falling back to env/default tools.');
286
+ }
287
+ else {
288
+ log.info({ workspaceCwd, tier: permProbe.permissions.tier }, 'workspace permissions loaded');
289
+ }
290
+ // --- Resolve bot display name ---
291
+ const botDisplayName = await resolveDisplayName({
292
+ configName: cfg.botDisplayName,
293
+ workspaceCwd,
294
+ log,
295
+ });
296
+ log.info({ botDisplayName }, 'resolved bot display name');
297
+ // Resolve task data paths early for JSON healing (before scaffold state is parsed).
298
+ const tasksDataRoot = dataDir ?? path.join(__dirname, '..', 'data');
299
+ const tasksDataDir = path.join(tasksDataRoot, 'tasks');
300
+ const tasksTagMapDefaultPath = resolveTaskDataPath(tasksDataRoot, 'tag-map.json')
301
+ ?? path.join(tasksDataDir, 'tag-map.json');
302
+ const tasksTagMapPath = cfg.tasksTagMapPathOverride || tasksTagMapDefaultPath;
303
+ // --- Load persisted scaffold state (forum IDs created on previous boots) ---
304
+ const scaffoldStatePath = path.join(pidLockDir, 'system-scaffold.json');
305
+ // --- JSON healing: back up any corrupted JSON stores before loaders read them ---
306
+ await healCorruptedJsonStores([
307
+ { path: scaffoldStatePath, label: 'system-scaffold' },
308
+ { path: cronStatsPath, label: 'cron-run-stats' },
309
+ { path: cronTagMapPath, label: 'cron-tag-map' },
310
+ { path: tasksTagMapPath, label: 'tasks-tag-map' },
311
+ ], log);
312
+ let scaffoldState = {};
313
+ try {
314
+ const raw = await fs.readFile(scaffoldStatePath, 'utf8');
315
+ const parsed = JSON.parse(raw);
316
+ if (parsed && typeof parsed === 'object') {
317
+ // Invalidate if guild changed — IDs from a different guild are meaningless.
318
+ if (guildId && typeof parsed.guildId === 'string' && parsed.guildId !== guildId) {
319
+ log.warn({ savedGuild: parsed.guildId, currentGuild: guildId }, 'system-scaffold: guild mismatch, ignoring persisted forum IDs');
320
+ }
321
+ else {
322
+ if (typeof parsed.systemCategoryId === 'string')
323
+ scaffoldState.systemCategoryId = parsed.systemCategoryId;
324
+ if (typeof parsed.cronsForumId === 'string')
325
+ scaffoldState.cronsForumId = parsed.cronsForumId;
326
+ if (typeof parsed.tasksForumId === 'string')
327
+ scaffoldState.tasksForumId = parsed.tasksForumId;
328
+ }
329
+ }
330
+ }
331
+ catch {
332
+ // No persisted state yet — first boot or file missing.
333
+ }
334
+ const cronForum = cfg.cronForum || scaffoldState.cronsForumId;
335
+ // --- Tasks subsystem ---
336
+ const tasksEnabled = cfg.tasksEnabled;
337
+ const tasksCwd = cfg.tasksCwdOverride || workspaceCwd;
338
+ const tasksForum = cfg.tasksForum || scaffoldState.tasksForumId || '';
339
+ const tasksPersistPath = resolveTaskDataPath(tasksDataRoot, 'tasks.jsonl')
340
+ ?? path.join(tasksDataDir, 'tasks.jsonl');
341
+ const tasksTagMapSeedPath = path.join(__dirname, '..', 'scripts', 'tasks', 'tag-map.json');
342
+ const tasksMentionUser = cfg.tasksMentionUser;
343
+ const tasksSidebar = cfg.tasksSidebar;
344
+ const tasksAutoTag = cfg.tasksAutoTag;
345
+ let tasksAutoTagModel = cfg.tasksAutoTagModel;
346
+ const tasksSyncFailureRetryEnabled = cfg.tasksSyncFailureRetryEnabled;
347
+ const tasksSyncFailureRetryDelayMs = cfg.tasksSyncFailureRetryDelayMs;
348
+ const tasksSyncDeferredRetryDelayMs = cfg.tasksSyncDeferredRetryDelayMs;
349
+ const discordActionsTasks = cfg.discordActionsTasks;
350
+ const tasksPrefix = cfg.tasksPrefix;
351
+ // Initialize shared task store (used by tasks, forge, and plan subsystems).
352
+ // Created unconditionally so forge/plan have a persistent store even when tasks are disabled.
353
+ for (const dir of new Set([path.dirname(tasksPersistPath), path.dirname(tasksTagMapPath)])) {
354
+ await fs.mkdir(dir, { recursive: true });
355
+ }
356
+ const tasksMigration = await migrateLegacyTaskDataFile(tasksDataRoot, 'tasks.jsonl');
357
+ if (tasksMigration.migrated) {
358
+ log.warn({ from: tasksMigration.fromPath, to: tasksMigration.toPath }, 'tasks: migrated legacy beads task store to canonical path');
359
+ }
360
+ const sharedTaskStore = new TaskStore({ prefix: tasksPrefix, persistPath: tasksPersistPath });
361
+ await sharedTaskStore.load();
362
+ log.info({ count: sharedTaskStore.size(), prefix: tasksPrefix }, 'tasks:store loaded');
363
+ const runtimeFallbackModel = cfg.runtimeFallbackModel;
364
+ const runtimeMaxBudgetUsd = cfg.runtimeMaxBudgetUsd;
365
+ const appendSystemPrompt = cfg.appendSystemPrompt;
366
+ const claudeBin = cfg.claudeBin;
367
+ const dangerouslySkipPermissions = cfg.dangerouslySkipPermissions;
368
+ const outputFormat = cfg.outputFormat;
369
+ const echoStdio = cfg.echoStdio;
370
+ const verbose = cfg.verbose;
371
+ const claudeDebugFile = cfg.claudeDebugFile ?? null;
372
+ const strictMcpConfig = cfg.strictMcpConfig;
373
+ const sessionScanning = cfg.sessionScanning;
374
+ const toolAwareStreaming = cfg.toolAwareStreaming;
375
+ const multiTurn = cfg.multiTurn;
376
+ const multiTurnHangTimeoutMs = cfg.multiTurnHangTimeoutMs;
377
+ const multiTurnIdleTimeoutMs = cfg.multiTurnIdleTimeoutMs;
378
+ const multiTurnMaxProcesses = cfg.multiTurnMaxProcesses;
379
+ const streamStallTimeoutMs = cfg.streamStallTimeoutMs;
380
+ const progressStallTimeoutMs = cfg.progressStallTimeoutMs;
381
+ const streamStallWarningMs = cfg.streamStallWarningMs;
382
+ const maxConcurrentInvocations = cfg.maxConcurrentInvocations;
383
+ const sharedConcurrencyLimiter = createConcurrencyLimiter(maxConcurrentInvocations);
384
+ // Build runtime registry
385
+ const runtimeRegistry = new RuntimeRegistry();
386
+ const MIN_CLAUDE_CLI_VERSION = '2.1.0';
387
+ const ensureClaudeCliVersion = () => {
388
+ try {
389
+ const versionOutput = execFileSync(claudeBin, ['--version'], { encoding: 'utf8', timeout: 10_000 }).trim();
390
+ const match = versionOutput.match(/(\d+)\.(\d+)\.(\d+)/);
391
+ if (match) {
392
+ const current = [Number(match[1]), Number(match[2]), Number(match[3])];
393
+ const minimum = MIN_CLAUDE_CLI_VERSION.split('.').map(Number);
394
+ let belowMinimum = false;
395
+ for (let i = 0; i < 3; i++) {
396
+ if (current[i] > minimum[i])
397
+ break;
398
+ if (current[i] < minimum[i]) {
399
+ belowMinimum = true;
400
+ break;
401
+ }
402
+ }
403
+ if (belowMinimum) {
404
+ log.error({ version: current.join('.'), minimum: MIN_CLAUDE_CLI_VERSION }, `Claude CLI >= ${MIN_CLAUDE_CLI_VERSION} required for Glob/Grep/Write tools and --fallback-model/--max-budget-usd/--append-system-prompt flags. Run: claude update`);
405
+ process.exit(1);
406
+ }
407
+ log.info({ claudeCliVersion: current.join('.') }, 'Claude CLI version check passed');
408
+ return;
409
+ }
410
+ log.warn({ raw: versionOutput.slice(0, 100) }, 'Could not parse Claude CLI version (continuing)');
411
+ }
412
+ catch (err) {
413
+ log.error({ err, claudeBin }, 'Failed to check Claude CLI version — is the CLI installed?');
414
+ process.exit(1);
415
+ }
416
+ };
417
+ const registerClaudeRuntime = () => {
418
+ ensureClaudeCliVersion();
419
+ const claudeRuntime = createClaudeCliRuntime({
420
+ claudeBin,
421
+ dangerouslySkipPermissions,
422
+ outputFormat,
423
+ echoStdio,
424
+ verbose,
425
+ debugFile: claudeDebugFile,
426
+ strictMcpConfig,
427
+ fallbackModel: runtimeFallbackModel,
428
+ maxBudgetUsd: runtimeMaxBudgetUsd,
429
+ appendSystemPrompt,
430
+ sessionScanning,
431
+ log,
432
+ multiTurn,
433
+ multiTurnHangTimeoutMs,
434
+ multiTurnIdleTimeoutMs,
435
+ multiTurnMaxProcesses,
436
+ streamStallTimeoutMs,
437
+ progressStallTimeoutMs,
438
+ });
439
+ const limitedClaudeRuntime = withConcurrencyLimit(claudeRuntime, {
440
+ maxConcurrentInvocations,
441
+ limiter: sharedConcurrencyLimiter,
442
+ log,
443
+ });
444
+ runtimeRegistry.register('claude', limitedClaudeRuntime);
445
+ return limitedClaudeRuntime;
446
+ };
447
+ if (cfg.openaiApiKey) {
448
+ const openaiRuntimeRaw = createOpenAICompatRuntime({
449
+ id: 'openai',
450
+ baseUrl: cfg.openaiBaseUrl ?? 'https://api.openai.com/v1',
451
+ apiKey: cfg.openaiApiKey,
452
+ defaultModel: cfg.openaiModel,
453
+ log,
454
+ });
455
+ const openaiRuntime = withConcurrencyLimit(openaiRuntimeRaw, {
456
+ maxConcurrentInvocations,
457
+ limiter: sharedConcurrencyLimiter,
458
+ log,
459
+ });
460
+ runtimeRegistry.register('openai', openaiRuntime);
461
+ }
462
+ if (cfg.openrouterApiKey) {
463
+ const openrouterRuntimeRaw = createOpenAICompatRuntime({
464
+ id: 'openrouter',
465
+ baseUrl: cfg.openrouterBaseUrl ?? 'https://openrouter.ai/api/v1',
466
+ apiKey: cfg.openrouterApiKey,
467
+ defaultModel: cfg.openrouterModel,
468
+ log,
469
+ });
470
+ const openrouterRuntime = withConcurrencyLimit(openrouterRuntimeRaw, {
471
+ maxConcurrentInvocations,
472
+ limiter: sharedConcurrencyLimiter,
473
+ log,
474
+ });
475
+ runtimeRegistry.register('openrouter', openrouterRuntime);
476
+ log.info({ baseUrl: cfg.openrouterBaseUrl ?? 'https://openrouter.ai/api/v1', model: cfg.openrouterModel }, 'runtime:openrouter registered');
477
+ }
478
+ // Register Codex CLI runtime.
479
+ const codexRuntimeRaw = createCodexCliRuntime({
480
+ codexBin: cfg.codexBin,
481
+ defaultModel: cfg.codexModel,
482
+ dangerouslyBypassApprovalsAndSandbox: cfg.codexDangerouslyBypassApprovalsAndSandbox,
483
+ disableSessions: cfg.codexDisableSessions,
484
+ log,
485
+ });
486
+ const codexRuntime = withConcurrencyLimit(codexRuntimeRaw, {
487
+ maxConcurrentInvocations,
488
+ limiter: sharedConcurrencyLimiter,
489
+ log,
490
+ });
491
+ runtimeRegistry.register('codex', codexRuntime);
492
+ log.info({
493
+ codexBin: cfg.codexBin,
494
+ model: cfg.codexModel,
495
+ dangerouslyBypassApprovalsAndSandbox: cfg.codexDangerouslyBypassApprovalsAndSandbox,
496
+ disableSessions: cfg.codexDisableSessions,
497
+ }, 'runtime:codex registered');
498
+ // Register Gemini CLI runtime.
499
+ const geminiRuntimeRaw = createGeminiCliRuntime({
500
+ geminiBin: cfg.geminiBin,
501
+ defaultModel: cfg.geminiModel,
502
+ log,
503
+ });
504
+ const geminiRuntime = withConcurrencyLimit(geminiRuntimeRaw, {
505
+ maxConcurrentInvocations,
506
+ limiter: sharedConcurrencyLimiter,
507
+ log,
508
+ });
509
+ runtimeRegistry.register('gemini', geminiRuntime);
510
+ log.info({ geminiBin: cfg.geminiBin, model: cfg.geminiModel }, 'runtime:gemini registered');
511
+ const claudeRequested = primaryRuntimeName === 'claude' || cfg.forgeDrafterRuntime === 'claude' || cfg.forgeAuditorRuntime === 'claude';
512
+ if (claudeRequested) {
513
+ registerClaudeRuntime();
514
+ }
515
+ const runtime = runtimeRegistry.get(primaryRuntimeName);
516
+ if (!runtime) {
517
+ log.error({
518
+ primaryRuntime: primaryRuntimeName,
519
+ availableRuntimes: runtimeRegistry.list(),
520
+ }, 'PRIMARY_RUNTIME is not available. Check configuration (OPENAI_API_KEY, Claude CLI, runtime name).');
521
+ process.exit(1);
522
+ }
523
+ const limitedRuntime = runtime;
524
+ runtimeModel = resolveModel(cfg.runtimeModel, runtime.id);
525
+ summaryModel = resolveModel(cfg.summaryModel, runtime.id);
526
+ cronModel = resolveModel(cfg.cronModel, runtime.id);
527
+ cronAutoTagModel = resolveModel(cfg.cronAutoTagModel, runtime.id);
528
+ tasksAutoTagModel = resolveModel(cfg.tasksAutoTagModel, runtime.id);
529
+ log.info({ primaryRuntime: primaryRuntimeName, runtimeId: runtime.id, model: runtimeModel }, 'runtime:primary selected');
530
+ // Debug: surface common "works in terminal but not in systemd" issues without logging secrets.
531
+ if (cfg.debugRuntime) {
532
+ log.info({
533
+ env: {
534
+ HOME: process.env.HOME,
535
+ USER: process.env.USER,
536
+ PATH: process.env.PATH,
537
+ XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
538
+ DBUS_SESSION_BUS_ADDRESS: process.env.DBUS_SESSION_BUS_ADDRESS ? '(set)' : '(unset)',
539
+ DISPLAY: process.env.DISPLAY ? '(set)' : '(unset)',
540
+ WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY ? '(set)' : '(unset)',
541
+ },
542
+ claude: {
543
+ bin: claudeBin,
544
+ outputFormat,
545
+ echoStdio,
546
+ verbose,
547
+ dangerouslySkipPermissions,
548
+ },
549
+ runtime: {
550
+ selected: primaryRuntimeName,
551
+ runtimeId: runtime.id,
552
+ model: runtimeModel,
553
+ toolsCount: runtimeTools.length,
554
+ timeoutMs: runtimeTimeoutMs,
555
+ workspaceCwd,
556
+ groupsDir,
557
+ useRuntimeSessions,
558
+ maxConcurrentInvocations,
559
+ },
560
+ }, 'debug:runtime config');
561
+ }
562
+ // Resolve the drafter runtime (if configured)
563
+ let drafterRuntime;
564
+ if (cfg.forgeDrafterRuntime) {
565
+ drafterRuntime = cfg.forgeDrafterRuntime === primaryRuntimeName
566
+ ? limitedRuntime
567
+ : runtimeRegistry.get(cfg.forgeDrafterRuntime);
568
+ if (!drafterRuntime) {
569
+ log.warn(`FORGE_DRAFTER_RUNTIME='${cfg.forgeDrafterRuntime}' but no adapter registered with that name. Available: ${runtimeRegistry.list().join(', ')}. Falling back to PRIMARY_RUNTIME='${primaryRuntimeName}'.`);
570
+ }
571
+ }
572
+ // Resolve the auditor runtime (if configured)
573
+ let auditorRuntime;
574
+ if (cfg.forgeAuditorRuntime) {
575
+ auditorRuntime = cfg.forgeAuditorRuntime === primaryRuntimeName
576
+ ? limitedRuntime
577
+ : runtimeRegistry.get(cfg.forgeAuditorRuntime);
578
+ if (!auditorRuntime) {
579
+ log.warn(`FORGE_AUDITOR_RUNTIME='${cfg.forgeAuditorRuntime}' but no adapter registered with that name. Available: ${runtimeRegistry.list().join(', ')}. Falling back to PRIMARY_RUNTIME='${primaryRuntimeName}'.`);
580
+ }
581
+ }
582
+ // Collect the set of provider IDs that are actually in use.
583
+ // Used to skip credential checks for providers that aren't configured.
584
+ const activeProviders = new Set([runtime.id]);
585
+ if (forgeCommandsEnabled) {
586
+ if (drafterRuntime?.id)
587
+ activeProviders.add(drafterRuntime.id);
588
+ if (auditorRuntime?.id)
589
+ activeProviders.add(auditorRuntime.id);
590
+ }
591
+ const sessionManager = new SessionManager(path.join(__dirname, '..', 'data', 'sessions.json'));
592
+ // Mutable ref updated by the message handler; read by the !status command.
593
+ const statusLastMessageAt = { current: null };
594
+ const botParams = {
595
+ token,
596
+ allowUserIds,
597
+ guildId,
598
+ botDisplayName,
599
+ dataDir: pidLockDir,
600
+ allowChannelIds: restrictChannelIds ? allowChannelIds : undefined,
601
+ log,
602
+ discordChannelContext,
603
+ requireChannelContext,
604
+ autoIndexChannelContext,
605
+ autoJoinThreads,
606
+ useRuntimeSessions,
607
+ runtime: limitedRuntime,
608
+ sessionManager,
609
+ workspaceCwd,
610
+ projectCwd: projectRoot,
611
+ groupsDir,
612
+ useGroupDirCwd,
613
+ runtimeModel,
614
+ runtimeTools,
615
+ runtimeTimeoutMs,
616
+ discordActionsEnabled,
617
+ discordActionsChannels,
618
+ discordActionsMessaging,
619
+ discordActionsGuild,
620
+ discordActionsModeration,
621
+ discordActionsPolls,
622
+ discordActionsBotProfile,
623
+ // Enable tasks/crons actions only after contexts are configured.
624
+ discordActionsTasks: false,
625
+ discordActionsCrons: false,
626
+ // Forge/plan/memory action flags — contexts are wired below after subsystem init.
627
+ discordActionsForge: discordActionsForge && forgeCommandsEnabled,
628
+ discordActionsPlan: discordActionsPlan && planCommandsEnabled,
629
+ discordActionsMemory: discordActionsMemory && durableMemoryEnabled,
630
+ discordActionsConfig: discordActionsEnabled, // Always enabled when actions are on — model switching is a core capability.
631
+ discordActionsDefer: cfg.discordActionsDefer,
632
+ deferMaxDelaySeconds: cfg.deferMaxDelaySeconds,
633
+ deferMaxConcurrent: cfg.deferMaxConcurrent,
634
+ deferScheduler: undefined,
635
+ taskCtx: undefined,
636
+ cronCtx: undefined,
637
+ forgeCtx: undefined,
638
+ planCtx: undefined,
639
+ memoryCtx: undefined,
640
+ configCtx: undefined,
641
+ messageHistoryBudget,
642
+ summaryEnabled,
643
+ summaryModel,
644
+ summaryMaxChars,
645
+ summaryEveryNTurns,
646
+ summaryDataDir,
647
+ durableMemoryEnabled,
648
+ durableDataDir,
649
+ durableInjectMaxChars,
650
+ durableMaxItems,
651
+ memoryCommandsEnabled,
652
+ planCommandsEnabled,
653
+ planPhasesEnabled,
654
+ planPhaseMaxContextFiles,
655
+ planPhaseTimeoutMs,
656
+ planPhaseMaxAuditFixAttempts,
657
+ forgeCommandsEnabled,
658
+ forgeMaxAuditRounds,
659
+ forgeDrafterModel,
660
+ forgeAuditorModel,
661
+ forgeTimeoutMs,
662
+ forgeProgressThrottleMs,
663
+ forgeAutoImplement,
664
+ drafterRuntime,
665
+ auditorRuntime,
666
+ summaryToDurableEnabled,
667
+ shortTermMemoryEnabled,
668
+ shortTermDataDir,
669
+ shortTermMaxEntries,
670
+ shortTermMaxAgeMs,
671
+ shortTermInjectMaxChars,
672
+ statusChannel,
673
+ bootstrapEnsureTasksForum: tasksEnabled,
674
+ existingCronsId: isSnowflake(cronForum ?? '') ? cronForum : undefined,
675
+ existingTasksId: isSnowflake(tasksForum) ? tasksForum : undefined,
676
+ toolAwareStreaming,
677
+ streamStallWarningMs,
678
+ actionFollowupDepth,
679
+ reactionHandlerEnabled,
680
+ reactionRemoveHandlerEnabled,
681
+ reactionMaxAgeMs,
682
+ healthCommandsEnabled,
683
+ healthVerboseAllowlist,
684
+ botStatus: cfg.botStatus,
685
+ botActivity: cfg.botActivity,
686
+ botActivityType: cfg.botActivityType,
687
+ botAvatar: cfg.botAvatar,
688
+ healthConfigSnapshot: {
689
+ runtimeModel,
690
+ runtimeTimeoutMs,
691
+ runtimeTools,
692
+ useRuntimeSessions,
693
+ toolAwareStreaming,
694
+ maxConcurrentInvocations,
695
+ discordActionsEnabled,
696
+ summaryEnabled,
697
+ durableMemoryEnabled,
698
+ messageHistoryBudget,
699
+ reactionHandlerEnabled,
700
+ reactionRemoveHandlerEnabled,
701
+ cronEnabled,
702
+ tasksEnabled,
703
+ tasksActive: false,
704
+ tasksSyncFailureRetryEnabled,
705
+ tasksSyncFailureRetryDelayMs,
706
+ tasksSyncDeferredRetryDelayMs,
707
+ requireChannelContext,
708
+ autoIndexChannelContext,
709
+ },
710
+ metrics: globalMetrics,
711
+ appendSystemPrompt,
712
+ startupInjection,
713
+ statusCommandContext: {
714
+ startedAt: bootStartMs,
715
+ lastMessageAt: statusLastMessageAt,
716
+ discordToken: token,
717
+ openaiApiKey: cfg.openaiApiKey,
718
+ openaiBaseUrl: cfg.openaiBaseUrl,
719
+ openrouterApiKey: cfg.openrouterApiKey,
720
+ openrouterBaseUrl: cfg.openrouterBaseUrl,
721
+ paFilePaths: ['SOUL.md', 'IDENTITY.md', 'USER.md'].map((f) => ({
722
+ label: f,
723
+ path: path.join(workspaceCwd, f),
724
+ })),
725
+ apiCheckTimeoutMs: 5000,
726
+ workspaceCwd,
727
+ summaryDataDir,
728
+ durableDataDir,
729
+ durableMemoryEnabled,
730
+ cronScheduler: null,
731
+ sharedTaskStore,
732
+ activeProviders,
733
+ },
734
+ };
735
+ if (discordActionsEnabled && cfg.discordActionsDefer) {
736
+ const handleDeferredRun = async (run) => {
737
+ const { action, context } = run;
738
+ const guild = context.guild;
739
+ if (!guild) {
740
+ log?.warn({ run, action }, 'defer:missing-guild');
741
+ return;
742
+ }
743
+ const channel = resolveChannel(guild, action.channel);
744
+ if (!channel) {
745
+ log?.warn({ run, channel: action.channel }, 'defer:target channel not found');
746
+ return;
747
+ }
748
+ if (botParams.allowChannelIds?.size) {
749
+ const ch = channel;
750
+ const isThread = typeof ch?.isThread === 'function' ? ch.isThread() : false;
751
+ const parentId = isThread ? String(ch.parentId ?? '') : '';
752
+ const allowed = botParams.allowChannelIds.has(channel.id) ||
753
+ (parentId && botParams.allowChannelIds.has(parentId));
754
+ if (!allowed) {
755
+ log?.warn({ channelId: channel.id }, 'defer:target channel not allowlisted');
756
+ return;
757
+ }
758
+ }
759
+ const isThread = typeof channel?.isThread === 'function' ? channel.isThread() : false;
760
+ const threadParentId = isThread ? String(channel.parentId ?? '') : null;
761
+ const channelCtx = resolveDiscordChannelContext({
762
+ ctx: discordChannelContext,
763
+ isDm: false,
764
+ channelId: channel.id,
765
+ threadParentId,
766
+ });
767
+ const paFiles = await loadWorkspacePaFiles(workspaceCwd, { skip: !!appendSystemPrompt });
768
+ const contextFiles = buildContextFiles(paFiles, discordChannelContext, channelCtx.contextPath);
769
+ let inlinedContext = '';
770
+ if (contextFiles.length > 0) {
771
+ try {
772
+ inlinedContext = await inlineContextFiles(contextFiles, {
773
+ required: new Set(discordChannelContext?.paContextFiles ?? []),
774
+ });
775
+ }
776
+ catch (err) {
777
+ log?.warn({ err, channelId: channel.id }, 'defer:context inline failed');
778
+ }
779
+ }
780
+ const deferredActionFlags = {
781
+ channels: botParams.discordActionsChannels,
782
+ messaging: botParams.discordActionsMessaging,
783
+ guild: botParams.discordActionsGuild,
784
+ moderation: botParams.discordActionsModeration,
785
+ polls: botParams.discordActionsPolls,
786
+ tasks: Boolean(botParams.discordActionsTasks),
787
+ crons: Boolean(botParams.discordActionsCrons),
788
+ botProfile: Boolean(botParams.discordActionsBotProfile),
789
+ forge: Boolean(botParams.discordActionsForge),
790
+ plan: Boolean(botParams.discordActionsPlan),
791
+ // Deferred runs do not carry a user identity, so memory actions are disabled.
792
+ memory: false,
793
+ config: Boolean(botParams.discordActionsConfig),
794
+ defer: false,
795
+ };
796
+ let prompt = buildPromptPreamble(inlinedContext) + '\n\n' +
797
+ `---\nDeferred follow-up scheduled for <#${channel.id}> (runs at ${fmtTime(run.runsAt)}).\n---\n` +
798
+ `User message:\n${action.prompt}`;
799
+ if (botParams.discordActionsEnabled) {
800
+ prompt += '\n\n---\n' + discordActionsPromptSection(deferredActionFlags, botDisplayName);
801
+ }
802
+ const noteLines = [];
803
+ let effectiveTools = runtimeTools;
804
+ try {
805
+ const toolsInfo = await resolveEffectiveTools({
806
+ workspaceCwd,
807
+ runtimeTools,
808
+ runtimeCapabilities: runtime.capabilities,
809
+ runtimeId: runtime.id,
810
+ log,
811
+ });
812
+ effectiveTools = toolsInfo.effectiveTools;
813
+ if (toolsInfo.permissionNote)
814
+ noteLines.push(`Permission note: ${toolsInfo.permissionNote}`);
815
+ if (toolsInfo.runtimeCapabilityNote)
816
+ noteLines.push(`Runtime capability note: ${toolsInfo.runtimeCapabilityNote}`);
817
+ }
818
+ catch (err) {
819
+ log?.warn({ err }, 'defer:resolve effective tools failed');
820
+ }
821
+ if (noteLines.length > 0) {
822
+ prompt += `\n\n---\n${noteLines.join('\n')}\n`;
823
+ }
824
+ const addDirs = [];
825
+ if (useGroupDirCwd)
826
+ addDirs.push(workspaceCwd);
827
+ if (discordChannelContext)
828
+ addDirs.push(discordChannelContext.contentDir);
829
+ const uniqueAddDirs = addDirs.length > 0 ? Array.from(new Set(addDirs)) : undefined;
830
+ let finalText = '';
831
+ let deltaText = '';
832
+ let runtimeError;
833
+ try {
834
+ for await (const evt of runtime.invoke({
835
+ prompt,
836
+ model: resolveModel(botParams.runtimeModel, runtime.id),
837
+ cwd: workspaceCwd,
838
+ addDirs: uniqueAddDirs,
839
+ tools: effectiveTools,
840
+ timeoutMs: runtimeTimeoutMs,
841
+ })) {
842
+ if (evt.type === 'text_final') {
843
+ finalText = evt.text;
844
+ }
845
+ else if (evt.type === 'text_delta') {
846
+ deltaText += evt.text;
847
+ }
848
+ else if (evt.type === 'error') {
849
+ runtimeError = evt.message;
850
+ finalText = mapRuntimeErrorToUserMessage(evt.message);
851
+ break;
852
+ }
853
+ }
854
+ }
855
+ catch (err) {
856
+ const msg = err instanceof Error ? err.message : String(err);
857
+ runtimeError ??= msg;
858
+ finalText = mapRuntimeErrorToUserMessage(msg);
859
+ log?.warn({ err }, 'defer:runtime invocation failed');
860
+ }
861
+ const processedText = finalText || deltaText || '';
862
+ const parsed = parseDiscordActions(processedText, deferredActionFlags);
863
+ const actCtx = {
864
+ guild,
865
+ client: context.client,
866
+ channelId: channel.id,
867
+ messageId: `defer-${Date.now()}`,
868
+ threadParentId,
869
+ confirmation: {
870
+ mode: 'automated',
871
+ },
872
+ };
873
+ let actionResults = [];
874
+ if (parsed.actions.length > 0) {
875
+ actionResults = await executeDiscordActions(parsed.actions, actCtx, log, {
876
+ taskCtx: botParams.taskCtx,
877
+ cronCtx: botParams.cronCtx,
878
+ forgeCtx: botParams.forgeCtx,
879
+ planCtx: botParams.planCtx,
880
+ memoryCtx: botParams.memoryCtx,
881
+ configCtx: botParams.configCtx,
882
+ });
883
+ }
884
+ const displayLines = buildDisplayResultLines(parsed.actions, actionResults);
885
+ let outgoingText = parsed.cleanText.trim();
886
+ if (displayLines.length > 0) {
887
+ outgoingText = outgoingText ? `${outgoingText}\n\n${displayLines.join('\n')}` : displayLines.join('\n');
888
+ }
889
+ outgoingText = appendUnavailableActionTypesNotice(outgoingText, parsed.strippedUnrecognizedTypes).trim();
890
+ if (!outgoingText && runtimeError) {
891
+ outgoingText = runtimeError;
892
+ }
893
+ if (outgoingText) {
894
+ try {
895
+ await channel.send({ content: outgoingText, allowedMentions: NO_MENTIONS });
896
+ }
897
+ catch (err) {
898
+ log?.warn({ err, channelId: channel.id }, 'defer:failed to post follow-up');
899
+ }
900
+ }
901
+ };
902
+ const deferScheduler = new DeferScheduler({
903
+ maxDelaySeconds: cfg.deferMaxDelaySeconds,
904
+ maxConcurrent: cfg.deferMaxConcurrent,
905
+ jobHandler: handleDeferredRun,
906
+ });
907
+ botParams.deferScheduler = deferScheduler;
908
+ log.info({ maxDelaySeconds: cfg.deferMaxDelaySeconds, maxConcurrent: cfg.deferMaxConcurrent }, 'defer:scheduler configured');
909
+ }
910
+ let client;
911
+ let status;
912
+ let system;
913
+ try {
914
+ ({ client, status, system } = await startDiscordBot(botParams));
915
+ }
916
+ catch (err) {
917
+ const tokenResult = validateDiscordToken(token);
918
+ log.error({ tokenFormat: tokenResult, error: err instanceof Error ? err.message : String(err) }, 'Discord login failed');
919
+ process.exit(1);
920
+ }
921
+ botStatus = status;
922
+ // --- Persist scaffold state (forum IDs) for next boot ---
923
+ if (system) {
924
+ const newState = {};
925
+ const resolvedGuild = guildId || system.guildId || '';
926
+ if (resolvedGuild)
927
+ newState.guildId = resolvedGuild;
928
+ if (system.systemCategoryId)
929
+ newState.systemCategoryId = system.systemCategoryId;
930
+ if (system.cronsForumId)
931
+ newState.cronsForumId = system.cronsForumId;
932
+ if (system.tasksForumId)
933
+ newState.tasksForumId = system.tasksForumId;
934
+ if (Object.keys(newState).length > 0) {
935
+ try {
936
+ await fs.writeFile(scaffoldStatePath, JSON.stringify(newState, null, 2) + '\n', 'utf8');
937
+ log.info({ scaffoldStatePath }, 'system-scaffold: persisted forum IDs');
938
+ }
939
+ catch (err) {
940
+ log.warn({ err, scaffoldStatePath }, 'system-scaffold: failed to persist forum IDs');
941
+ }
942
+ }
943
+ }
944
+ // --- Cold-start: clean up orphaned in-flight replies from a previous unclean exit ---
945
+ await cleanupOrphanedReplies({ client, dataFilePath: path.join(pidLockDir, 'inflight.json'), log });
946
+ // --- Task thread healing: surface stale task thread refs for deleted Discord threads ---
947
+ healStaleTaskThreadRefs(sharedTaskStore, client, log).catch((err) => {
948
+ log.warn({ err }, 'startup:heal:task thread refs failed');
949
+ });
950
+ // --- Credential health checks (runs after bot connects: full context available) ---
951
+ const resolvedStatusChannelId = statusChannel || system?.statusChannelId || undefined;
952
+ const credentialCheckReport = await runCredentialChecks({
953
+ token: cfg.token,
954
+ openaiApiKey: cfg.openaiApiKey,
955
+ openaiBaseUrl: cfg.openaiBaseUrl,
956
+ openrouterApiKey: cfg.openrouterApiKey,
957
+ openrouterBaseUrl: cfg.openrouterBaseUrl,
958
+ workspacePath: workspaceCwd,
959
+ statusChannelId: resolvedStatusChannelId,
960
+ activeProviders,
961
+ });
962
+ const credentialReport = formatCredentialReport(credentialCheckReport);
963
+ if (credentialCheckReport.criticalFailures.length > 0) {
964
+ for (const name of credentialCheckReport.criticalFailures) {
965
+ const result = credentialCheckReport.results.find((r) => r.name === name);
966
+ log.error({ name, message: result?.message }, 'boot:credential-check: critical credential failed');
967
+ }
968
+ }
969
+ for (const result of credentialCheckReport.results) {
970
+ if (result.status === 'fail' && !credentialCheckReport.criticalFailures.includes(result.name)) {
971
+ log.warn({ name: result.name, message: result.message }, 'boot:credential-check: non-critical credential failed');
972
+ }
973
+ }
974
+ log.info({ credentialReport }, 'boot:credential-check');
975
+ // --- Configure task context after bootstrap (so the forum can be auto-created) ---
976
+ let taskCtx;
977
+ if (tasksEnabled) {
978
+ // Seed tag map from repo if data-dir copy doesn't exist yet.
979
+ await seedTagMap(tasksTagMapSeedPath, tasksTagMapPath);
980
+ const tasksResult = await initializeTasksContext({
981
+ enabled: true,
982
+ tasksCwd,
983
+ tasksForum,
984
+ tasksTagMapPath,
985
+ tasksMentionUser,
986
+ tasksSidebar,
987
+ tasksAutoTag,
988
+ tasksAutoTagModel,
989
+ syncRunOptions: { skipPhase5: cfg.tasksSyncSkipPhase5 },
990
+ tasksSyncFailureRetryEnabled,
991
+ tasksSyncFailureRetryDelayMs,
992
+ tasksSyncDeferredRetryDelayMs,
993
+ runtime,
994
+ resolveModel,
995
+ metrics: globalMetrics,
996
+ statusPoster: botStatus ?? undefined,
997
+ hasInFlightForChannel,
998
+ log,
999
+ systemTasksForumId: system?.tasksForumId,
1000
+ store: sharedTaskStore,
1001
+ });
1002
+ taskCtx = tasksResult.taskCtx;
1003
+ }
1004
+ if (taskCtx) {
1005
+ // Attach status poster now that the bot is connected (may not have been available during pre-flight).
1006
+ if (!taskCtx.statusPoster && botStatus) {
1007
+ taskCtx.statusPoster = botStatus;
1008
+ }
1009
+ botParams.taskCtx = taskCtx;
1010
+ botParams.discordActionsTasks = discordActionsTasks && tasksEnabled;
1011
+ botParams.healthConfigSnapshot.tasksActive = true;
1012
+ // Wire coordinator + sync triggers + startup sync
1013
+ const resolvedGuildId = guildId || system?.guildId || '';
1014
+ const guild = resolvedGuildId ? client.guilds.cache.get(resolvedGuildId) : undefined;
1015
+ if (guild) {
1016
+ // Create forum count sync for tasks.
1017
+ const tasksForumChannel = await resolveTasksForum(guild, taskCtx.forumId);
1018
+ if (tasksForumChannel) {
1019
+ taskForumCountSync = new ForumCountSync(client, tasksForumChannel.id, async () => {
1020
+ return taskCtx.store.list({ status: 'all' }).filter((b) => b.status !== 'closed').length;
1021
+ }, log);
1022
+ taskCtx.forumCountSync = taskForumCountSync;
1023
+ taskForumCountSync.requestUpdate();
1024
+ }
1025
+ // Install forum guard before any async operations that touch the forum.
1026
+ initTasksForumGuard({ client, forumId: taskCtx.forumId, log, store: taskCtx.store, tagMap: taskCtx.tagMap });
1027
+ // Tag bootstrap + reload BEFORE wireTaskSync so the first sync has the correct tag map.
1028
+ if (tasksForumChannel) {
1029
+ try {
1030
+ await ensureForumTags(guild, tasksForumChannel.id, tasksTagMapPath, {
1031
+ seedPath: tasksTagMapSeedPath,
1032
+ log,
1033
+ });
1034
+ }
1035
+ catch (err) {
1036
+ log.warn({ err }, 'tasks:tag bootstrap failed');
1037
+ }
1038
+ try {
1039
+ await reloadTagMapInPlace(tasksTagMapPath, taskCtx.tagMap);
1040
+ }
1041
+ catch (err) {
1042
+ log.warn({ err }, 'tasks:tag map reload failed');
1043
+ }
1044
+ }
1045
+ // Wire coordinator + sync triggers + startup sync (now uses correct tag map).
1046
+ const wired = await wireTaskSync(taskCtx, { client, guild });
1047
+ taskSyncWiring = wired;
1048
+ }
1049
+ else {
1050
+ log.warn({ resolvedGuildId }, 'tasks:sync wiring skipped; guild not in cache');
1051
+ }
1052
+ log.info({ tasksCwd, tasksForum: taskCtx.forumId, tagCount: Object.keys(taskCtx.tagMap).length, autoTag: tasksAutoTag }, 'tasks:initialized');
1053
+ }
1054
+ // --- Forge / Plan / Memory action contexts ---
1055
+ // Initialized before cron so cron executor can reference these contexts.
1056
+ {
1057
+ const plansDir = path.join(workspaceCwd, 'plans');
1058
+ const effectiveTaskStore = sharedTaskStore;
1059
+ if (forgeCommandsEnabled && discordActionsForge) {
1060
+ botParams.forgeCtx = {
1061
+ orchestratorFactory: () => new ForgeOrchestrator({
1062
+ runtime: limitedRuntime,
1063
+ drafterRuntime,
1064
+ auditorRuntime,
1065
+ model: botParams.runtimeModel,
1066
+ cwd: projectRoot,
1067
+ workspaceCwd,
1068
+ taskStore: effectiveTaskStore,
1069
+ plansDir,
1070
+ maxAuditRounds: forgeMaxAuditRounds,
1071
+ progressThrottleMs: forgeProgressThrottleMs,
1072
+ timeoutMs: forgeTimeoutMs,
1073
+ drafterModel: botParams.forgeDrafterModel,
1074
+ auditorModel: botParams.forgeAuditorModel,
1075
+ log,
1076
+ }),
1077
+ plansDir,
1078
+ workspaceCwd,
1079
+ taskStore: effectiveTaskStore,
1080
+ onProgress: async (msg) => {
1081
+ // Action-initiated forges log progress rather than posting to a channel.
1082
+ log.info({ msg }, 'forge:action:progress');
1083
+ },
1084
+ log,
1085
+ };
1086
+ log.info('forge:action context initialized');
1087
+ }
1088
+ if (planCommandsEnabled && discordActionsPlan) {
1089
+ botParams.planCtx = {
1090
+ plansDir,
1091
+ workspaceCwd,
1092
+ taskStore: effectiveTaskStore,
1093
+ log,
1094
+ runtime: limitedRuntime,
1095
+ model: runtimeModel,
1096
+ phaseTimeoutMs: planPhaseTimeoutMs,
1097
+ maxAuditFixAttempts: planPhaseMaxAuditFixAttempts,
1098
+ onProgress: async (msg) => {
1099
+ log.info({ msg }, 'plan:action:progress');
1100
+ },
1101
+ };
1102
+ log.info('plan:action context initialized');
1103
+ }
1104
+ if (durableMemoryEnabled && discordActionsMemory) {
1105
+ // Store a template memoryCtx — handlers override userId and Discord metadata per-message.
1106
+ botParams.memoryCtx = {
1107
+ userId: '', // Placeholder — overridden per-message with msg.author.id.
1108
+ durableDataDir,
1109
+ durableMaxItems,
1110
+ durableInjectMaxChars,
1111
+ log,
1112
+ };
1113
+ log.info('memory:action context initialized');
1114
+ }
1115
+ if (discordActionsEnabled) {
1116
+ // Config actions are always available when actions are enabled.
1117
+ // botParams is read by reference, so mutations here take effect on next invocation.
1118
+ botParams.configCtx = {
1119
+ botParams,
1120
+ runtime: limitedRuntime,
1121
+ };
1122
+ log.info('config:action context initialized');
1123
+ }
1124
+ }
1125
+ // --- Cron subsystem ---
1126
+ const effectiveCronForum = system?.cronsForumId || cronForum || undefined;
1127
+ if (cronEnabled && effectiveCronForum) {
1128
+ // Seed tag map from repo if target doesn't exist yet.
1129
+ await seedTagMap(cronTagMapSeedPath, cronTagMapPath);
1130
+ // Load persistent stats.
1131
+ const cronLocksDir = path.join(cronStatsDir, 'locks');
1132
+ await fs.mkdir(cronLocksDir, { recursive: true });
1133
+ const cronStats = await loadRunStats(cronStatsPath);
1134
+ // --- Cron record healing: remove stale stats records for deleted threads ---
1135
+ await healStaleCronRecords(cronStats, client, log);
1136
+ const cronActionFlags = {
1137
+ channels: discordActionsChannels,
1138
+ messaging: discordActionsMessaging,
1139
+ guild: discordActionsGuild,
1140
+ moderation: discordActionsModeration,
1141
+ polls: discordActionsPolls,
1142
+ tasks: discordActionsTasks && tasksEnabled && Boolean(taskCtx),
1143
+ // Prevent cron jobs from mutating cron state via emitted action blocks.
1144
+ crons: false,
1145
+ botProfile: false, // Intentionally excluded from cron flows to avoid rate-limit and abuse issues.
1146
+ forge: discordActionsForge && forgeCommandsEnabled, // Enables cron → forge autonomous workflows.
1147
+ plan: discordActionsPlan && planCommandsEnabled, // Enables cron → plan autonomous workflows.
1148
+ memory: false, // No user context in cron flows.
1149
+ config: false, // No model switching from cron flows.
1150
+ defer: false,
1151
+ };
1152
+ const cronRunControl = new CronRunControl();
1153
+ // Load cron tag map (strict, but fallback to empty on first run)
1154
+ const cronTagMap = await loadCronTagMapStrict(cronTagMapPath).catch((err) => {
1155
+ log.warn({ err, cronTagMapPath }, 'cron:tag-map strict load failed; starting with empty map');
1156
+ return {};
1157
+ });
1158
+ const cronPendingThreadIds = new Set();
1159
+ const cronCtx = {
1160
+ scheduler: null, // Will be set after scheduler creation.
1161
+ client,
1162
+ forumId: effectiveCronForum,
1163
+ tagMapPath: cronTagMapPath,
1164
+ tagMap: cronTagMap,
1165
+ statsStore: cronStats,
1166
+ runtime,
1167
+ autoTag: cronAutoTag,
1168
+ autoTagModel: cronAutoTagModel,
1169
+ cwd: workspaceCwd,
1170
+ allowUserIds,
1171
+ log,
1172
+ pendingThreadIds: cronPendingThreadIds,
1173
+ };
1174
+ if (botParams.deferScheduler) {
1175
+ cronCtx.deferScheduler = botParams.deferScheduler;
1176
+ }
1177
+ const cronExecCtx = {
1178
+ client,
1179
+ runtime,
1180
+ model: runtimeModel,
1181
+ cronExecModel: undefined,
1182
+ cwd: workspaceCwd,
1183
+ tools: runtimeTools,
1184
+ timeoutMs: runtimeTimeoutMs,
1185
+ status: botStatus,
1186
+ log,
1187
+ allowChannelIds: restrictChannelIds ? allowChannelIds : undefined,
1188
+ discordActionsEnabled,
1189
+ actionFlags: cronActionFlags,
1190
+ deferScheduler: botParams.deferScheduler,
1191
+ taskCtx,
1192
+ cronCtx,
1193
+ forgeCtx: botParams.forgeCtx,
1194
+ planCtx: botParams.planCtx,
1195
+ statsStore: cronStats,
1196
+ lockDir: cronLocksDir,
1197
+ runControl: cronRunControl,
1198
+ };
1199
+ savedCronExecCtx = cronExecCtx;
1200
+ cronScheduler = new CronScheduler((job) => executeCronJob(job, cronExecCtx), log);
1201
+ cronCtx.scheduler = cronScheduler;
1202
+ cronCtx.executorCtx = cronExecCtx;
1203
+ botParams.cronCtx = cronCtx;
1204
+ botParams.statusCommandContext.cronScheduler = cronScheduler;
1205
+ botParams.discordActionsCrons = discordActionsCrons && cronEnabled;
1206
+ let cronForumResult = { forumId: '' };
1207
+ try {
1208
+ cronForumResult = await initCronForum({
1209
+ client,
1210
+ forumChannelNameOrId: effectiveCronForum,
1211
+ scheduler: cronScheduler,
1212
+ runtime,
1213
+ cronModel,
1214
+ cwd: workspaceCwd,
1215
+ allowUserIds,
1216
+ log,
1217
+ statsStore: cronStats,
1218
+ pendingThreadIds: cronPendingThreadIds,
1219
+ onCountChanged: () => cronForumCountSync?.requestUpdate(),
1220
+ });
1221
+ }
1222
+ catch (err) {
1223
+ log.error({ err }, 'cron:forum init failed');
1224
+ }
1225
+ // Create forum count sync for crons (after initCronForum so all jobs are loaded).
1226
+ if (cronForumResult.forumId) {
1227
+ cronForumCountSync = new ForumCountSync(client, cronForumResult.forumId, () => cronScheduler.listJobs().length, log);
1228
+ cronCtx.forumCountSync = cronForumCountSync;
1229
+ cronForumCountSync.requestUpdate();
1230
+ }
1231
+ // Wire coordinator + watcher for cron tag-map hot-reload
1232
+ if (cronForumResult.forumId) {
1233
+ const cronSyncCoordinator = new CronSyncCoordinator({
1234
+ client,
1235
+ forumId: cronForumResult.forumId,
1236
+ scheduler: cronScheduler,
1237
+ statsStore: cronStats,
1238
+ runtime,
1239
+ tagMap: cronTagMap,
1240
+ tagMapPath: cronTagMapPath,
1241
+ autoTag: cronAutoTag,
1242
+ autoTagModel: cronAutoTagModel,
1243
+ cwd: workspaceCwd,
1244
+ log,
1245
+ forumCountSync: cronForumCountSync,
1246
+ });
1247
+ cronCtx.syncCoordinator = cronSyncCoordinator;
1248
+ // Startup sync (fire-and-forget; reconciles tags changed while bot was down)
1249
+ cronSyncCoordinator.sync().catch((err) => {
1250
+ log.warn({ err }, 'cron:startup-sync failed');
1251
+ });
1252
+ // File watcher for tag-map hot-reload
1253
+ cronTagMapWatcher = startCronTagMapWatcher({
1254
+ coordinator: cronSyncCoordinator,
1255
+ tagMapPath: cronTagMapPath,
1256
+ log,
1257
+ });
1258
+ }
1259
+ // Bootstrap forum tags from the tag map (creates missing tags on the Discord forum).
1260
+ if (system?.guildId) {
1261
+ const guild = client.guilds.cache.get(system.guildId);
1262
+ if (guild) {
1263
+ try {
1264
+ await ensureForumTags(guild, effectiveCronForum, cronTagMapPath, { log });
1265
+ }
1266
+ catch (err) {
1267
+ log.warn({ err }, 'cron:forum tag bootstrap failed');
1268
+ }
1269
+ }
1270
+ }
1271
+ log.info({ cronForum: effectiveCronForum, autoTag: cronAutoTag, actionsCrons: discordActionsCrons, statsDir: cronStatsDir }, 'cron:initialized');
1272
+ }
1273
+ else if (cronEnabled && !effectiveCronForum) {
1274
+ log.warn('DISCOCLAW_CRON_ENABLED=1 but no automations forum was resolved (set DISCORD_GUILD_ID or DISCOCLAW_CRON_FORUM); cron subsystem disabled');
1275
+ }
1276
+ // --- Webhook subsystem ---
1277
+ if (cfg.webhookEnabled && savedCronExecCtx) {
1278
+ if (!cfg.webhookConfigPath) {
1279
+ log.warn('DISCOCLAW_WEBHOOK_ENABLED=1 but DISCOCLAW_WEBHOOK_CONFIG is not set; webhook server disabled');
1280
+ }
1281
+ else {
1282
+ const resolvedGuildId = guildId || system?.guildId || '';
1283
+ if (!resolvedGuildId) {
1284
+ log.warn('DISCOCLAW_WEBHOOK_ENABLED=1 but no guild ID resolved; webhook server disabled');
1285
+ }
1286
+ else {
1287
+ // Build a webhook-specific executor context with security overrides:
1288
+ // no Discord actions, no tools.
1289
+ const webhookExecCtx = {
1290
+ ...savedCronExecCtx,
1291
+ discordActionsEnabled: false,
1292
+ tools: [],
1293
+ };
1294
+ try {
1295
+ const webhookHost = '127.0.0.1';
1296
+ webhookServer = await startWebhookServer({
1297
+ configPath: cfg.webhookConfigPath,
1298
+ port: cfg.webhookPort,
1299
+ host: webhookHost,
1300
+ guildId: resolvedGuildId,
1301
+ executorCtx: webhookExecCtx,
1302
+ log,
1303
+ });
1304
+ log.info({ port: cfg.webhookPort, configPath: cfg.webhookConfigPath }, 'webhook:server started');
1305
+ if (webhookHost === '127.0.0.1' || webhookHost === '::1') {
1306
+ log.warn({ host: webhookHost, port: cfg.webhookPort }, 'webhook:server is bound to loopback — external services (e.g. GitHub) cannot reach it. See docs/webhook-exposure.md for exposure options (Tailscale Funnel recommended)');
1307
+ }
1308
+ }
1309
+ catch (err) {
1310
+ log.error({ err }, 'webhook:server failed to start');
1311
+ }
1312
+ }
1313
+ }
1314
+ }
1315
+ else if (cfg.webhookEnabled && !savedCronExecCtx) {
1316
+ log.warn('DISCOCLAW_WEBHOOK_ENABLED=1 but cron executor context is not available; webhook server disabled');
1317
+ }
1318
+ if (reactionHandlerEnabled) {
1319
+ log.info({ reactionMaxAgeHours }, 'reaction:handler enabled');
1320
+ }
1321
+ if (reactionRemoveHandlerEnabled) {
1322
+ log.info({ reactionMaxAgeHours }, 'reaction-remove:handler enabled');
1323
+ }
1324
+ log.info('Discord bot started');
1325
+ // --- Boot report (replaces the bare online() call in startDiscordBot) ---
1326
+ if (botStatus?.bootReport) {
1327
+ const actionCategoriesEnabled = [];
1328
+ if (discordActionsChannels)
1329
+ actionCategoriesEnabled.push('channels');
1330
+ if (discordActionsMessaging)
1331
+ actionCategoriesEnabled.push('messaging');
1332
+ if (discordActionsGuild)
1333
+ actionCategoriesEnabled.push('guild');
1334
+ if (discordActionsModeration)
1335
+ actionCategoriesEnabled.push('moderation');
1336
+ if (discordActionsPolls)
1337
+ actionCategoriesEnabled.push('polls');
1338
+ if (discordActionsTasks && tasksEnabled)
1339
+ actionCategoriesEnabled.push('tasks');
1340
+ if (discordActionsCrons && cronEnabled)
1341
+ actionCategoriesEnabled.push('crons');
1342
+ if (discordActionsBotProfile)
1343
+ actionCategoriesEnabled.push('bot-profile');
1344
+ if (discordActionsForge && forgeCommandsEnabled)
1345
+ actionCategoriesEnabled.push('forge');
1346
+ if (discordActionsPlan && planCommandsEnabled)
1347
+ actionCategoriesEnabled.push('plan');
1348
+ if (discordActionsMemory && durableMemoryEnabled)
1349
+ actionCategoriesEnabled.push('memory');
1350
+ botStatus.bootReport({
1351
+ startupType: startupCtx.type,
1352
+ shutdownReason: startupCtx.shutdown?.reason,
1353
+ shutdownMessage: startupCtx.shutdown?.message,
1354
+ shutdownRequestedBy: startupCtx.shutdown?.requestedBy,
1355
+ activeForge: startupCtx.shutdown?.activeForge,
1356
+ tasksEnabled: tasksEnabled,
1357
+ forumResolved: Boolean(taskCtx?.forumId),
1358
+ cronsEnabled: Boolean(cronEnabled && botParams.cronCtx),
1359
+ cronJobCount: cronScheduler?.listJobs().length,
1360
+ memoryEpisodicOn: summaryEnabled,
1361
+ memorySemanticOn: durableMemoryEnabled,
1362
+ memoryWorkingOn: shortTermMemoryEnabled,
1363
+ actionCategoriesEnabled,
1364
+ configWarnings: parsedConfig.warnings.length,
1365
+ permissionsStatus: permProbe.status === 'valid' ? 'ok' : permProbe.status,
1366
+ permissionsReason: permProbe.status === 'invalid' ? permProbe.reason : undefined,
1367
+ permissionsTier: permProbe.status === 'valid' ? permProbe.permissions.tier : undefined,
1368
+ credentialReport,
1369
+ credentialHealth: credentialCheckReport.results.map((r) => ({
1370
+ name: r.name,
1371
+ status: r.status === 'ok' ? 'pass' : r.status,
1372
+ detail: r.message,
1373
+ })),
1374
+ runtimeModel,
1375
+ bootDurationMs: Date.now() - bootStartMs,
1376
+ buildVersion: gitHash ?? undefined,
1377
+ }).catch((err) => log.warn({ err }, 'status-channel: boot report failed'));
1378
+ }