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,279 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { writeShutdownContext, readAndClearShutdownContext, formatStartupInjection, } from './shutdown-context.js';
6
+ let tmpDir;
7
+ beforeEach(async () => {
8
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'shutdown-ctx-test-'));
9
+ });
10
+ afterEach(async () => {
11
+ await fs.rm(tmpDir, { recursive: true, force: true });
12
+ });
13
+ describe('writeShutdownContext', () => {
14
+ it('writes a valid JSON file', async () => {
15
+ const ctx = {
16
+ reason: 'restart-command',
17
+ message: 'User requested via !restart',
18
+ timestamp: '2026-02-13T00:00:00.000Z',
19
+ requestedBy: '12345',
20
+ };
21
+ await writeShutdownContext(tmpDir, ctx);
22
+ const raw = await fs.readFile(path.join(tmpDir, 'shutdown-context.json'), 'utf-8');
23
+ const parsed = JSON.parse(raw);
24
+ expect(parsed.reason).toBe('restart-command');
25
+ expect(parsed.requestedBy).toBe('12345');
26
+ expect(parsed.message).toBe('User requested via !restart');
27
+ });
28
+ it('does not leave tmp files on success', async () => {
29
+ await writeShutdownContext(tmpDir, {
30
+ reason: 'unknown',
31
+ timestamp: new Date().toISOString(),
32
+ });
33
+ const files = await fs.readdir(tmpDir);
34
+ expect(files).toEqual(['shutdown-context.json']);
35
+ });
36
+ it('overwrites existing file by default', async () => {
37
+ await writeShutdownContext(tmpDir, {
38
+ reason: 'restart-command',
39
+ timestamp: '2026-02-13T00:00:00.000Z',
40
+ });
41
+ await writeShutdownContext(tmpDir, {
42
+ reason: 'unknown',
43
+ timestamp: '2026-02-13T00:01:00.000Z',
44
+ });
45
+ const raw = await fs.readFile(path.join(tmpDir, 'shutdown-context.json'), 'utf-8');
46
+ expect(JSON.parse(raw).reason).toBe('unknown');
47
+ });
48
+ it('skips write when skipIfExists is true and file exists', async () => {
49
+ await writeShutdownContext(tmpDir, {
50
+ reason: 'restart-command',
51
+ message: 'rich context',
52
+ timestamp: '2026-02-13T00:00:00.000Z',
53
+ });
54
+ await writeShutdownContext(tmpDir, { reason: 'unknown', timestamp: '2026-02-13T00:01:00.000Z' }, { skipIfExists: true });
55
+ const raw = await fs.readFile(path.join(tmpDir, 'shutdown-context.json'), 'utf-8');
56
+ expect(JSON.parse(raw).reason).toBe('restart-command');
57
+ expect(JSON.parse(raw).message).toBe('rich context');
58
+ });
59
+ it('writes when skipIfExists is true but no file exists', async () => {
60
+ await writeShutdownContext(tmpDir, { reason: 'unknown', timestamp: '2026-02-13T00:00:00.000Z' }, { skipIfExists: true });
61
+ const raw = await fs.readFile(path.join(tmpDir, 'shutdown-context.json'), 'utf-8');
62
+ expect(JSON.parse(raw).reason).toBe('unknown');
63
+ });
64
+ });
65
+ describe('readAndClearShutdownContext', () => {
66
+ it('returns crash when no file exists', async () => {
67
+ const result = await readAndClearShutdownContext(tmpDir);
68
+ expect(result.type).toBe('crash');
69
+ expect(result.shutdown).toBeUndefined();
70
+ });
71
+ it('returns first-boot when no file exists and firstBoot hint is set', async () => {
72
+ const result = await readAndClearShutdownContext(tmpDir, { firstBoot: true });
73
+ expect(result.type).toBe('first-boot');
74
+ expect(result.shutdown).toBeUndefined();
75
+ });
76
+ it('ignores firstBoot hint when file exists', async () => {
77
+ await writeShutdownContext(tmpDir, {
78
+ reason: 'restart-command',
79
+ timestamp: '2026-02-13T00:00:00.000Z',
80
+ });
81
+ const result = await readAndClearShutdownContext(tmpDir, { firstBoot: true });
82
+ expect(result.type).toBe('intentional');
83
+ });
84
+ it('returns graceful-unknown for reason: unknown', async () => {
85
+ await writeShutdownContext(tmpDir, {
86
+ reason: 'unknown',
87
+ timestamp: '2026-02-13T00:00:00.000Z',
88
+ });
89
+ const result = await readAndClearShutdownContext(tmpDir);
90
+ expect(result.type).toBe('graceful-unknown');
91
+ expect(result.shutdown?.reason).toBe('unknown');
92
+ });
93
+ it('returns intentional for reason: restart-command', async () => {
94
+ await writeShutdownContext(tmpDir, {
95
+ reason: 'restart-command',
96
+ message: 'User requested',
97
+ timestamp: '2026-02-13T00:00:00.000Z',
98
+ requestedBy: '99999',
99
+ });
100
+ const result = await readAndClearShutdownContext(tmpDir);
101
+ expect(result.type).toBe('intentional');
102
+ expect(result.shutdown?.reason).toBe('restart-command');
103
+ expect(result.shutdown?.requestedBy).toBe('99999');
104
+ });
105
+ it('returns intentional for reason: deploy', async () => {
106
+ await writeShutdownContext(tmpDir, {
107
+ reason: 'deploy',
108
+ timestamp: '2026-02-13T00:00:00.000Z',
109
+ });
110
+ const result = await readAndClearShutdownContext(tmpDir);
111
+ expect(result.type).toBe('intentional');
112
+ });
113
+ it('deletes the file after reading', async () => {
114
+ await writeShutdownContext(tmpDir, {
115
+ reason: 'restart-command',
116
+ timestamp: '2026-02-13T00:00:00.000Z',
117
+ });
118
+ await readAndClearShutdownContext(tmpDir);
119
+ const files = await fs.readdir(tmpDir);
120
+ expect(files).toEqual([]);
121
+ });
122
+ it('returns crash for corrupted JSON', async () => {
123
+ await fs.writeFile(path.join(tmpDir, 'shutdown-context.json'), 'not json!!!', 'utf-8');
124
+ const result = await readAndClearShutdownContext(tmpDir);
125
+ expect(result.type).toBe('crash');
126
+ });
127
+ it.each([
128
+ ['null', 'null'],
129
+ ['string', '"hello"'],
130
+ ['number', '42'],
131
+ ['array', '[1,2,3]'],
132
+ ])('returns crash for valid JSON that is %s', async (_label, json) => {
133
+ await fs.writeFile(path.join(tmpDir, 'shutdown-context.json'), json, 'utf-8');
134
+ const result = await readAndClearShutdownContext(tmpDir);
135
+ expect(result.type).toBe('crash');
136
+ });
137
+ it('preserves activeForge in shutdown context', async () => {
138
+ await writeShutdownContext(tmpDir, {
139
+ reason: 'restart-command',
140
+ timestamp: '2026-02-13T00:00:00.000Z',
141
+ activeForge: 'plan-037',
142
+ });
143
+ const result = await readAndClearShutdownContext(tmpDir);
144
+ expect(result.shutdown?.activeForge).toBe('plan-037');
145
+ });
146
+ it('classifies unrecognized reason as graceful-unknown', async () => {
147
+ await fs.writeFile(path.join(tmpDir, 'shutdown-context.json'), JSON.stringify({ reason: 'banana', timestamp: '2026-02-13T00:00:00.000Z' }));
148
+ const result = await readAndClearShutdownContext(tmpDir);
149
+ expect(result.type).toBe('graceful-unknown');
150
+ expect(result.shutdown?.reason).toBe('unknown');
151
+ });
152
+ it('classifies missing reason as graceful-unknown', async () => {
153
+ await fs.writeFile(path.join(tmpDir, 'shutdown-context.json'), JSON.stringify({ timestamp: '2026-02-13T00:00:00.000Z' }));
154
+ const result = await readAndClearShutdownContext(tmpDir);
155
+ expect(result.type).toBe('graceful-unknown');
156
+ });
157
+ it('truncates oversized message and activeForge fields', async () => {
158
+ const long = 'x'.repeat(1000);
159
+ await fs.writeFile(path.join(tmpDir, 'shutdown-context.json'), JSON.stringify({ reason: 'restart-command', timestamp: '', message: long, activeForge: long }));
160
+ const result = await readAndClearShutdownContext(tmpDir);
161
+ expect(result.shutdown?.message?.length).toBe(500);
162
+ expect(result.shutdown?.activeForge?.length).toBe(500);
163
+ });
164
+ });
165
+ describe('formatStartupInjection', () => {
166
+ it('formats intentional restart', () => {
167
+ const ctx = {
168
+ type: 'intentional',
169
+ shutdown: {
170
+ reason: 'restart-command',
171
+ message: 'User requested via !restart',
172
+ timestamp: '2026-02-13T00:00:00.000Z',
173
+ requestedBy: '12345',
174
+ },
175
+ };
176
+ const result = formatStartupInjection(ctx);
177
+ expect(result).toContain('restarted via !restart');
178
+ expect(result).toContain('<@12345>');
179
+ expect(result).toContain('User requested via !restart');
180
+ });
181
+ it('formats intentional restart without requestedBy', () => {
182
+ const ctx = {
183
+ type: 'intentional',
184
+ shutdown: {
185
+ reason: 'restart-command',
186
+ timestamp: '2026-02-13T00:00:00.000Z',
187
+ },
188
+ };
189
+ const result = formatStartupInjection(ctx);
190
+ expect(result).toContain('restarted via !restart');
191
+ expect(result).not.toContain('<@');
192
+ });
193
+ it('formats deploy reason correctly', () => {
194
+ const ctx = {
195
+ type: 'intentional',
196
+ shutdown: {
197
+ reason: 'deploy',
198
+ timestamp: '2026-02-13T00:00:00.000Z',
199
+ },
200
+ };
201
+ const result = formatStartupInjection(ctx);
202
+ expect(result).toContain('restarted for a deploy');
203
+ expect(result).not.toContain('!restart');
204
+ });
205
+ it('formats code-fix reason correctly', () => {
206
+ const ctx = {
207
+ type: 'intentional',
208
+ shutdown: {
209
+ reason: 'code-fix',
210
+ message: 'Applied hotfix for memory leak',
211
+ timestamp: '2026-02-13T00:00:00.000Z',
212
+ },
213
+ };
214
+ const result = formatStartupInjection(ctx);
215
+ expect(result).toContain('apply a code fix');
216
+ expect(result).toContain('Applied hotfix for memory leak');
217
+ expect(result).not.toContain('!restart');
218
+ });
219
+ it('returns null for first-boot', () => {
220
+ const ctx = { type: 'first-boot' };
221
+ expect(formatStartupInjection(ctx)).toBeNull();
222
+ });
223
+ it('formats graceful-unknown', () => {
224
+ const ctx = {
225
+ type: 'graceful-unknown',
226
+ shutdown: {
227
+ reason: 'unknown',
228
+ timestamp: '2026-02-13T00:00:00.000Z',
229
+ },
230
+ };
231
+ const result = formatStartupInjection(ctx);
232
+ expect(result).toContain('graceful shutdown');
233
+ expect(result).toContain('reason unknown');
234
+ });
235
+ it('formats crash', () => {
236
+ const ctx = { type: 'crash' };
237
+ const result = formatStartupInjection(ctx);
238
+ expect(result).toContain('crashed or been killed');
239
+ expect(result).toContain('journalctl');
240
+ });
241
+ it('appends active forge info when present', () => {
242
+ const ctx = {
243
+ type: 'intentional',
244
+ shutdown: {
245
+ reason: 'restart-command',
246
+ timestamp: '2026-02-13T00:00:00.000Z',
247
+ activeForge: 'plan-037',
248
+ },
249
+ };
250
+ const result = formatStartupInjection(ctx);
251
+ expect(result).toContain('plan-037');
252
+ expect(result).toContain('forge run was in progress');
253
+ });
254
+ it('appends active forge info for crash type', () => {
255
+ // Crash with forge info shouldn't happen (no file = no forge info),
256
+ // but the formatter handles it gracefully.
257
+ const ctx = {
258
+ type: 'crash',
259
+ shutdown: {
260
+ reason: 'unknown',
261
+ timestamp: '2026-02-13T00:00:00.000Z',
262
+ activeForge: 'plan-042',
263
+ },
264
+ };
265
+ const result = formatStartupInjection(ctx);
266
+ expect(result).toContain('plan-042');
267
+ });
268
+ it('includes resolved-task guard for all non-null results', () => {
269
+ const cases = [
270
+ { type: 'intentional', shutdown: { reason: 'restart-command', timestamp: '' } },
271
+ { type: 'graceful-unknown', shutdown: { reason: 'unknown', timestamp: '' } },
272
+ { type: 'crash' },
273
+ ];
274
+ for (const ctx of cases) {
275
+ const result = formatStartupInjection(ctx);
276
+ expect(result).toContain('already resolved');
277
+ }
278
+ });
279
+ });
@@ -0,0 +1,214 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+ import { ActivityType, GatewayIntentBits } from 'discord.js';
3
+ // We test the startup logic by calling startDiscordBot with mocked Client.
4
+ // The module under test is src/discord.ts — we import startDiscordBot.
5
+ // ---------------------------------------------------------------------------
6
+ // Mocks
7
+ // ---------------------------------------------------------------------------
8
+ vi.mock('node:fs/promises', () => ({
9
+ default: {
10
+ readFile: vi.fn(async () => Buffer.from('fake-avatar')),
11
+ mkdir: vi.fn(async () => { }),
12
+ stat: vi.fn(async () => ({})),
13
+ writeFile: vi.fn(async () => { }),
14
+ },
15
+ }));
16
+ let mockClientInstance;
17
+ vi.mock('discord.js', async (importOriginal) => {
18
+ const actual = await importOriginal();
19
+ return {
20
+ ...actual,
21
+ Client: vi.fn().mockImplementation(() => mockClientInstance),
22
+ };
23
+ });
24
+ // Suppress bootstrap side-effects.
25
+ vi.mock('../discord/system-bootstrap.js', () => ({
26
+ selectBootstrapGuild: () => null,
27
+ ensureSystemScaffold: async () => null,
28
+ }));
29
+ import { startDiscordBot } from '../discord.js';
30
+ import fs from 'node:fs/promises';
31
+ function makeMockLog() {
32
+ return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
33
+ }
34
+ function makeMockClient(overrides = {}) {
35
+ const setPresence = vi.fn();
36
+ const setAvatar = vi.fn();
37
+ const setStatus = vi.fn();
38
+ const setActivity = vi.fn();
39
+ const user = { setPresence, setAvatar, setStatus, setActivity, id: 'bot-1' };
40
+ const guilds = new Map();
41
+ const guildObj = {
42
+ id: 'guild-1',
43
+ members: {
44
+ me: {
45
+ nickname: null,
46
+ user: { username: 'TestBot' },
47
+ setNickname: vi.fn(async () => { }),
48
+ },
49
+ fetchMe: vi.fn(async () => guildObj.members.me),
50
+ },
51
+ };
52
+ guilds.set('guild-1', guildObj);
53
+ return {
54
+ user,
55
+ guilds: { cache: { values: () => guilds.values(), get: (id) => guilds.get(id) } },
56
+ on: vi.fn().mockReturnThis(),
57
+ once: vi.fn((event, cb) => { if (event === 'ready')
58
+ cb(); }),
59
+ login: vi.fn(async () => { }),
60
+ isReady: vi.fn(() => true),
61
+ channels: { cache: { get: vi.fn() } },
62
+ ...overrides,
63
+ };
64
+ }
65
+ function baseParams(overrides = {}) {
66
+ return {
67
+ token: 'test-token',
68
+ allowUserIds: new Set(['123']),
69
+ botDisplayName: 'TestBot',
70
+ log: makeMockLog(),
71
+ requireChannelContext: false,
72
+ autoIndexChannelContext: false,
73
+ autoJoinThreads: false,
74
+ useRuntimeSessions: true,
75
+ runtime: { invoke: vi.fn() },
76
+ sessionManager: { getOrCreate: vi.fn(async () => 'sess') },
77
+ workspaceCwd: '/tmp/workspace',
78
+ projectCwd: '/tmp',
79
+ groupsDir: '/tmp/groups',
80
+ useGroupDirCwd: false,
81
+ runtimeModel: 'opus',
82
+ runtimeTools: [],
83
+ runtimeTimeoutMs: 1000,
84
+ discordActionsEnabled: false,
85
+ discordActionsChannels: false,
86
+ discordActionsMessaging: false,
87
+ discordActionsGuild: false,
88
+ discordActionsModeration: false,
89
+ discordActionsPolls: false,
90
+ discordActionsTasks: false,
91
+ discordActionsBotProfile: false,
92
+ messageHistoryBudget: 0,
93
+ summaryEnabled: false,
94
+ summaryModel: 'haiku',
95
+ summaryMaxChars: 2000,
96
+ summaryEveryNTurns: 5,
97
+ summaryDataDir: '/tmp/summaries',
98
+ summaryToDurableEnabled: false,
99
+ shortTermMemoryEnabled: false,
100
+ shortTermDataDir: '/tmp/shortterm',
101
+ shortTermMaxEntries: 20,
102
+ shortTermMaxAgeMs: 21600000,
103
+ shortTermInjectMaxChars: 1000,
104
+ durableMemoryEnabled: false,
105
+ durableDataDir: '/tmp/durable',
106
+ durableInjectMaxChars: 2000,
107
+ durableMaxItems: 200,
108
+ memoryCommandsEnabled: false,
109
+ actionFollowupDepth: 0,
110
+ reactionHandlerEnabled: false,
111
+ reactionRemoveHandlerEnabled: false,
112
+ reactionMaxAgeMs: 86400000,
113
+ streamStallWarningMs: 0,
114
+ ...overrides,
115
+ };
116
+ }
117
+ beforeEach(() => {
118
+ vi.clearAllMocks();
119
+ mockClientInstance = makeMockClient();
120
+ });
121
+ // ---------------------------------------------------------------------------
122
+ // Presence tests
123
+ // ---------------------------------------------------------------------------
124
+ describe('startup presence', () => {
125
+ it('setPresence called with correct payload when botStatus + botActivity set', async () => {
126
+ await startDiscordBot(baseParams({
127
+ botStatus: 'dnd',
128
+ botActivity: 'with beads',
129
+ botActivityType: 'Playing',
130
+ }));
131
+ expect(mockClientInstance.user.setPresence).toHaveBeenCalledWith({
132
+ status: 'dnd',
133
+ activities: [{ name: 'with beads', type: ActivityType.Playing }],
134
+ });
135
+ });
136
+ it('Custom activity uses state field', async () => {
137
+ await startDiscordBot(baseParams({
138
+ botActivity: 'Thinking hard',
139
+ botActivityType: 'Custom',
140
+ }));
141
+ expect(mockClientInstance.user.setPresence).toHaveBeenCalledWith({
142
+ activities: [{ name: 'Custom Status', type: ActivityType.Custom, state: 'Thinking hard' }],
143
+ });
144
+ });
145
+ it('status-only sets presence without activities key', async () => {
146
+ await startDiscordBot(baseParams({ botStatus: 'dnd' }));
147
+ expect(mockClientInstance.user.setPresence).toHaveBeenCalledWith({ status: 'dnd' });
148
+ });
149
+ it('activity-only sets presence without status key', async () => {
150
+ await startDiscordBot(baseParams({ botActivity: 'with beads', botActivityType: 'Playing' }));
151
+ expect(mockClientInstance.user.setPresence).toHaveBeenCalledWith({
152
+ activities: [{ name: 'with beads', type: ActivityType.Playing }],
153
+ });
154
+ });
155
+ it('setPresence not called when neither botStatus nor botActivity set', async () => {
156
+ await startDiscordBot(baseParams());
157
+ expect(mockClientInstance.user.setPresence).not.toHaveBeenCalled();
158
+ });
159
+ it('presence failure logged as warning, startup continues', async () => {
160
+ mockClientInstance.user.setPresence.mockImplementationOnce(() => { throw new Error('presence fail'); });
161
+ const log = makeMockLog();
162
+ const result = await startDiscordBot(baseParams({ botStatus: 'idle', log: log }));
163
+ expect(result.client).toBeDefined();
164
+ expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(Error) }), 'discord:presence failed to set');
165
+ });
166
+ });
167
+ // ---------------------------------------------------------------------------
168
+ // Avatar tests
169
+ // ---------------------------------------------------------------------------
170
+ describe('startup avatar', () => {
171
+ it('setAvatar called with URL string for https sources', async () => {
172
+ await startDiscordBot(baseParams({ botAvatar: 'https://example.com/avatar.png' }));
173
+ expect(mockClientInstance.user.setAvatar).toHaveBeenCalledWith('https://example.com/avatar.png');
174
+ });
175
+ it('setAvatar called with URL string for http sources', async () => {
176
+ await startDiscordBot(baseParams({ botAvatar: 'http://example.com/avatar.png' }));
177
+ expect(mockClientInstance.user.setAvatar).toHaveBeenCalledWith('http://example.com/avatar.png');
178
+ });
179
+ it('setAvatar called with Buffer for file path sources', async () => {
180
+ await startDiscordBot(baseParams({ botAvatar: '/home/user/avatar.png' }));
181
+ expect(fs.readFile).toHaveBeenCalledWith('/home/user/avatar.png');
182
+ expect(mockClientInstance.user.setAvatar).toHaveBeenCalledWith(Buffer.from('fake-avatar'));
183
+ });
184
+ it('avatar failure logged as warning, startup continues', async () => {
185
+ mockClientInstance.user.setAvatar.mockRejectedValueOnce(new Error('rate limited'));
186
+ const log = makeMockLog();
187
+ const result = await startDiscordBot(baseParams({ botAvatar: 'https://example.com/avatar.png', log: log }));
188
+ expect(result.client).toBeDefined();
189
+ expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(Error), avatar: 'https://example.com/avatar.png' }), 'discord:avatar failed to set');
190
+ });
191
+ });
192
+ // ---------------------------------------------------------------------------
193
+ // Reaction remove handler wiring tests
194
+ // ---------------------------------------------------------------------------
195
+ describe('reaction remove handler wiring', () => {
196
+ it('registers messageReactionRemove listener when reactionRemoveHandlerEnabled is true', async () => {
197
+ await startDiscordBot(baseParams({ reactionRemoveHandlerEnabled: true }));
198
+ const onCalls = mockClientInstance.on.mock.calls;
199
+ const events = onCalls.map((c) => c[0]);
200
+ expect(events).toContain('messageReactionRemove');
201
+ });
202
+ it('does NOT register messageReactionRemove listener when reactionRemoveHandlerEnabled is false', async () => {
203
+ await startDiscordBot(baseParams({ reactionRemoveHandlerEnabled: false }));
204
+ const onCalls = mockClientInstance.on.mock.calls;
205
+ const events = onCalls.map((c) => c[0]);
206
+ expect(events).not.toContain('messageReactionRemove');
207
+ });
208
+ it('enables GuildMessageReactions intent when only remove handler is enabled (add disabled)', async () => {
209
+ const { Client } = await import('discord.js');
210
+ await startDiscordBot(baseParams({ reactionHandlerEnabled: false, reactionRemoveHandlerEnabled: true }));
211
+ const constructorCall = Client.mock.calls.at(-1)[0];
212
+ expect(constructorCall.intents).toContain(GatewayIntentBits.GuildMessageReactions);
213
+ });
214
+ });
@@ -0,0 +1,190 @@
1
+ import { NO_MENTIONS } from './allowed-mentions.js';
2
+ /**
3
+ * Strip CLI args and prompt content from error messages so internals
4
+ * (SOUL.md, IDENTITY.md, full prompts) don't leak into status channel messages.
5
+ */
6
+ export function sanitizeErrorMessage(raw) {
7
+ if (!raw)
8
+ return '(no message)';
9
+ // execa kill messages look like:
10
+ // "Command was killed with SIGKILL (Forced termination): claude -p \"You are...\""
11
+ // "Command was killed with SIGKILL (Forced termination): /usr/local/bin/claude ... -- \"You are...\""
12
+ // Match ": " followed by a path or bare name containing "claude" (the binary invocation).
13
+ const cliBinMatch = raw.match(/:\s+(?:\/\S*\/)?claude\s/);
14
+ if (cliBinMatch) {
15
+ return raw.slice(0, cliBinMatch.index).slice(0, 500);
16
+ }
17
+ // Positional prompt separator: " -- " followed by a quoted string (prompt content).
18
+ // execa formats args with single quotes in shortMessage, so check both quote styles.
19
+ const positionalMatch = raw.match(/ -- ["']/);
20
+ if (positionalMatch) {
21
+ return raw.slice(0, positionalMatch.index).slice(0, 500);
22
+ }
23
+ // Generic: if the message contains "claude -p" anywhere, truncate before it
24
+ const dashPIdx = raw.indexOf('claude -p');
25
+ if (dashPIdx !== -1) {
26
+ const prefix = raw.slice(0, dashPIdx).trimEnd();
27
+ return (prefix || 'Command failed').slice(0, 500);
28
+ }
29
+ // Safety-net truncation for any other long message
30
+ return raw.slice(0, 500);
31
+ }
32
+ /**
33
+ * Format a phase failure error for human-readable Discord display.
34
+ * Detects timeout patterns and emits "Phase **X** timed out after Y minutes".
35
+ * Non-timeout errors emit "Phase **X** failed: <sanitized error>".
36
+ * Output is always truncated to 500 chars.
37
+ */
38
+ export function sanitizePhaseError(phaseId, raw, timeoutMs) {
39
+ // Detect execa/AbortSignal timeout patterns: "timed out after Nms"
40
+ const timeoutMatch = raw.match(/timed out after (\d+)ms/i);
41
+ if (timeoutMatch) {
42
+ const ms = timeoutMs ?? parseInt(timeoutMatch[1], 10);
43
+ const minutes = Math.round(ms / 60000);
44
+ const humanTime = ms >= 60000 ? `${minutes} minute${minutes !== 1 ? 's' : ''}` : `${Math.round(ms / 1000)} seconds`;
45
+ return `Phase **${phaseId}** timed out after ${humanTime}`.slice(0, 500);
46
+ }
47
+ return `Phase **${phaseId}** failed: ${sanitizeErrorMessage(raw)}`.slice(0, 500);
48
+ }
49
+ export function createStatusPoster(channel, opts) {
50
+ const name = opts?.botDisplayName ?? 'Discoclaw';
51
+ const log = opts?.log;
52
+ const send = async (content) => {
53
+ try {
54
+ await channel.send({ content, allowedMentions: NO_MENTIONS });
55
+ }
56
+ catch (err) {
57
+ log?.warn({ err }, 'status-channel: failed to post status message');
58
+ }
59
+ };
60
+ const sendTaskSyncComplete = async (result) => {
61
+ const { threadsCreated, emojisUpdated, starterMessagesUpdated, threadsArchived, statusesUpdated, tagsUpdated, threadsReconciled, orphanThreadsFound, warnings } = result;
62
+ const allZero = threadsCreated === 0 && emojisUpdated === 0 && starterMessagesUpdated === 0 && threadsArchived === 0 && statusesUpdated === 0 && tagsUpdated === 0 && (threadsReconciled ?? 0) === 0 && (orphanThreadsFound ?? 0) === 0;
63
+ if (allZero && warnings === 0)
64
+ return;
65
+ const parts = ['**Task Sync Complete**'];
66
+ if (threadsCreated > 0)
67
+ parts.push(`Created: ${threadsCreated}`);
68
+ if (emojisUpdated > 0)
69
+ parts.push(`Names Updated: ${emojisUpdated}`);
70
+ if (starterMessagesUpdated > 0)
71
+ parts.push(`Starters Updated: ${starterMessagesUpdated}`);
72
+ if (threadsArchived > 0)
73
+ parts.push(`Archived: ${threadsArchived}`);
74
+ if (statusesUpdated > 0)
75
+ parts.push(`Statuses Fixed: ${statusesUpdated}`);
76
+ if (tagsUpdated > 0)
77
+ parts.push(`Tags Updated: ${tagsUpdated}`);
78
+ if (threadsReconciled && threadsReconciled > 0)
79
+ parts.push(`Reconciled: ${threadsReconciled}`);
80
+ if (orphanThreadsFound && orphanThreadsFound > 0)
81
+ parts.push(`Orphans Found: ${orphanThreadsFound}`);
82
+ if (warnings > 0)
83
+ parts.push(`Warnings: ${warnings}`);
84
+ await send(parts.join(' · '));
85
+ };
86
+ return {
87
+ async online() {
88
+ await send(`**Bot Online** — ${name} is connected and ready.`);
89
+ },
90
+ async offline() {
91
+ await send(`**Bot Offline** — ${name} is shutting down.`);
92
+ },
93
+ async runtimeError(context, message) {
94
+ const ctx = [context.sessionKey, context.channelName].filter(Boolean).join(' · ');
95
+ await send(`**Runtime Error** [${ctx}]\n${sanitizeErrorMessage(message).slice(0, 500)}`);
96
+ },
97
+ async handlerError(context, err) {
98
+ await send(`**Handler Failure** [${context.sessionKey}]\n${sanitizeErrorMessage(String(err) || '(unknown error)').slice(0, 500)}`);
99
+ },
100
+ async actionFailed(actionType, error) {
101
+ await send(`**Action Failed** [${actionType || '(unknown)'}]\n${(error || '(unknown)').slice(0, 500)}`);
102
+ },
103
+ async taskSyncComplete(result) {
104
+ await sendTaskSyncComplete(result);
105
+ },
106
+ async bootReport(data) {
107
+ const typeLabel = {
108
+ crash: 'Crash',
109
+ intentional: 'Intentional',
110
+ 'graceful-unknown': 'Graceful (unknown)',
111
+ 'first-boot': 'First Boot',
112
+ };
113
+ const lines = ['**Boot Report**'];
114
+ lines.push(`Startup · ${typeLabel[data.startupType]}`);
115
+ if (data.bootDurationMs !== undefined)
116
+ lines.push(`Boot Time · ${data.bootDurationMs}ms`);
117
+ lines.push(`Model · ${data.runtimeModel || '(default)'}`);
118
+ if (data.permissionsStatus) {
119
+ const permLabel = data.permissionsStatus === 'ok'
120
+ ? `ok (${data.permissionsTier ?? '?'})`
121
+ : data.permissionsStatus === 'invalid'
122
+ ? `INVALID${data.permissionsReason ? ` (${data.permissionsReason})` : ''}`
123
+ : 'missing';
124
+ lines.push(`Permissions · ${permLabel}`);
125
+ }
126
+ else {
127
+ lines.push(`Permissions · ${data.permissionsTier || '(unset)'}`);
128
+ }
129
+ if (data.shutdownReason) {
130
+ const reasonParts = [data.shutdownReason];
131
+ if (data.shutdownRequestedBy)
132
+ reasonParts.push(`by <@${data.shutdownRequestedBy}>`);
133
+ if (data.shutdownMessage)
134
+ reasonParts.push(`— ${data.shutdownMessage}`);
135
+ lines.push(`Last Shutdown · ${reasonParts.join(' ')}`);
136
+ }
137
+ if (data.activeForge) {
138
+ lines.push(`Forge at Shutdown · ${data.activeForge.slice(0, 200)}`);
139
+ }
140
+ const tasksEnabled = data.tasksEnabled;
141
+ const tasksDbVersion = data.tasksDbVersion;
142
+ const tasksStatus = tasksEnabled
143
+ ? `on${tasksDbVersion ? ` · v${tasksDbVersion}` : ''}${data.forumResolved ? ' · forum ok' : ' · forum unresolved'}`
144
+ : 'off';
145
+ lines.push(`Tasks · ${tasksStatus}`);
146
+ const cronsStatus = data.cronsEnabled
147
+ ? `on${data.cronJobCount !== undefined ? ` · ${data.cronJobCount} job${data.cronJobCount !== 1 ? 's' : ''}` : ''}`
148
+ : 'off';
149
+ lines.push(`Crons · ${cronsStatus}`);
150
+ const memoryParts = [];
151
+ if (data.memoryEpisodicOn)
152
+ memoryParts.push('episodic');
153
+ if (data.memorySemanticOn)
154
+ memoryParts.push('semantic');
155
+ if (data.memoryWorkingOn)
156
+ memoryParts.push('working');
157
+ lines.push(`Memory · ${memoryParts.length > 0 ? memoryParts.join(', ') : 'off'}`);
158
+ lines.push(`Actions · ${data.actionCategoriesEnabled.length > 0 ? data.actionCategoriesEnabled.join(', ') : '(none)'}`);
159
+ lines.push(`Version · DiscoClaw ${data.buildVersion ?? '(unknown)'}`);
160
+ if (data.configWarnings && data.configWarnings > 0) {
161
+ lines.push(`Config Warnings · ${data.configWarnings}`);
162
+ }
163
+ if (data.credentialReport) {
164
+ lines.push(`Credentials · ${data.credentialReport}`);
165
+ }
166
+ if (data.credentialHealth && data.credentialHealth.length > 0) {
167
+ const passCount = data.credentialHealth.filter(c => c.status === 'pass').length;
168
+ const failCount = data.credentialHealth.filter(c => c.status === 'fail').length;
169
+ const skipCount = data.credentialHealth.filter(c => c.status === 'skip').length;
170
+ if (passCount === data.credentialHealth.length) {
171
+ lines.push('Health · all pass');
172
+ }
173
+ else {
174
+ const summary = [];
175
+ if (passCount > 0)
176
+ summary.push(`${passCount} pass`);
177
+ if (failCount > 0)
178
+ summary.push(`${failCount} fail`);
179
+ if (skipCount > 0)
180
+ summary.push(`${skipCount} skip`);
181
+ lines.push(`Health · ${summary.join(', ')}`);
182
+ for (const c of data.credentialHealth.filter(c => c.status === 'fail')) {
183
+ lines.push(` ${c.name}: ${c.detail ?? 'failed'}`);
184
+ }
185
+ }
186
+ }
187
+ await send(lines.join('\n'));
188
+ },
189
+ };
190
+ }