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,282 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createStatusPoster, sanitizeErrorMessage, sanitizePhaseError } from './status-channel.js';
3
+ function mockChannel() {
4
+ return { send: vi.fn().mockResolvedValue(undefined) };
5
+ }
6
+ function mockLog() {
7
+ return { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
8
+ }
9
+ function sentContent(ch, callIndex = 0) {
10
+ const arg = ch.send.mock.calls[callIndex][0];
11
+ return arg.content;
12
+ }
13
+ describe('createStatusPoster', () => {
14
+ it('online() sends a plain text Bot Online message', async () => {
15
+ const ch = mockChannel();
16
+ const poster = createStatusPoster(ch);
17
+ await poster.online();
18
+ expect(ch.send).toHaveBeenCalledOnce();
19
+ const msg = sentContent(ch);
20
+ expect(msg).toContain('**Bot Online**');
21
+ expect(msg).toContain('connected and ready');
22
+ });
23
+ it('online() suppresses mentions via NO_MENTIONS', async () => {
24
+ const ch = mockChannel();
25
+ await createStatusPoster(ch).online();
26
+ const arg = ch.send.mock.calls[0][0];
27
+ expect(arg.allowedMentions).toEqual({ parse: [] });
28
+ });
29
+ it('offline() sends a plain text Bot Offline message', async () => {
30
+ const ch = mockChannel();
31
+ const poster = createStatusPoster(ch);
32
+ await poster.offline();
33
+ expect(ch.send).toHaveBeenCalledOnce();
34
+ const msg = sentContent(ch);
35
+ expect(msg).toContain('**Bot Offline**');
36
+ expect(msg).toContain('shutting down');
37
+ });
38
+ it('runtimeError() sends plain text with session and channel context', async () => {
39
+ const ch = mockChannel();
40
+ const poster = createStatusPoster(ch);
41
+ await poster.runtimeError({ sessionKey: 'dm:123', channelName: 'general' }, 'timeout');
42
+ expect(ch.send).toHaveBeenCalledOnce();
43
+ const msg = sentContent(ch);
44
+ expect(msg).toContain('**Runtime Error**');
45
+ expect(msg).toContain('dm:123');
46
+ expect(msg).toContain('general');
47
+ expect(msg).toContain('timeout');
48
+ });
49
+ it('handlerError() sends plain text with error content', async () => {
50
+ const ch = mockChannel();
51
+ const poster = createStatusPoster(ch);
52
+ await poster.handlerError({ sessionKey: 'g:1:c:2' }, new Error('boom'));
53
+ expect(ch.send).toHaveBeenCalledOnce();
54
+ const msg = sentContent(ch);
55
+ expect(msg).toContain('**Handler Failure**');
56
+ expect(msg).toContain('boom');
57
+ });
58
+ it('handlerError() sanitizes messages containing prompt content', async () => {
59
+ const ch = mockChannel();
60
+ const poster = createStatusPoster(ch);
61
+ const leakyErr = new Error('Command was killed with SIGKILL (Forced termination): claude -p "You are a helpful assistant..."');
62
+ await poster.handlerError({ sessionKey: 'g:1:c:2' }, leakyErr);
63
+ const msg = sentContent(ch);
64
+ expect(msg).not.toContain('claude -p');
65
+ expect(msg).toContain('SIGKILL');
66
+ });
67
+ it('actionFailed() sends plain text with action type and error', async () => {
68
+ const ch = mockChannel();
69
+ const poster = createStatusPoster(ch);
70
+ await poster.actionFailed('channelCreate', 'Missing perms');
71
+ expect(ch.send).toHaveBeenCalledOnce();
72
+ const msg = sentContent(ch);
73
+ expect(msg).toContain('**Action Failed**');
74
+ expect(msg).toContain('channelCreate');
75
+ expect(msg).toContain('Missing perms');
76
+ });
77
+ it('taskSyncComplete() sends plain text with non-zero fields only', async () => {
78
+ const ch = mockChannel();
79
+ const poster = createStatusPoster(ch);
80
+ await poster.taskSyncComplete({
81
+ threadsCreated: 1, emojisUpdated: 0, starterMessagesUpdated: 2, threadsArchived: 3, statusesUpdated: 0, tagsUpdated: 0, warnings: 0,
82
+ });
83
+ expect(ch.send).toHaveBeenCalledOnce();
84
+ const msg = sentContent(ch);
85
+ expect(msg).toContain('**Task Sync Complete**');
86
+ expect(msg).toContain('Created: 1');
87
+ expect(msg).toContain('Starters Updated: 2');
88
+ expect(msg).toContain('Archived: 3');
89
+ expect(msg).not.toContain('Names Updated');
90
+ expect(msg).not.toContain('Statuses Fixed');
91
+ expect(msg).not.toContain('Warnings');
92
+ });
93
+ it('taskSyncComplete() includes warnings when > 0', async () => {
94
+ const ch = mockChannel();
95
+ const poster = createStatusPoster(ch);
96
+ await poster.taskSyncComplete({
97
+ threadsCreated: 0, emojisUpdated: 0, starterMessagesUpdated: 0, threadsArchived: 0, statusesUpdated: 0, tagsUpdated: 0, warnings: 2,
98
+ });
99
+ expect(ch.send).toHaveBeenCalledOnce();
100
+ const msg = sentContent(ch);
101
+ expect(msg).toContain('**Task Sync Complete**');
102
+ expect(msg).toContain('Warnings: 2');
103
+ });
104
+ it('taskSyncComplete() includes all non-zero counters and warnings together', async () => {
105
+ const ch = mockChannel();
106
+ const poster = createStatusPoster(ch);
107
+ await poster.taskSyncComplete({
108
+ threadsCreated: 2, emojisUpdated: 0, starterMessagesUpdated: 0, threadsArchived: 1, statusesUpdated: 0, tagsUpdated: 0, warnings: 1,
109
+ });
110
+ expect(ch.send).toHaveBeenCalledOnce();
111
+ const msg = sentContent(ch);
112
+ expect(msg).toContain('Created: 2');
113
+ expect(msg).toContain('Archived: 1');
114
+ expect(msg).toContain('Warnings: 1');
115
+ });
116
+ it('taskSyncComplete() is silent when all counters and warnings are zero', async () => {
117
+ const ch = mockChannel();
118
+ const poster = createStatusPoster(ch);
119
+ await poster.taskSyncComplete({
120
+ threadsCreated: 0, emojisUpdated: 0, starterMessagesUpdated: 0, threadsArchived: 0, statusesUpdated: 0, tagsUpdated: 0, warnings: 0,
121
+ });
122
+ expect(ch.send).not.toHaveBeenCalled();
123
+ });
124
+ it('runtimeError() sanitizes messages containing prompt content', async () => {
125
+ const ch = mockChannel();
126
+ const poster = createStatusPoster(ch);
127
+ const leakyMsg = 'Command was killed with SIGKILL (Forced termination): claude -p "You are a helpful assistant called Weston..."';
128
+ await poster.runtimeError({ sessionKey: 'dm:123' }, leakyMsg);
129
+ const msg = sentContent(ch);
130
+ expect(msg).toContain('Command was killed with SIGKILL (Forced termination)');
131
+ expect(msg).not.toContain('claude -p');
132
+ });
133
+ it('does not throw when channel.send fails', async () => {
134
+ const ch = { send: vi.fn().mockRejectedValue(new Error('network')) };
135
+ const log = mockLog();
136
+ const poster = createStatusPoster(ch, { log });
137
+ await expect(poster.online()).resolves.toBeUndefined();
138
+ expect(log.warn).toHaveBeenCalledOnce();
139
+ });
140
+ });
141
+ describe('bootReport', () => {
142
+ const baseData = {
143
+ startupType: 'first-boot',
144
+ tasksEnabled: false,
145
+ forumResolved: false,
146
+ cronsEnabled: false,
147
+ memoryEpisodicOn: false,
148
+ memorySemanticOn: false,
149
+ memoryWorkingOn: false,
150
+ actionCategoriesEnabled: [],
151
+ };
152
+ it('includes Credentials line when credentialReport is provided', async () => {
153
+ const ch = mockChannel();
154
+ const poster = createStatusPoster(ch);
155
+ await poster.bootReport({ ...baseData, credentialReport: 'discord-token: ok, openai-key: skip' });
156
+ const msg = sentContent(ch);
157
+ expect(msg).toContain('Credentials · discord-token: ok, openai-key: skip');
158
+ });
159
+ it('omits Credentials line when credentialReport is absent', async () => {
160
+ const ch = mockChannel();
161
+ const poster = createStatusPoster(ch);
162
+ await poster.bootReport({ ...baseData });
163
+ const msg = sentContent(ch);
164
+ expect(msg).not.toContain('Credentials');
165
+ });
166
+ it('includes FAIL in the Credentials line when a check failed', async () => {
167
+ const ch = mockChannel();
168
+ const poster = createStatusPoster(ch);
169
+ await poster.bootReport({
170
+ ...baseData,
171
+ credentialReport: 'discord-token: FAIL (invalid or revoked token (401)), openai-key: skip',
172
+ });
173
+ const msg = sentContent(ch);
174
+ expect(msg).toContain('Credentials · discord-token: FAIL (invalid or revoked token (401)), openai-key: skip');
175
+ });
176
+ it('formats Permissions as "ok (tier)" when permissionsStatus is ok', async () => {
177
+ const ch = mockChannel();
178
+ const poster = createStatusPoster(ch);
179
+ await poster.bootReport({ ...baseData, permissionsStatus: 'ok', permissionsTier: 'full' });
180
+ const msg = sentContent(ch);
181
+ expect(msg).toContain('Permissions · ok (full)');
182
+ });
183
+ it('formats Permissions as "missing" when permissionsStatus is missing', async () => {
184
+ const ch = mockChannel();
185
+ const poster = createStatusPoster(ch);
186
+ await poster.bootReport({ ...baseData, permissionsStatus: 'missing' });
187
+ const msg = sentContent(ch);
188
+ expect(msg).toContain('Permissions · missing');
189
+ });
190
+ it('formats Permissions as "INVALID (reason)" when permissionsStatus is invalid with reason', async () => {
191
+ const ch = mockChannel();
192
+ const poster = createStatusPoster(ch);
193
+ await poster.bootReport({ ...baseData, permissionsStatus: 'invalid', permissionsReason: 'invalid tier: "godmode"' });
194
+ const msg = sentContent(ch);
195
+ expect(msg).toContain('Permissions · INVALID (invalid tier: "godmode")');
196
+ });
197
+ it('formats Permissions as "INVALID" without parentheses when no reason is provided', async () => {
198
+ const ch = mockChannel();
199
+ const poster = createStatusPoster(ch);
200
+ await poster.bootReport({ ...baseData, permissionsStatus: 'invalid' });
201
+ const msg = sentContent(ch);
202
+ expect(msg).toContain('Permissions · INVALID');
203
+ expect(msg).not.toContain('(undefined');
204
+ });
205
+ it('falls back to tier label when permissionsStatus is absent', async () => {
206
+ const ch = mockChannel();
207
+ const poster = createStatusPoster(ch);
208
+ await poster.bootReport({ ...baseData, permissionsTier: 'standard' });
209
+ const msg = sentContent(ch);
210
+ expect(msg).toContain('Permissions · standard');
211
+ });
212
+ });
213
+ describe('sanitizeErrorMessage', () => {
214
+ it('passes through short clean messages unchanged', () => {
215
+ expect(sanitizeErrorMessage('timeout')).toBe('timeout');
216
+ });
217
+ it('returns "(no message)" for empty/falsy input', () => {
218
+ expect(sanitizeErrorMessage('')).toBe('(no message)');
219
+ });
220
+ it('strips prompt after "Command was killed with SIGKILL": claude -p ...', () => {
221
+ const msg = 'Command was killed with SIGKILL (Forced termination): claude -p "You are a helpful..."';
222
+ expect(sanitizeErrorMessage(msg)).toBe('Command was killed with SIGKILL (Forced termination)');
223
+ });
224
+ it('strips prompt after "Command failed with exit code 1": claude -p ...', () => {
225
+ const msg = 'Command failed with exit code 1: claude -p "big prompt here..."';
226
+ expect(sanitizeErrorMessage(msg)).toBe('Command failed with exit code 1');
227
+ });
228
+ it('strips content when "claude -p" appears mid-message without colon-space separator', () => {
229
+ const msg = 'Something went wrong while running claude -p "giant prompt"';
230
+ expect(sanitizeErrorMessage(msg)).toBe('Something went wrong while running');
231
+ });
232
+ it('strips prompt with absolute binary path and positional arg (double quotes)', () => {
233
+ const msg = 'Command was killed with SIGKILL (Forced termination): /usr/local/bin/claude --tools bash -- "You are a helpful assistant..."';
234
+ expect(sanitizeErrorMessage(msg)).toBe('Command was killed with SIGKILL (Forced termination)');
235
+ });
236
+ it('strips prompt with absolute binary path and positional arg (single quotes)', () => {
237
+ // execa formats args with single quotes in shortMessage
238
+ const msg = "Command was killed with SIGKILL (Forced termination): /usr/local/bin/claude --tools bash -- 'You are a helpful assistant...'";
239
+ expect(sanitizeErrorMessage(msg)).toBe('Command was killed with SIGKILL (Forced termination)');
240
+ });
241
+ it('strips single-quoted positional prompt when binary name is not "claude"', () => {
242
+ const msg = "Command was killed with SIGKILL (Forced termination): /opt/mybin --tools bash -- 'You are a helpful assistant...'";
243
+ expect(sanitizeErrorMessage(msg)).not.toContain('You are a helpful');
244
+ });
245
+ it('truncates long messages to 500 chars', () => {
246
+ const long = 'x'.repeat(1000);
247
+ expect(sanitizeErrorMessage(long).length).toBe(500);
248
+ });
249
+ });
250
+ describe('sanitizePhaseError', () => {
251
+ it('formats timeout using ms from error string when no timeoutMs provided', () => {
252
+ const raw = 'Process timed out after 120000ms';
253
+ expect(sanitizePhaseError('3', raw)).toBe('Phase **3** timed out after 2 minutes');
254
+ });
255
+ it('uses provided timeoutMs over value in error string', () => {
256
+ const raw = 'Process timed out after 60000ms';
257
+ expect(sanitizePhaseError('2', raw, 300000)).toBe('Phase **2** timed out after 5 minutes');
258
+ });
259
+ it('uses singular "minute" when timeout is exactly 1 minute', () => {
260
+ const raw = 'timed out after 60000ms';
261
+ expect(sanitizePhaseError('1', raw)).toBe('Phase **1** timed out after 1 minute');
262
+ });
263
+ it('falls back to seconds when timeout is under 1 minute', () => {
264
+ const raw = 'timed out after 30000ms';
265
+ expect(sanitizePhaseError('1', raw)).toBe('Phase **1** timed out after 30 seconds');
266
+ });
267
+ it('wraps non-timeout errors with "Phase X failed:" prefix', () => {
268
+ const raw = 'Command was killed with SIGKILL (Forced termination): claude -p "You are..."';
269
+ const result = sanitizePhaseError('4', raw);
270
+ expect(result).not.toContain('claude -p');
271
+ expect(result).toContain('SIGKILL');
272
+ expect(result).toMatch(/^Phase \*\*4\*\* failed:/);
273
+ });
274
+ it('truncates output to 500 chars', () => {
275
+ const raw = 'x'.repeat(1000);
276
+ expect(sanitizePhaseError('1', raw).length).toBe(500);
277
+ });
278
+ it('handles case-insensitive timeout pattern', () => {
279
+ const raw = 'Timed Out After 90000ms';
280
+ expect(sanitizePhaseError('5', raw)).toBe('Phase **5** timed out after 2 minutes');
281
+ });
282
+ });
@@ -0,0 +1,206 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { checkDiscordToken, checkOpenAiKey, checkOpenRouterKey } from '../health/credential-check.js';
4
+ const DEFAULT_API_CHECK_TIMEOUT_MS = 5000;
5
+ // ---------------------------------------------------------------------------
6
+ // Parser
7
+ // ---------------------------------------------------------------------------
8
+ export function parseStatusCommand(content) {
9
+ const normalized = String(content ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
10
+ if (normalized === '!status')
11
+ return true;
12
+ return null;
13
+ }
14
+ /**
15
+ * Race an API credential check against a timeout sentinel.
16
+ * Returns a 'fail' result if the check doesn't resolve within `timeoutMs`.
17
+ */
18
+ function withApiTimeout(promise, timeoutMs, name) {
19
+ const timeout = new Promise((resolve) => {
20
+ const t = setTimeout(() => resolve({ name, status: 'fail', message: `check timed out after ${timeoutMs}ms` }), timeoutMs);
21
+ // Don't block the Node.js event loop exit while this timer is pending.
22
+ if (typeof t === 'object' && t !== null && typeof t.unref === 'function') {
23
+ t.unref();
24
+ }
25
+ });
26
+ return Promise.race([promise, timeout]);
27
+ }
28
+ async function countDurableItems(dir) {
29
+ let total = 0;
30
+ try {
31
+ const files = await fs.readdir(dir);
32
+ for (const file of files) {
33
+ if (!file.endsWith('.json'))
34
+ continue;
35
+ try {
36
+ const raw = await fs.readFile(path.join(dir, file), 'utf8');
37
+ const parsed = JSON.parse(raw);
38
+ if (parsed &&
39
+ typeof parsed === 'object' &&
40
+ 'items' in parsed &&
41
+ Array.isArray(parsed.items)) {
42
+ total += parsed.items.filter((i) => i?.status === 'active').length;
43
+ }
44
+ }
45
+ catch {
46
+ // skip malformed files
47
+ }
48
+ }
49
+ }
50
+ catch {
51
+ // dir missing or unreadable
52
+ }
53
+ return total;
54
+ }
55
+ async function countRollingSummaryChars(dir) {
56
+ let total = 0;
57
+ try {
58
+ const files = await fs.readdir(dir);
59
+ for (const file of files) {
60
+ if (!file.endsWith('.json'))
61
+ continue;
62
+ try {
63
+ const raw = await fs.readFile(path.join(dir, file), 'utf8');
64
+ const parsed = JSON.parse(raw);
65
+ if (parsed &&
66
+ typeof parsed === 'object' &&
67
+ 'summary' in parsed &&
68
+ typeof parsed.summary === 'string') {
69
+ total += parsed.summary.length;
70
+ }
71
+ }
72
+ catch {
73
+ // skip malformed files
74
+ }
75
+ }
76
+ }
77
+ catch {
78
+ // dir missing or unreadable
79
+ }
80
+ return total;
81
+ }
82
+ async function checkPaFiles(filePaths) {
83
+ return Promise.all(filePaths.map(async ({ label, path: filePath }) => {
84
+ try {
85
+ await fs.stat(filePath);
86
+ return { label, exists: true };
87
+ }
88
+ catch {
89
+ return { label, exists: false };
90
+ }
91
+ }));
92
+ }
93
+ export async function collectStatusSnapshot(opts) {
94
+ const now = Date.now();
95
+ const apiCheckTimeoutMs = opts.apiCheckTimeoutMs ?? DEFAULT_API_CHECK_TIMEOUT_MS;
96
+ const [durableItemCount, rollingSummaryCharCount, apiChecks, paFiles] = await Promise.all([
97
+ opts.durableDataDir ? countDurableItems(opts.durableDataDir) : Promise.resolve(0),
98
+ opts.summaryDataDir ? countRollingSummaryChars(opts.summaryDataDir) : Promise.resolve(0),
99
+ (async () => {
100
+ const checks = [
101
+ withApiTimeout(checkDiscordToken(opts.discordToken), apiCheckTimeoutMs, 'discord-token'),
102
+ ];
103
+ const runOpenAi = opts.activeProviders === undefined || opts.activeProviders.has('openai');
104
+ if (runOpenAi) {
105
+ checks.push(withApiTimeout(checkOpenAiKey({ apiKey: opts.openaiApiKey, baseUrl: opts.openaiBaseUrl }), apiCheckTimeoutMs, 'openai-key'));
106
+ }
107
+ const runOpenRouter = opts.activeProviders !== undefined && opts.activeProviders.has('openrouter');
108
+ if (runOpenRouter) {
109
+ checks.push(withApiTimeout(checkOpenRouterKey({ apiKey: opts.openrouterApiKey, baseUrl: opts.openrouterBaseUrl }), apiCheckTimeoutMs, 'openrouter-key'));
110
+ }
111
+ return Promise.all(checks);
112
+ })(),
113
+ checkPaFiles(opts.paFilePaths),
114
+ ]);
115
+ return {
116
+ uptimeMs: now - opts.startedAt,
117
+ lastMessageAt: opts.lastMessageAt,
118
+ crons: opts.scheduler?.listJobs() ?? [],
119
+ openTaskCount: opts.taskStore?.list().length ?? 0,
120
+ durableItemCount,
121
+ rollingSummaryCharCount,
122
+ apiChecks,
123
+ paFiles,
124
+ };
125
+ }
126
+ // ---------------------------------------------------------------------------
127
+ // Renderer
128
+ // ---------------------------------------------------------------------------
129
+ function formatUptime(ms) {
130
+ const seconds = Math.max(0, Math.floor(ms / 1000));
131
+ const h = Math.floor(seconds / 3600);
132
+ const m = Math.floor((seconds % 3600) / 60);
133
+ const s = seconds % 60;
134
+ return `${h}h ${m}m ${s}s`;
135
+ }
136
+ function formatTimeAgo(ms) {
137
+ const seconds = Math.floor(ms / 1000);
138
+ if (seconds < 60)
139
+ return `${seconds}s`;
140
+ const minutes = Math.floor(seconds / 60);
141
+ if (minutes < 60)
142
+ return `${minutes}m`;
143
+ const hours = Math.floor(minutes / 60);
144
+ return `${hours}h ${minutes % 60}m`;
145
+ }
146
+ function formatNextRun(date) {
147
+ const diff = date.getTime() - Date.now();
148
+ if (diff <= 0)
149
+ return 'imminent';
150
+ const seconds = Math.floor(diff / 1000);
151
+ if (seconds < 60)
152
+ return `in ${seconds}s`;
153
+ const minutes = Math.floor(seconds / 60);
154
+ if (minutes < 60)
155
+ return `in ${minutes}m`;
156
+ const hours = Math.floor(minutes / 60);
157
+ if (hours < 24)
158
+ return `in ${hours}h ${minutes % 60}m`;
159
+ const days = Math.floor(hours / 24);
160
+ return `in ${days}d ${hours % 24}h`;
161
+ }
162
+ export function renderStatusReport(snapshot, botDisplayName = 'Discoclaw') {
163
+ const lines = [];
164
+ lines.push(`${botDisplayName} Status`);
165
+ // Uptime & last message
166
+ lines.push(`Uptime: ${formatUptime(snapshot.uptimeMs)}`);
167
+ if (snapshot.lastMessageAt !== null) {
168
+ const agoMs = Date.now() - snapshot.lastMessageAt;
169
+ lines.push(`Last message: ${formatTimeAgo(agoMs)} ago`);
170
+ }
171
+ else {
172
+ lines.push('Last message: none since startup');
173
+ }
174
+ // Crons
175
+ if (snapshot.crons.length === 0) {
176
+ lines.push('Crons: none');
177
+ }
178
+ else {
179
+ lines.push(`Crons (${snapshot.crons.length}):`);
180
+ for (const job of snapshot.crons) {
181
+ const next = job.nextRun
182
+ ? formatNextRun(job.nextRun)
183
+ : job.schedule
184
+ ? 'stopped'
185
+ : 'manual/webhook';
186
+ lines.push(` ${job.name}: next=${next}`);
187
+ }
188
+ }
189
+ // Tasks
190
+ lines.push(`Open tasks: ${snapshot.openTaskCount}`);
191
+ // Memory
192
+ lines.push(`Memory: durable=${snapshot.durableItemCount} items, summaries=${snapshot.rollingSummaryCharCount} chars`);
193
+ // API connectivity
194
+ const apiParts = snapshot.apiChecks.map((r) => {
195
+ const tag = r.status === 'ok' ? 'ok' : r.status === 'skip' ? 'skip' : 'FAIL';
196
+ const detail = r.message ? ` (${r.message})` : '';
197
+ return `${r.name}: ${tag}${detail}`;
198
+ });
199
+ lines.push(`API: ${apiParts.length > 0 ? apiParts.join(', ') : 'no checks'}`);
200
+ // Workspace PA files
201
+ const paAllOk = snapshot.paFiles.length > 0 && snapshot.paFiles.every((f) => f.exists);
202
+ const paParts = snapshot.paFiles.map((f) => `${f.label}: ${f.exists ? 'ok' : 'MISSING'}`);
203
+ const paSuffix = paParts.length > 0 ? paParts.join(', ') : 'none configured';
204
+ lines.push(`Workspace PA: ${paAllOk ? 'ok' : 'DEGRADED'} — ${paSuffix}`);
205
+ return `\`\`\`text\n${lines.join('\n')}\n\`\`\``;
206
+ }