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,143 @@
1
+ import { execFile } from 'node:child_process';
2
+ import os from 'node:os';
3
+ import { writeShutdownContext } from './shutdown-context.js';
4
+ export function parseRestartCommand(content) {
5
+ const normalized = content.trim().toLowerCase().replace(/\s+/g, ' ');
6
+ if (normalized === '!restart')
7
+ return { action: 'restart' };
8
+ if (normalized === '!restart status')
9
+ return { action: 'status' };
10
+ if (normalized === '!restart logs')
11
+ return { action: 'logs' };
12
+ if (normalized === '!restart help')
13
+ return { action: 'help' };
14
+ return null;
15
+ }
16
+ function run(cmd, args) {
17
+ return new Promise((resolve) => {
18
+ execFile(cmd, args, { timeout: 15_000 }, (err, stdout, stderr) => {
19
+ const exitCode = err ? err.code ?? null : 0;
20
+ resolve({
21
+ stdout: String(stdout ?? ''),
22
+ stderr: String(stderr ?? ''),
23
+ exitCode: typeof exitCode === 'number' ? exitCode : null,
24
+ });
25
+ });
26
+ });
27
+ }
28
+ function getPlatformCommands() {
29
+ if (process.platform === 'linux') {
30
+ return {
31
+ statusCmd: ['systemctl', ['--user', 'status', 'discoclaw']],
32
+ logsCmd: ['journalctl', ['--user', '-u', 'discoclaw', '--no-pager', '-n', '30']],
33
+ checkActiveCmd: ['systemctl', ['--user', 'status', 'discoclaw']],
34
+ isActive: (result) => result.stdout.includes('active (running)'),
35
+ restartCmd: () => ['systemctl', ['--user', 'restart', 'discoclaw']],
36
+ };
37
+ }
38
+ if (process.platform === 'darwin') {
39
+ const uid = process.getuid?.() ?? 501;
40
+ const plistPath = `${os.homedir()}/Library/LaunchAgents/com.discoclaw.agent.plist`;
41
+ const domain = `gui/${uid}`;
42
+ const label = 'com.discoclaw.agent';
43
+ return {
44
+ statusCmd: ['launchctl', ['list', label]],
45
+ logsCmd: ['log', ['show', '--predicate', 'process == "node"', '--last', '5m', '--style', 'compact']],
46
+ checkActiveCmd: ['launchctl', ['list', label]],
47
+ isActive: (result) => result.exitCode === 0,
48
+ restartCmd: (wasActive) => wasActive
49
+ ? ['launchctl', ['kickstart', '-k', `${domain}/${label}`]]
50
+ : ['launchctl', ['bootstrap', domain, plistPath]],
51
+ };
52
+ }
53
+ return null;
54
+ }
55
+ /**
56
+ * Returns [cmd, args] for restarting the discoclaw service on the current
57
+ * platform, assuming the service is already running. Falls back to systemctl
58
+ * on unsupported platforms.
59
+ */
60
+ export function getRestartCmdArgs() {
61
+ const pc = getPlatformCommands();
62
+ if (pc)
63
+ return pc.restartCmd(true);
64
+ return ['systemctl', ['--user', 'restart', 'discoclaw']];
65
+ }
66
+ export async function handleRestartCommand(cmd, opts) {
67
+ // Support both legacy (log) and new (opts bag) signatures.
68
+ const resolved = opts && typeof opts === 'object' && 'info' in opts
69
+ ? { log: opts }
70
+ : opts ?? {};
71
+ const { log, dataDir, userId, activeForge } = resolved;
72
+ try {
73
+ if (cmd.action === 'help') {
74
+ return {
75
+ reply: [
76
+ '**!restart commands:**',
77
+ '- `!restart` — restart the discoclaw service',
78
+ '- `!restart status` — show service status',
79
+ '- `!restart logs` — show recent logs (last 30 lines)',
80
+ '- `!restart help` — this message',
81
+ ].join('\n'),
82
+ };
83
+ }
84
+ const pc = getPlatformCommands();
85
+ if (!pc) {
86
+ return {
87
+ reply: `!restart is not supported on this platform (${process.platform}). Only Linux (systemd) and macOS (launchd) are supported.`,
88
+ };
89
+ }
90
+ if (cmd.action === 'status') {
91
+ const result = await run(pc.statusCmd[0], pc.statusCmd[1]);
92
+ const output = (result.stdout || result.stderr).trim();
93
+ log?.info({ exitCode: result.exitCode }, 'restart-command:status');
94
+ return { reply: `\`\`\`\n${output.slice(0, 1800)}\n\`\`\`` };
95
+ }
96
+ if (cmd.action === 'logs') {
97
+ const result = await run(pc.logsCmd[0], pc.logsCmd[1]);
98
+ const output = (result.stdout || result.stderr).trim();
99
+ log?.info({}, 'restart-command:logs');
100
+ return { reply: `\`\`\`\n${output.slice(0, 1800)}\n\`\`\`` };
101
+ }
102
+ // action === 'restart'
103
+ // Check current status for context in the reply.
104
+ const before = await run(pc.checkActiveCmd[0], pc.checkActiveCmd[1]);
105
+ const wasActive = pc.isActive(before);
106
+ log?.info({ wasActive }, 'restart-command:restart');
107
+ // We can't restart inline — the restart kills this process before
108
+ // we can reply. Instead, return a deferred function that the caller
109
+ // invokes *after* sending the reply to Discord.
110
+ return {
111
+ reply: wasActive
112
+ ? 'Restarting discoclaw... back in a moment.'
113
+ : 'Starting discoclaw...',
114
+ deferred: () => {
115
+ // Write shutdown context right before triggering restart so it
116
+ // doesn't linger if the deferred never fires or restart fails.
117
+ if (dataDir) {
118
+ const ctx = {
119
+ reason: 'restart-command',
120
+ message: 'User requested via !restart',
121
+ timestamp: new Date().toISOString(),
122
+ requestedBy: userId,
123
+ activeForge,
124
+ };
125
+ // Synchronous-ish: writeFile + rename, then exec restart.
126
+ writeShutdownContext(dataDir, ctx).catch((err) => {
127
+ log?.warn({ err }, 'restart-command:failed to write shutdown context');
128
+ });
129
+ }
130
+ // Fire and forget — the process will die during this call.
131
+ const [restartBin, restartArgs] = pc.restartCmd(wasActive);
132
+ execFile(restartBin, restartArgs, (err) => {
133
+ // If we somehow survive (e.g., the service unit changed), log it.
134
+ if (err)
135
+ log?.error({ err }, 'restart-command:restart failed');
136
+ });
137
+ },
138
+ };
139
+ }
140
+ catch (err) {
141
+ return { reply: `Restart command error: ${String(err)}` };
142
+ }
143
+ }
@@ -0,0 +1,196 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { parseRestartCommand, handleRestartCommand } from './restart-command.js';
3
+ import * as shutdownCtx from './shutdown-context.js';
4
+ vi.mock('node:child_process', () => ({
5
+ execFile: vi.fn((cmd, args, optsOrCb, maybeCb) => {
6
+ const cb = typeof optsOrCb === 'function' ? optsOrCb : maybeCb;
7
+ // Simulate systemctl status returning "active (running)"
8
+ if (args.includes('status')) {
9
+ cb(null, 'active (running)\n', '');
10
+ }
11
+ else if (args.includes('restart')) {
12
+ cb(null, '', '');
13
+ }
14
+ else {
15
+ // journalctl logs / launchctl list / log show / kickstart / bootstrap
16
+ cb(null, 'Feb 12 14:00:00 discoclaw[1234]: started\n', '');
17
+ }
18
+ }),
19
+ }));
20
+ vi.mock('node:os', () => ({
21
+ default: {
22
+ homedir: () => '/Users/testuser',
23
+ },
24
+ }));
25
+ const savedPlatform = process.platform;
26
+ describe('parseRestartCommand', () => {
27
+ it('parses !restart as restart action', () => {
28
+ expect(parseRestartCommand('!restart')).toEqual({ action: 'restart' });
29
+ });
30
+ it('parses !restart status', () => {
31
+ expect(parseRestartCommand('!restart status')).toEqual({ action: 'status' });
32
+ });
33
+ it('parses !restart logs', () => {
34
+ expect(parseRestartCommand('!restart logs')).toEqual({ action: 'logs' });
35
+ });
36
+ it('parses !restart help', () => {
37
+ expect(parseRestartCommand('!restart help')).toEqual({ action: 'help' });
38
+ });
39
+ it('returns null for non-restart messages', () => {
40
+ expect(parseRestartCommand('hello')).toBeNull();
41
+ expect(parseRestartCommand('!memory show')).toBeNull();
42
+ expect(parseRestartCommand('!restarting')).toBeNull();
43
+ });
44
+ it('is case-insensitive', () => {
45
+ expect(parseRestartCommand('!RESTART')).toEqual({ action: 'restart' });
46
+ expect(parseRestartCommand('!Restart Status')).toEqual({ action: 'status' });
47
+ });
48
+ it('handles whitespace', () => {
49
+ expect(parseRestartCommand(' !restart ')).toEqual({ action: 'restart' });
50
+ expect(parseRestartCommand(' !restart status ')).toEqual({ action: 'status' });
51
+ });
52
+ });
53
+ describe('handleRestartCommand', () => {
54
+ beforeEach(() => {
55
+ vi.clearAllMocks();
56
+ Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
57
+ });
58
+ afterEach(() => {
59
+ Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true });
60
+ });
61
+ it('help returns usage text without calling execFile', async () => {
62
+ const { execFile } = await import('node:child_process');
63
+ const result = await handleRestartCommand({ action: 'help' });
64
+ expect(result.reply).toContain('!restart commands');
65
+ expect(result.deferred).toBeUndefined();
66
+ expect(execFile).not.toHaveBeenCalled();
67
+ });
68
+ it('status returns code-block output', async () => {
69
+ const result = await handleRestartCommand({ action: 'status' });
70
+ expect(result.reply).toContain('```');
71
+ expect(result.reply).toContain('active (running)');
72
+ expect(result.deferred).toBeUndefined();
73
+ });
74
+ it('logs returns code-block output', async () => {
75
+ const result = await handleRestartCommand({ action: 'logs' });
76
+ expect(result.reply).toContain('```');
77
+ expect(result.reply).toContain('discoclaw');
78
+ expect(result.deferred).toBeUndefined();
79
+ });
80
+ it('restart returns a deferred function and correct reply', async () => {
81
+ const result = await handleRestartCommand({ action: 'restart' });
82
+ expect(result.reply).toBe('Restarting discoclaw... back in a moment.');
83
+ expect(typeof result.deferred).toBe('function');
84
+ });
85
+ it('does not write shutdown context until deferred is called', async () => {
86
+ const spy = vi.spyOn(shutdownCtx, 'writeShutdownContext').mockResolvedValue();
87
+ const result = await handleRestartCommand({ action: 'restart' }, { dataDir: '/tmp/test', userId: '123', activeForge: 'plan-001' });
88
+ // Before deferred: no write.
89
+ expect(spy).not.toHaveBeenCalled();
90
+ // After deferred: write happens.
91
+ result.deferred();
92
+ expect(spy).toHaveBeenCalledOnce();
93
+ expect(spy.mock.calls[0][1]).toMatchObject({
94
+ reason: 'restart-command',
95
+ requestedBy: '123',
96
+ activeForge: 'plan-001',
97
+ });
98
+ spy.mockRestore();
99
+ });
100
+ it('restart reports "Starting" when service was not active', async () => {
101
+ const { execFile } = await import('node:child_process');
102
+ // Override mock to simulate inactive service (only for the status-check call)
103
+ execFile.mockImplementationOnce((cmd, args, opts, cb) => {
104
+ cb(null, 'inactive (dead)\n', '');
105
+ });
106
+ const result = await handleRestartCommand({ action: 'restart' });
107
+ expect(result.reply).toBe('Starting discoclaw...');
108
+ });
109
+ });
110
+ describe('handleRestartCommand - macOS', () => {
111
+ beforeEach(() => {
112
+ vi.clearAllMocks();
113
+ Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
114
+ vi.spyOn(process, 'getuid').mockReturnValue(501);
115
+ });
116
+ afterEach(() => {
117
+ Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true });
118
+ vi.restoreAllMocks();
119
+ });
120
+ it('status calls launchctl list instead of systemctl', async () => {
121
+ const { execFile } = await import('node:child_process');
122
+ const result = await handleRestartCommand({ action: 'status' });
123
+ expect(result.reply).toContain('```');
124
+ expect(result.deferred).toBeUndefined();
125
+ const calls = execFile.mock.calls;
126
+ const statusCall = calls.find((c) => c[0] === 'launchctl');
127
+ expect(statusCall).toBeDefined();
128
+ expect(statusCall[1]).toContain('list');
129
+ expect(statusCall[1]).toContain('com.discoclaw.agent');
130
+ // Must NOT call systemctl
131
+ expect(calls.every((c) => c[0] !== 'systemctl')).toBe(true);
132
+ });
133
+ it('logs calls log show instead of journalctl', async () => {
134
+ const { execFile } = await import('node:child_process');
135
+ const result = await handleRestartCommand({ action: 'logs' });
136
+ expect(result.reply).toContain('```');
137
+ expect(result.deferred).toBeUndefined();
138
+ const calls = execFile.mock.calls;
139
+ const logsCall = calls.find((c) => c[0] === 'log');
140
+ expect(logsCall).toBeDefined();
141
+ expect(logsCall[1]).toContain('show');
142
+ expect(logsCall[1]).toContain('--predicate');
143
+ // Must NOT call journalctl
144
+ expect(calls.every((c) => c[0] !== 'journalctl')).toBe(true);
145
+ });
146
+ it('restart uses launchctl kickstart in deferred when service is active', async () => {
147
+ const { execFile } = await import('node:child_process');
148
+ // Default mock returns exit code 0 for launchctl list → wasActive = true
149
+ const result = await handleRestartCommand({ action: 'restart' });
150
+ expect(result.reply).toBe('Restarting discoclaw... back in a moment.');
151
+ expect(typeof result.deferred).toBe('function');
152
+ result.deferred();
153
+ const calls = execFile.mock.calls;
154
+ const kickstartCall = calls.find((c) => c[0] === 'launchctl' && c[1].includes('kickstart'));
155
+ expect(kickstartCall).toBeDefined();
156
+ expect(kickstartCall[1]).toContain('-k');
157
+ expect(kickstartCall[1]).toContain('gui/501/com.discoclaw.agent');
158
+ });
159
+ it('restart uses launchctl bootstrap in deferred when service is inactive', async () => {
160
+ const { execFile } = await import('node:child_process');
161
+ // Return non-zero exit code for the launchctl list check → wasActive = false
162
+ execFile.mockImplementationOnce((cmd, args, opts, cb) => {
163
+ const err = Object.assign(new Error('not loaded'), { code: 1 });
164
+ cb(err, '', '');
165
+ });
166
+ const result = await handleRestartCommand({ action: 'restart' });
167
+ expect(result.reply).toBe('Starting discoclaw...');
168
+ expect(typeof result.deferred).toBe('function');
169
+ result.deferred();
170
+ const calls = execFile.mock.calls;
171
+ const bootstrapCall = calls.find((c) => c[0] === 'launchctl' && c[1].includes('bootstrap'));
172
+ expect(bootstrapCall).toBeDefined();
173
+ expect(bootstrapCall[1]).toContain('gui/501');
174
+ expect(bootstrapCall[1]).toContain('/Users/testuser/Library/LaunchAgents/com.discoclaw.agent.plist');
175
+ });
176
+ });
177
+ describe('handleRestartCommand - unsupported platform', () => {
178
+ beforeEach(() => {
179
+ vi.clearAllMocks();
180
+ Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
181
+ });
182
+ afterEach(() => {
183
+ Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true });
184
+ });
185
+ it('restart returns explicit error message', async () => {
186
+ const result = await handleRestartCommand({ action: 'restart' });
187
+ expect(result.reply).toContain('!restart is not supported on this platform (win32)');
188
+ expect(result.reply).toContain('Only Linux (systemd) and macOS (launchd) are supported.');
189
+ expect(result.deferred).toBeUndefined();
190
+ });
191
+ it('status returns explicit error message', async () => {
192
+ const result = await handleRestartCommand({ action: 'status' });
193
+ expect(result.reply).toContain('!restart is not supported on this platform (win32)');
194
+ expect(result.reply).toContain('Only Linux (systemd) and macOS (launchd) are supported.');
195
+ });
196
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Collect the final text from a runtime invocation, streaming through all events.
3
+ *
4
+ * When `opts.requireFinalEvent` is true, throws if the stream ends without
5
+ * a `text_final` event (distinguishes a complete response from a truncated one).
6
+ *
7
+ * When `opts.onEvent` is provided, each event is forwarded to it before
8
+ * processing — used to drive live streaming preview in Discord progress messages.
9
+ */
10
+ export async function collectRuntimeText(runtime, prompt, model, cwd, tools, addDirs, timeoutMs, opts) {
11
+ let text = '';
12
+ let sawFinal = false;
13
+ for await (const evt of runtime.invoke({
14
+ prompt,
15
+ model,
16
+ cwd,
17
+ tools,
18
+ addDirs: addDirs.length > 0 ? addDirs : undefined,
19
+ timeoutMs,
20
+ ...(opts?.sessionKey ? { sessionKey: opts.sessionKey } : {}),
21
+ ...(opts?.signal ? { signal: opts.signal } : {}),
22
+ })) {
23
+ try {
24
+ opts?.onEvent?.(evt);
25
+ }
26
+ catch { /* UI callback errors must not abort execution */ }
27
+ if (evt.type === 'text_final') {
28
+ text = evt.text;
29
+ sawFinal = true;
30
+ }
31
+ else if (evt.type === 'text_delta') {
32
+ // Accumulate deltas in case text_final isn't emitted
33
+ text += evt.text;
34
+ }
35
+ else if (evt.type === 'error') {
36
+ throw new Error(`Runtime error: ${evt.message}`);
37
+ }
38
+ }
39
+ if (opts?.requireFinalEvent && !sawFinal) {
40
+ throw new Error('Runtime stream ended without text_final event (response may be truncated)');
41
+ }
42
+ return text;
43
+ }
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { collectRuntimeText } from './runtime-utils.js';
3
+ function makeCaptureRuntime() {
4
+ const calls = [];
5
+ const runtime = {
6
+ id: 'claude_code',
7
+ capabilities: new Set(['streaming_text']),
8
+ invoke(params) {
9
+ calls.push(params);
10
+ return (async function* () {
11
+ yield { type: 'text_final', text: 'ok' };
12
+ })();
13
+ },
14
+ };
15
+ return { runtime, calls };
16
+ }
17
+ function makeMultiEventRuntime(events) {
18
+ return {
19
+ id: 'claude_code',
20
+ capabilities: new Set(['streaming_text']),
21
+ invoke() {
22
+ return (async function* () {
23
+ for (const evt of events)
24
+ yield evt;
25
+ })();
26
+ },
27
+ };
28
+ }
29
+ describe('collectRuntimeText', () => {
30
+ it('passes sessionKey through to runtime.invoke() when provided', async () => {
31
+ const { runtime, calls } = makeCaptureRuntime();
32
+ await collectRuntimeText(runtime, 'hello', 'test-model', '/tmp', ['Read'], [], 30000, { sessionKey: 'forge:plan-001:opus:drafter' });
33
+ expect(calls).toHaveLength(1);
34
+ expect(calls[0].sessionKey).toBe('forge:plan-001:opus:drafter');
35
+ });
36
+ it('does not include sessionKey in invoke params when opts omitted', async () => {
37
+ const { runtime, calls } = makeCaptureRuntime();
38
+ await collectRuntimeText(runtime, 'hello', 'test-model', '/tmp', ['Read'], [], 30000);
39
+ expect(calls).toHaveLength(1);
40
+ expect(calls[0].sessionKey).toBeUndefined();
41
+ });
42
+ it('does not include sessionKey when opts has no sessionKey', async () => {
43
+ const { runtime, calls } = makeCaptureRuntime();
44
+ await collectRuntimeText(runtime, 'hello', 'test-model', '/tmp', ['Read'], [], 30000, { requireFinalEvent: true });
45
+ expect(calls).toHaveLength(1);
46
+ expect(calls[0].sessionKey).toBeUndefined();
47
+ });
48
+ });
49
+ describe('collectRuntimeText signal', () => {
50
+ it('passes signal through to runtime.invoke() when provided', async () => {
51
+ const { runtime, calls } = makeCaptureRuntime();
52
+ const ac = new AbortController();
53
+ await collectRuntimeText(runtime, 'hello', 'test-model', '/tmp', ['Read'], [], 30000, { signal: ac.signal });
54
+ expect(calls).toHaveLength(1);
55
+ expect(calls[0].signal).toBe(ac.signal);
56
+ });
57
+ it('does not include signal when opts has no signal', async () => {
58
+ const { runtime, calls } = makeCaptureRuntime();
59
+ await collectRuntimeText(runtime, 'hello', 'test-model', '/tmp', ['Read'], [], 30000, { requireFinalEvent: true });
60
+ expect(calls).toHaveLength(1);
61
+ expect(calls[0].signal).toBeUndefined();
62
+ });
63
+ it('throws when runtime emits error due to pre-aborted signal', async () => {
64
+ const ac = new AbortController();
65
+ ac.abort();
66
+ const runtime = makeMultiEventRuntime([
67
+ { type: 'error', message: 'aborted' },
68
+ ]);
69
+ await expect(collectRuntimeText(runtime, 'p', 'm', '/tmp', [], [], 30000, { signal: ac.signal })).rejects.toThrow('aborted');
70
+ });
71
+ });
72
+ describe('collectRuntimeText onEvent', () => {
73
+ it('forwards events to onEvent in order', async () => {
74
+ const events = [
75
+ { type: 'text_delta', text: 'hello' },
76
+ { type: 'text_delta', text: ' world' },
77
+ { type: 'text_final', text: 'hello world' },
78
+ ];
79
+ const runtime = makeMultiEventRuntime(events);
80
+ const received = [];
81
+ await collectRuntimeText(runtime, 'p', 'm', '/tmp', [], [], 30000, {
82
+ onEvent: (evt) => received.push(evt),
83
+ });
84
+ expect(received).toEqual(events);
85
+ });
86
+ it('return value is unchanged when onEvent is provided', async () => {
87
+ const runtime = makeMultiEventRuntime([{ type: 'text_final', text: 'result text' }]);
88
+ const result = await collectRuntimeText(runtime, 'p', 'm', '/tmp', [], [], 30000, {
89
+ onEvent: () => { },
90
+ });
91
+ expect(result).toBe('result text');
92
+ });
93
+ it('does not propagate a throwing onEvent', async () => {
94
+ const runtime = makeMultiEventRuntime([{ type: 'text_final', text: 'ok' }]);
95
+ await expect(collectRuntimeText(runtime, 'p', 'm', '/tmp', [], [], 30000, {
96
+ onEvent: () => { throw new Error('callback error'); },
97
+ })).resolves.toBe('ok');
98
+ });
99
+ it('still processes all events even when onEvent throws', async () => {
100
+ const events = [
101
+ { type: 'text_delta', text: 'a' },
102
+ { type: 'text_final', text: 'final' },
103
+ ];
104
+ const runtime = makeMultiEventRuntime(events);
105
+ const callCount = { n: 0 };
106
+ const result = await collectRuntimeText(runtime, 'p', 'm', '/tmp', [], [], 30000, {
107
+ onEvent: () => { callCount.n++; throw new Error('oops'); },
108
+ });
109
+ expect(callCount.n).toBe(2);
110
+ expect(result).toBe('final');
111
+ });
112
+ });
@@ -0,0 +1,7 @@
1
+ export function discordSessionKey(msg) {
2
+ if (msg.isDm)
3
+ return `discord:dm:${msg.authorId}`;
4
+ if (msg.threadId)
5
+ return `discord:thread:${msg.threadId}`;
6
+ return `discord:channel:${msg.channelId}`;
7
+ }
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { discordSessionKey } from './session-key.js';
3
+ describe('discordSessionKey', () => {
4
+ it('uses dm:<authorId> for DMs', () => {
5
+ expect(discordSessionKey({ channelId: 'c', authorId: 'u', isDm: true })).toBe('discord:dm:u');
6
+ });
7
+ it('uses thread:<threadId> for threads', () => {
8
+ expect(discordSessionKey({ channelId: 'c', authorId: 'u', isDm: false, threadId: 't' })).toBe('discord:thread:t');
9
+ });
10
+ it('uses channel:<channelId> for normal channels', () => {
11
+ expect(discordSessionKey({ channelId: 'c', authorId: 'u', isDm: false })).toBe('discord:channel:c');
12
+ });
13
+ });