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,93 @@
1
+ import { LongRunningProcess } from './long-running-process.js';
2
+ /**
3
+ * Pool of LongRunningProcess instances keyed by Discord session key.
4
+ */
5
+ export class ProcessPool {
6
+ pool = new Map();
7
+ maxProcesses;
8
+ log;
9
+ constructor(opts) {
10
+ this.maxProcesses = opts?.maxProcesses ?? 5;
11
+ this.log = opts?.log;
12
+ }
13
+ /**
14
+ * Get an existing alive process for the session key, or spawn a new one.
15
+ * Returns null if spawn fails (caller should fall back to one-shot).
16
+ */
17
+ getOrSpawn(sessionKey, processOpts) {
18
+ const existing = this.pool.get(sessionKey);
19
+ if (existing?.state === 'idle') {
20
+ this.log?.debug({ sessionKey }, 'process-pool: reusing existing process');
21
+ // True LRU: touch on access (Map preserves insertion order).
22
+ this.pool.delete(sessionKey);
23
+ this.pool.set(sessionKey, existing);
24
+ return existing;
25
+ }
26
+ // Remove dead process if present.
27
+ if (existing) {
28
+ // If the process is busy, do not disrupt it: fall back to one-shot.
29
+ if (existing.state === 'busy') {
30
+ this.log?.debug({ sessionKey }, 'process-pool: session busy, falling back to one-shot');
31
+ return null;
32
+ }
33
+ this.pool.delete(sessionKey);
34
+ }
35
+ // Evict least-recently-used idle process if at capacity. If none are idle, do not evict.
36
+ if (this.pool.size >= this.maxProcesses) {
37
+ const evicted = this.evictOldestIdle();
38
+ if (!evicted) {
39
+ this.log?.debug({ sessionKey, poolSize: this.pool.size }, 'process-pool: at capacity with no idle processes');
40
+ return null;
41
+ }
42
+ }
43
+ // Spawn a new process.
44
+ const proc = new LongRunningProcess(processOpts);
45
+ const ok = proc.spawn();
46
+ if (!ok) {
47
+ this.log?.info({ sessionKey }, 'process-pool: spawn failed');
48
+ return null;
49
+ }
50
+ // Auto-remove when the process dies.
51
+ proc.onCleanup = () => {
52
+ const current = this.pool.get(sessionKey);
53
+ if (current === proc) {
54
+ this.pool.delete(sessionKey);
55
+ }
56
+ };
57
+ this.pool.set(sessionKey, proc);
58
+ this.log?.info({ sessionKey, poolSize: this.pool.size }, 'process-pool: spawned new process');
59
+ return proc;
60
+ }
61
+ /** Kill and remove a specific session's process. */
62
+ remove(sessionKey) {
63
+ const proc = this.pool.get(sessionKey);
64
+ if (proc) {
65
+ this.pool.delete(sessionKey);
66
+ proc.kill();
67
+ this.log?.info({ sessionKey }, 'process-pool: removed process');
68
+ }
69
+ }
70
+ /** Kill all processes (shutdown cleanup). */
71
+ killAll() {
72
+ for (const [key, proc] of this.pool) {
73
+ proc.forceKill();
74
+ this.log?.debug({ sessionKey: key }, 'process-pool: killed process');
75
+ }
76
+ this.pool.clear();
77
+ }
78
+ get size() {
79
+ return this.pool.size;
80
+ }
81
+ evictOldestIdle() {
82
+ // Map iteration order is LRU (oldest to newest) because we "touch" on reuse.
83
+ for (const [key, proc] of this.pool) {
84
+ if (proc.state === 'idle') {
85
+ this.pool.delete(key);
86
+ proc.kill();
87
+ this.log?.info({ sessionKey: key }, 'process-pool: evicted idle process');
88
+ return true;
89
+ }
90
+ }
91
+ return false;
92
+ }
93
+ }
@@ -0,0 +1,148 @@
1
+ import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest';
2
+ import { EventEmitter } from 'node:events';
3
+ vi.mock('execa', () => ({
4
+ execa: vi.fn(),
5
+ }));
6
+ import { execa } from 'execa';
7
+ import { ProcessPool } from './process-pool.js';
8
+ function createMockSubprocess() {
9
+ const stdout = new EventEmitter();
10
+ const stderr = new EventEmitter();
11
+ const stdin = { write: vi.fn(), end: vi.fn() };
12
+ let resolvePromise;
13
+ const promise = new Promise((res) => {
14
+ resolvePromise = res;
15
+ });
16
+ const proc = Object.assign(promise, {
17
+ stdout,
18
+ stderr,
19
+ stdin,
20
+ kill: vi.fn(),
21
+ pid: Math.floor(Math.random() * 100000),
22
+ });
23
+ return { proc, stdout, stderr, stdin, resolve: resolvePromise };
24
+ }
25
+ const baseProcessOpts = {
26
+ claudeBin: 'claude',
27
+ model: 'opus',
28
+ cwd: '/tmp',
29
+ dangerouslySkipPermissions: true,
30
+ hangTimeoutMs: 60000,
31
+ idleTimeoutMs: 300000,
32
+ };
33
+ beforeEach(() => {
34
+ vi.useFakeTimers();
35
+ execa.mockReset?.();
36
+ execa.mockImplementation(() => createMockSubprocess().proc);
37
+ });
38
+ afterEach(() => {
39
+ vi.useRealTimers();
40
+ });
41
+ describe('ProcessPool', () => {
42
+ it('getOrSpawn creates a new process for a new session key', () => {
43
+ const pool = new ProcessPool({ maxProcesses: 3 });
44
+ const proc = pool.getOrSpawn('session-1', baseProcessOpts);
45
+ expect(proc).not.toBeNull();
46
+ expect(proc.isAlive).toBe(true);
47
+ expect(pool.size).toBe(1);
48
+ });
49
+ it('getOrSpawn returns existing process for same session key', () => {
50
+ const pool = new ProcessPool({ maxProcesses: 3 });
51
+ const proc1 = pool.getOrSpawn('session-1', baseProcessOpts);
52
+ const proc2 = pool.getOrSpawn('session-1', baseProcessOpts);
53
+ expect(proc1).toBe(proc2);
54
+ expect(pool.size).toBe(1);
55
+ // Only one execa call
56
+ expect(execa.mock.calls).toHaveLength(1);
57
+ });
58
+ it('getOrSpawn creates separate processes for different session keys', () => {
59
+ const pool = new ProcessPool({ maxProcesses: 3 });
60
+ const proc1 = pool.getOrSpawn('session-1', baseProcessOpts);
61
+ const proc2 = pool.getOrSpawn('session-2', baseProcessOpts);
62
+ expect(proc1).not.toBe(proc2);
63
+ expect(pool.size).toBe(2);
64
+ });
65
+ it('evicts oldest idle process when at capacity', () => {
66
+ const pool = new ProcessPool({ maxProcesses: 2 });
67
+ const proc1 = pool.getOrSpawn('session-1', baseProcessOpts);
68
+ pool.getOrSpawn('session-2', baseProcessOpts);
69
+ expect(pool.size).toBe(2);
70
+ // Adding a third should evict the first (oldest idle)
71
+ pool.getOrSpawn('session-3', baseProcessOpts);
72
+ expect(pool.size).toBe(2);
73
+ expect(proc1.state).toBe('dead');
74
+ });
75
+ it('touching a session makes it most-recently-used (true LRU)', () => {
76
+ const pool = new ProcessPool({ maxProcesses: 2 });
77
+ const proc1 = pool.getOrSpawn('session-1', baseProcessOpts);
78
+ const proc2 = pool.getOrSpawn('session-2', baseProcessOpts);
79
+ expect(pool.size).toBe(2);
80
+ // Touch session-1 so session-2 becomes the LRU entry.
81
+ const proc1b = pool.getOrSpawn('session-1', baseProcessOpts);
82
+ expect(proc1b).toBe(proc1);
83
+ // Adding a third should evict session-2 (LRU).
84
+ pool.getOrSpawn('session-3', baseProcessOpts);
85
+ expect(pool.size).toBe(2);
86
+ expect(proc2.state).toBe('dead');
87
+ expect(proc1.state).not.toBe('dead');
88
+ });
89
+ it('does not evict busy processes; returns null when at capacity with no idle', async () => {
90
+ const pool = new ProcessPool({ maxProcesses: 2 });
91
+ const proc1 = pool.getOrSpawn('session-1', { ...baseProcessOpts, hangTimeoutMs: 60_000 });
92
+ const proc2 = pool.getOrSpawn('session-2', { ...baseProcessOpts, hangTimeoutMs: 60_000 });
93
+ expect(proc1).not.toBeNull();
94
+ expect(proc2).not.toBeNull();
95
+ const consume1 = (async () => {
96
+ for await (const _evt of proc1.sendTurn('t1')) {
97
+ // drain until killAll unblocks
98
+ }
99
+ })();
100
+ const consume2 = (async () => {
101
+ for await (const _evt of proc2.sendTurn('t2')) {
102
+ // drain until killAll unblocks
103
+ }
104
+ })();
105
+ expect(proc1.state).toBe('busy');
106
+ expect(proc2.state).toBe('busy');
107
+ const proc3 = pool.getOrSpawn('session-3', baseProcessOpts);
108
+ expect(proc3).toBeNull();
109
+ expect(pool.size).toBe(2);
110
+ expect(proc1.state).toBe('busy');
111
+ expect(proc2.state).toBe('busy');
112
+ pool.killAll();
113
+ await Promise.all([consume1, consume2]);
114
+ });
115
+ it('remove kills and deletes a specific process', () => {
116
+ const pool = new ProcessPool({ maxProcesses: 3 });
117
+ const proc = pool.getOrSpawn('session-1', baseProcessOpts);
118
+ expect(pool.size).toBe(1);
119
+ pool.remove('session-1');
120
+ expect(pool.size).toBe(0);
121
+ expect(proc.state).toBe('dead');
122
+ });
123
+ it('remove is a no-op for unknown session key', () => {
124
+ const pool = new ProcessPool({ maxProcesses: 3 });
125
+ pool.getOrSpawn('session-1', baseProcessOpts);
126
+ pool.remove('nonexistent');
127
+ expect(pool.size).toBe(1);
128
+ });
129
+ it('killAll kills all processes and empties the pool', () => {
130
+ const pool = new ProcessPool({ maxProcesses: 5 });
131
+ const proc1 = pool.getOrSpawn('session-1', baseProcessOpts);
132
+ const proc2 = pool.getOrSpawn('session-2', baseProcessOpts);
133
+ const proc3 = pool.getOrSpawn('session-3', baseProcessOpts);
134
+ pool.killAll();
135
+ expect(pool.size).toBe(0);
136
+ expect(proc1.state).toBe('dead');
137
+ expect(proc2.state).toBe('dead');
138
+ expect(proc3.state).toBe('dead');
139
+ });
140
+ it('replaces dead process on getOrSpawn', () => {
141
+ const pool = new ProcessPool({ maxProcesses: 3 });
142
+ const proc1 = pool.getOrSpawn('session-1', baseProcessOpts);
143
+ proc1.kill(); // Mark as dead
144
+ const proc2 = pool.getOrSpawn('session-1', baseProcessOpts);
145
+ expect(proc2).not.toBe(proc1);
146
+ expect(proc2.isAlive).toBe(true);
147
+ });
148
+ });
@@ -0,0 +1,15 @@
1
+ export class RuntimeRegistry {
2
+ adapters = new Map();
3
+ register(name, adapter) {
4
+ this.adapters.set(name, adapter);
5
+ }
6
+ get(name) {
7
+ return this.adapters.get(name);
8
+ }
9
+ list() {
10
+ return [...this.adapters.keys()];
11
+ }
12
+ has(name) {
13
+ return this.adapters.has(name);
14
+ }
15
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { RuntimeRegistry } from './registry.js';
3
+ function makeMockAdapter(id) {
4
+ return {
5
+ id: id,
6
+ capabilities: new Set(['streaming_text']),
7
+ invoke() {
8
+ return (async function* () {
9
+ yield { type: 'text_final', text: '' };
10
+ yield { type: 'done' };
11
+ })();
12
+ },
13
+ };
14
+ }
15
+ describe('RuntimeRegistry', () => {
16
+ it('register + get returns the adapter', () => {
17
+ const registry = new RuntimeRegistry();
18
+ const adapter = makeMockAdapter('openai');
19
+ registry.register('openai', adapter);
20
+ expect(registry.get('openai')).toBe(adapter);
21
+ });
22
+ it('get for unknown name returns undefined', () => {
23
+ const registry = new RuntimeRegistry();
24
+ expect(registry.get('nonexistent')).toBeUndefined();
25
+ });
26
+ it('list returns registered names', () => {
27
+ const registry = new RuntimeRegistry();
28
+ registry.register('claude', makeMockAdapter('claude_code'));
29
+ registry.register('openai', makeMockAdapter('openai'));
30
+ expect(registry.list()).toEqual(['claude', 'openai']);
31
+ });
32
+ it('has returns correct boolean', () => {
33
+ const registry = new RuntimeRegistry();
34
+ registry.register('claude', makeMockAdapter('claude_code'));
35
+ expect(registry.has('claude')).toBe(true);
36
+ expect(registry.has('openai')).toBe(false);
37
+ });
38
+ it('registering the same name twice overwrites silently', () => {
39
+ const registry = new RuntimeRegistry();
40
+ const first = makeMockAdapter('openai');
41
+ const second = makeMockAdapter('openai');
42
+ registry.register('openai', first);
43
+ registry.register('openai', second);
44
+ expect(registry.get('openai')).toBe(second);
45
+ expect(registry.list()).toEqual(['openai']);
46
+ });
47
+ });
@@ -0,0 +1,186 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ /**
6
+ * Escape a CWD path for Claude's session directory naming.
7
+ * `/home/user/code/proj` → `-home-user-code-proj`
8
+ */
9
+ function escapeCwd(cwd) {
10
+ return cwd.replace(/\//g, '-');
11
+ }
12
+ function sessionFilePath(sessionId, cwd) {
13
+ const home = os.homedir();
14
+ const escaped = escapeCwd(cwd);
15
+ return path.join(home, '.claude', 'projects', escaped, `${sessionId}.jsonl`);
16
+ }
17
+ export class SessionFileScanner {
18
+ filePath;
19
+ callbacks;
20
+ log;
21
+ offset = 0;
22
+ lineBuf = '';
23
+ activeTools = new Map();
24
+ watcher = null;
25
+ pollTimer = null;
26
+ stopped = false;
27
+ reading = false;
28
+ constructor(opts, callbacks) {
29
+ this.filePath = sessionFilePath(opts.sessionId, opts.cwd);
30
+ this.callbacks = callbacks;
31
+ this.log = opts.log;
32
+ }
33
+ async start() {
34
+ // Wait for the file to appear (poll with backoff, max ~10s).
35
+ const existed = await this.waitForFile();
36
+ if (!existed || this.stopped)
37
+ return;
38
+ // Record initial file size so we skip pre-existing content.
39
+ try {
40
+ const stat = await fsp.stat(this.filePath);
41
+ this.offset = stat.size;
42
+ }
43
+ catch {
44
+ return;
45
+ }
46
+ if (this.stopped)
47
+ return;
48
+ // Start watching for changes.
49
+ try {
50
+ this.watcher = fs.watch(this.filePath, () => {
51
+ this.readNewBytes();
52
+ });
53
+ this.watcher.on('error', () => {
54
+ // Degrade gracefully — polling fallback continues.
55
+ });
56
+ }
57
+ catch {
58
+ // fs.watch not available — polling alone.
59
+ }
60
+ // Polling fallback every 2s (fs.watch isn't reliable on all platforms).
61
+ this.pollTimer = setInterval(() => {
62
+ this.readNewBytes();
63
+ }, 2000);
64
+ }
65
+ stop() {
66
+ if (this.stopped)
67
+ return;
68
+ this.stopped = true;
69
+ if (this.watcher) {
70
+ this.watcher.close();
71
+ this.watcher = null;
72
+ }
73
+ if (this.pollTimer) {
74
+ clearInterval(this.pollTimer);
75
+ this.pollTimer = null;
76
+ }
77
+ // Emit tool_end for any still-active tools.
78
+ for (const tool of this.activeTools.values()) {
79
+ this.callbacks.onEvent({
80
+ type: 'tool_end',
81
+ name: tool.name,
82
+ ok: true,
83
+ });
84
+ }
85
+ this.activeTools.clear();
86
+ }
87
+ async waitForFile() {
88
+ const delays = [100, 200, 500, 1000, 2000, 3000, 3200];
89
+ for (const delay of delays) {
90
+ if (this.stopped)
91
+ return false;
92
+ try {
93
+ await fsp.access(this.filePath);
94
+ return true;
95
+ }
96
+ catch {
97
+ await new Promise((r) => setTimeout(r, delay));
98
+ }
99
+ }
100
+ this.log?.debug('session-scanner: file never appeared, degrading gracefully');
101
+ return false;
102
+ }
103
+ readNewBytes() {
104
+ if (this.stopped || this.reading)
105
+ return;
106
+ this.reading = true;
107
+ let fd = null;
108
+ try {
109
+ fd = fs.openSync(this.filePath, 'r');
110
+ const stat = fs.fstatSync(fd);
111
+ if (stat.size <= this.offset) {
112
+ fs.closeSync(fd);
113
+ return;
114
+ }
115
+ const bytesToRead = stat.size - this.offset;
116
+ const buf = Buffer.alloc(bytesToRead);
117
+ fs.readSync(fd, buf, 0, bytesToRead, this.offset);
118
+ fs.closeSync(fd);
119
+ fd = null;
120
+ this.offset = stat.size;
121
+ this.lineBuf += buf.toString('utf8');
122
+ const lines = this.lineBuf.split('\n');
123
+ // Keep the last element as the incomplete line buffer.
124
+ this.lineBuf = lines.pop() ?? '';
125
+ for (const line of lines) {
126
+ const trimmed = line.trim();
127
+ if (!trimmed)
128
+ continue;
129
+ this.processLine(trimmed);
130
+ }
131
+ }
132
+ catch {
133
+ // Degrade gracefully on read errors.
134
+ if (fd !== null) {
135
+ try {
136
+ fs.closeSync(fd);
137
+ }
138
+ catch { /* ignore */ }
139
+ }
140
+ }
141
+ finally {
142
+ this.reading = false;
143
+ }
144
+ }
145
+ processLine(line) {
146
+ let parsed;
147
+ try {
148
+ parsed = JSON.parse(line);
149
+ }
150
+ catch {
151
+ this.log?.debug('session-scanner: parse error', line.slice(0, 200));
152
+ return;
153
+ }
154
+ // Tool use: assistant message with tool_use content blocks
155
+ if (parsed?.type === 'assistant' && Array.isArray(parsed?.message?.content)) {
156
+ for (const block of parsed.message.content) {
157
+ if (block?.type === 'tool_use' && typeof block.name === 'string') {
158
+ const blockId = String(block.id ?? '');
159
+ this.activeTools.set(blockId, { name: block.name, blockId });
160
+ this.callbacks.onEvent({
161
+ type: 'tool_start',
162
+ name: block.name,
163
+ input: block.input,
164
+ });
165
+ }
166
+ }
167
+ }
168
+ // Tool result: user message with tool_result content blocks
169
+ if (parsed?.type === 'user' && Array.isArray(parsed?.message?.content)) {
170
+ for (const block of parsed.message.content) {
171
+ if (block?.type === 'tool_result' && typeof block.tool_use_id === 'string') {
172
+ const toolUseId = block.tool_use_id;
173
+ const active = this.activeTools.get(toolUseId);
174
+ if (active) {
175
+ this.activeTools.delete(toolUseId);
176
+ this.callbacks.onEvent({
177
+ type: 'tool_end',
178
+ name: active.name,
179
+ ok: !block.is_error,
180
+ });
181
+ }
182
+ }
183
+ }
184
+ }
185
+ }
186
+ }