@monoes/monomindcli 1.14.7 → 1.15.1

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 (329) hide show
  1. package/.claude/agents/reengineer-squad/boss.md +113 -0
  2. package/.claude/agents/reengineer-squad/critic-architect.md +132 -0
  3. package/.claude/agents/reengineer-squad/git-manager.md +145 -0
  4. package/.claude/agents/reengineer-squad/idea-generator.md +95 -0
  5. package/.claude/agents/reengineer-squad/implementer.md +112 -0
  6. package/.claude/agents/reengineer-squad/integration-planner.md +112 -0
  7. package/.claude/agents/reengineer-squad/source-analyst.md +103 -0
  8. package/.claude/agents/reengineer-squad/target-analyst.md +118 -0
  9. package/.claude/agents/reengineer-squad/tester.md +105 -0
  10. package/.claude/commands/mastermind/master.md +35 -14
  11. package/.claude/helpers/handlers/capture-handler.cjs +155 -18
  12. package/.claude/helpers/monolean-activate.cjs +20 -0
  13. package/.claude/helpers/monolean-config.cjs +76 -0
  14. package/.claude/helpers/monolean-instructions.cjs +109 -0
  15. package/.claude/helpers/monolean-propagate.cjs +9 -0
  16. package/.claude/helpers/monolean-tracker.cjs +18 -0
  17. package/.claude/helpers/skill-registry.json +2 -2
  18. package/.claude/skills/agent-browser-testing/SKILL.md +301 -18
  19. package/.claude/skills/mastermind/runorg.md +69 -23
  20. package/.claude/skills/monodesign/SKILL.md +32 -1
  21. package/.claude/skills/monodesign/adapt.md +53 -0
  22. package/.claude/skills/monodesign/agents/monodesign-asset-producer.md +100 -0
  23. package/.claude/skills/monodesign/animate.md +65 -0
  24. package/.claude/skills/monodesign/audit.md +89 -0
  25. package/.claude/skills/monodesign/bolder.md +50 -0
  26. package/.claude/skills/monodesign/clarify.md +64 -0
  27. package/.claude/skills/monodesign/colorize.md +68 -0
  28. package/.claude/skills/monodesign/craft.md +51 -0
  29. package/.claude/skills/monodesign/critique.md +66 -0
  30. package/.claude/skills/monodesign/delight.md +47 -0
  31. package/.claude/skills/monodesign/distill.md +56 -0
  32. package/.claude/skills/monodesign/document.md +80 -0
  33. package/.claude/skills/monodesign/extract.md +74 -0
  34. package/.claude/skills/monodesign/harden.md +65 -0
  35. package/.claude/skills/monodesign/live.md +59 -0
  36. package/.claude/skills/monodesign/onboard.md +50 -0
  37. package/.claude/skills/monodesign/optimize.md +64 -0
  38. package/.claude/skills/monodesign/overdrive.md +56 -0
  39. package/.claude/skills/monodesign/polish.md +68 -0
  40. package/.claude/skills/monodesign/quieter.md +57 -0
  41. package/.claude/skills/monodesign/reference/antipatterns-catalog.md +248 -76
  42. package/.claude/skills/monodesign/reference/codex.md +107 -0
  43. package/.claude/skills/monodesign/reference/craft.md +3 -0
  44. package/.claude/skills/monodesign/reference/hooks.md +99 -0
  45. package/.claude/skills/monodesign/reference/image-prompts.md +12 -0
  46. package/.claude/skills/monodesign/shape.md +71 -0
  47. package/.claude/skills/monodesign/teach.md +69 -0
  48. package/.claude/skills/monodesign/typeset.md +59 -0
  49. package/.claude/skills/monolean/SKILL.md +118 -0
  50. package/.claude/skills/monolean-audit/SKILL.md +41 -0
  51. package/.claude/skills/monolean-debt/SKILL.md +46 -0
  52. package/.claude/skills/monolean-help/SKILL.md +60 -0
  53. package/.claude/skills/monolean-review/SKILL.md +57 -0
  54. package/bin/cli.js +3 -1
  55. package/dist/dashboard/server.js +137 -0
  56. package/dist/src/__tests__/browse-adapters.test.d.ts +2 -0
  57. package/dist/src/__tests__/browse-adapters.test.d.ts.map +1 -0
  58. package/dist/src/__tests__/browse-adapters.test.js +51 -0
  59. package/dist/src/__tests__/browse-adapters.test.js.map +1 -0
  60. package/dist/src/__tests__/browse-analyzer.test.d.ts +2 -0
  61. package/dist/src/__tests__/browse-analyzer.test.d.ts.map +1 -0
  62. package/dist/src/__tests__/browse-analyzer.test.js +68 -0
  63. package/dist/src/__tests__/browse-analyzer.test.js.map +1 -0
  64. package/dist/src/__tests__/browse-builtin-handlers.test.d.ts +2 -0
  65. package/dist/src/__tests__/browse-builtin-handlers.test.d.ts.map +1 -0
  66. package/dist/src/__tests__/browse-builtin-handlers.test.js +139 -0
  67. package/dist/src/__tests__/browse-builtin-handlers.test.js.map +1 -0
  68. package/dist/src/__tests__/browse-cdp.test.d.ts +2 -0
  69. package/dist/src/__tests__/browse-cdp.test.d.ts.map +1 -0
  70. package/dist/src/__tests__/browse-cdp.test.js +169 -0
  71. package/dist/src/__tests__/browse-cdp.test.js.map +1 -0
  72. package/dist/src/__tests__/browse-dashboard.test.d.ts +2 -0
  73. package/dist/src/__tests__/browse-dashboard.test.d.ts.map +1 -0
  74. package/dist/src/__tests__/browse-dashboard.test.js +179 -0
  75. package/dist/src/__tests__/browse-dashboard.test.js.map +1 -0
  76. package/dist/src/__tests__/browse-engine.test.d.ts +2 -0
  77. package/dist/src/__tests__/browse-engine.test.d.ts.map +1 -0
  78. package/dist/src/__tests__/browse-engine.test.js +122 -0
  79. package/dist/src/__tests__/browse-engine.test.js.map +1 -0
  80. package/dist/src/__tests__/browse-expression.test.d.ts +2 -0
  81. package/dist/src/__tests__/browse-expression.test.d.ts.map +1 -0
  82. package/dist/src/__tests__/browse-expression.test.js +54 -0
  83. package/dist/src/__tests__/browse-expression.test.js.map +1 -0
  84. package/dist/src/__tests__/browse-store.test.d.ts +2 -0
  85. package/dist/src/__tests__/browse-store.test.d.ts.map +1 -0
  86. package/dist/src/__tests__/browse-store.test.js +99 -0
  87. package/dist/src/__tests__/browse-store.test.js.map +1 -0
  88. package/dist/src/__tests__/browse-workflow-types.test.d.ts +2 -0
  89. package/dist/src/__tests__/browse-workflow-types.test.d.ts.map +1 -0
  90. package/dist/src/__tests__/browse-workflow-types.test.js +33 -0
  91. package/dist/src/__tests__/browse-workflow-types.test.js.map +1 -0
  92. package/dist/src/browser/action-builder/analyzer.d.ts +11 -0
  93. package/dist/src/browser/action-builder/analyzer.d.ts.map +1 -0
  94. package/dist/src/browser/action-builder/analyzer.js +71 -0
  95. package/dist/src/browser/action-builder/analyzer.js.map +1 -0
  96. package/dist/src/browser/action-builder/types.d.ts +47 -0
  97. package/dist/src/browser/action-builder/types.d.ts.map +1 -0
  98. package/dist/src/browser/action-builder/types.js +2 -0
  99. package/dist/src/browser/action-builder/types.js.map +1 -0
  100. package/dist/src/browser/adapters/gemini.d.ts +3 -0
  101. package/dist/src/browser/adapters/gemini.d.ts.map +1 -0
  102. package/dist/src/browser/adapters/gemini.js +16 -0
  103. package/dist/src/browser/adapters/gemini.js.map +1 -0
  104. package/dist/src/browser/adapters/google.d.ts +3 -0
  105. package/dist/src/browser/adapters/google.d.ts.map +1 -0
  106. package/dist/src/browser/adapters/google.js +17 -0
  107. package/dist/src/browser/adapters/google.js.map +1 -0
  108. package/dist/src/browser/adapters/index.d.ts +19 -0
  109. package/dist/src/browser/adapters/index.d.ts.map +1 -0
  110. package/dist/src/browser/adapters/index.js +23 -0
  111. package/dist/src/browser/adapters/index.js.map +1 -0
  112. package/dist/src/browser/adapters/instagram.d.ts +3 -0
  113. package/dist/src/browser/adapters/instagram.d.ts.map +1 -0
  114. package/dist/src/browser/adapters/instagram.js +17 -0
  115. package/dist/src/browser/adapters/instagram.js.map +1 -0
  116. package/dist/src/browser/adapters/linkedin.d.ts +3 -0
  117. package/dist/src/browser/adapters/linkedin.d.ts.map +1 -0
  118. package/dist/src/browser/adapters/linkedin.js +19 -0
  119. package/dist/src/browser/adapters/linkedin.js.map +1 -0
  120. package/dist/src/browser/adapters/microsoft.d.ts +3 -0
  121. package/dist/src/browser/adapters/microsoft.d.ts.map +1 -0
  122. package/dist/src/browser/adapters/microsoft.js +16 -0
  123. package/dist/src/browser/adapters/microsoft.js.map +1 -0
  124. package/dist/src/browser/adapters/x.d.ts +3 -0
  125. package/dist/src/browser/adapters/x.d.ts.map +1 -0
  126. package/dist/src/browser/adapters/x.js +19 -0
  127. package/dist/src/browser/adapters/x.js.map +1 -0
  128. package/dist/src/browser/dashboard/api-types.d.ts +50 -0
  129. package/dist/src/browser/dashboard/api-types.d.ts.map +1 -0
  130. package/dist/src/browser/dashboard/api-types.js +14 -0
  131. package/dist/src/browser/dashboard/api-types.js.map +1 -0
  132. package/dist/src/browser/dashboard/server.d.ts +9 -0
  133. package/dist/src/browser/dashboard/server.d.ts.map +1 -0
  134. package/dist/src/browser/dashboard/server.js +62 -0
  135. package/dist/src/browser/dashboard/server.js.map +1 -0
  136. package/dist/src/browser/dashboard/ui.html +1811 -0
  137. package/dist/src/browser/workflow/builtin-handlers.d.ts +3 -0
  138. package/dist/src/browser/workflow/builtin-handlers.d.ts.map +1 -0
  139. package/dist/src/browser/workflow/builtin-handlers.js +343 -0
  140. package/dist/src/browser/workflow/builtin-handlers.js.map +1 -0
  141. package/dist/src/browser/workflow/engine.d.ts +15 -0
  142. package/dist/src/browser/workflow/engine.d.ts.map +1 -0
  143. package/dist/src/browser/workflow/engine.js +127 -0
  144. package/dist/src/browser/workflow/engine.js.map +1 -0
  145. package/dist/src/browser/workflow/expression.d.ts +4 -0
  146. package/dist/src/browser/workflow/expression.d.ts.map +1 -0
  147. package/dist/src/browser/workflow/expression.js +64 -0
  148. package/dist/src/browser/workflow/expression.js.map +1 -0
  149. package/dist/src/browser/workflow/store.d.ts +24 -0
  150. package/dist/src/browser/workflow/store.d.ts.map +1 -0
  151. package/dist/src/browser/workflow/store.js +145 -0
  152. package/dist/src/browser/workflow/store.js.map +1 -0
  153. package/dist/src/browser/workflow/types.d.ts +48 -0
  154. package/dist/src/browser/workflow/types.d.ts.map +1 -0
  155. package/dist/src/browser/workflow/types.js +2 -0
  156. package/dist/src/browser/workflow/types.js.map +1 -0
  157. package/dist/src/commands/browse-action.d.ts +4 -0
  158. package/dist/src/commands/browse-action.d.ts.map +1 -0
  159. package/dist/src/commands/browse-action.js +151 -0
  160. package/dist/src/commands/browse-action.js.map +1 -0
  161. package/dist/src/commands/browse-platform.d.ts +4 -0
  162. package/dist/src/commands/browse-platform.d.ts.map +1 -0
  163. package/dist/src/commands/browse-platform.js +117 -0
  164. package/dist/src/commands/browse-platform.js.map +1 -0
  165. package/dist/src/commands/browse-workflow.d.ts +4 -0
  166. package/dist/src/commands/browse-workflow.d.ts.map +1 -0
  167. package/dist/src/commands/browse-workflow.js +153 -0
  168. package/dist/src/commands/browse-workflow.js.map +1 -0
  169. package/dist/src/commands/browse.d.ts +10 -6
  170. package/dist/src/commands/browse.d.ts.map +1 -1
  171. package/dist/src/commands/browse.js +11 -2154
  172. package/dist/src/commands/browse.js.map +1 -1
  173. package/dist/src/commands/design-detect.d.ts +21 -0
  174. package/dist/src/commands/design-detect.d.ts.map +1 -0
  175. package/dist/src/commands/design-detect.js +127 -0
  176. package/dist/src/commands/design-detect.js.map +1 -0
  177. package/dist/src/commands/design-palette.d.ts +22 -0
  178. package/dist/src/commands/design-palette.d.ts.map +1 -0
  179. package/dist/src/commands/design-palette.js +539 -0
  180. package/dist/src/commands/design-palette.js.map +1 -0
  181. package/dist/src/commands/hooks-core-commands.d.ts +10 -0
  182. package/dist/src/commands/hooks-core-commands.d.ts.map +1 -0
  183. package/dist/src/commands/hooks-core-commands.js +377 -0
  184. package/dist/src/commands/hooks-core-commands.js.map +1 -0
  185. package/dist/src/commands/hooks-coverage-commands.d.ts +12 -0
  186. package/dist/src/commands/hooks-coverage-commands.d.ts.map +1 -0
  187. package/dist/src/commands/hooks-coverage-commands.js +1217 -0
  188. package/dist/src/commands/hooks-coverage-commands.js.map +1 -0
  189. package/dist/src/commands/hooks-coverage-utils.d.ts +42 -0
  190. package/dist/src/commands/hooks-coverage-utils.d.ts.map +1 -0
  191. package/dist/src/commands/hooks-coverage-utils.js +220 -0
  192. package/dist/src/commands/hooks-coverage-utils.js.map +1 -0
  193. package/dist/src/commands/hooks-extended-commands.d.ts +14 -0
  194. package/dist/src/commands/hooks-extended-commands.d.ts.map +1 -0
  195. package/dist/src/commands/hooks-extended-commands.js +579 -0
  196. package/dist/src/commands/hooks-extended-commands.js.map +1 -0
  197. package/dist/src/commands/hooks-formatting.d.ts +13 -0
  198. package/dist/src/commands/hooks-formatting.d.ts.map +1 -0
  199. package/dist/src/commands/hooks-formatting.js +42 -0
  200. package/dist/src/commands/hooks-formatting.js.map +1 -0
  201. package/dist/src/commands/hooks-routing-commands.d.ts +15 -0
  202. package/dist/src/commands/hooks-routing-commands.d.ts.map +1 -0
  203. package/dist/src/commands/hooks-routing-commands.js +723 -0
  204. package/dist/src/commands/hooks-routing-commands.js.map +1 -0
  205. package/dist/src/commands/hooks-workers.d.ts +9 -0
  206. package/dist/src/commands/hooks-workers.d.ts.map +1 -0
  207. package/dist/src/commands/hooks-workers.js +782 -0
  208. package/dist/src/commands/hooks-workers.js.map +1 -0
  209. package/dist/src/commands/hooks.d.ts +8 -0
  210. package/dist/src/commands/hooks.d.ts.map +1 -1
  211. package/dist/src/commands/hooks.js +179 -4103
  212. package/dist/src/commands/hooks.js.map +1 -1
  213. package/dist/src/commands/index.d.ts +1 -0
  214. package/dist/src/commands/index.d.ts.map +1 -1
  215. package/dist/src/commands/index.js +6 -0
  216. package/dist/src/commands/index.js.map +1 -1
  217. package/dist/src/commands/org.d.ts.map +1 -1
  218. package/dist/src/commands/org.js +14 -15
  219. package/dist/src/commands/org.js.map +1 -1
  220. package/dist/src/commands/tokens.d.ts.map +1 -1
  221. package/dist/src/commands/tokens.js +77 -1
  222. package/dist/src/commands/tokens.js.map +1 -1
  223. package/dist/src/init/executor.d.ts.map +1 -1
  224. package/dist/src/init/executor.js +18 -8
  225. package/dist/src/init/executor.js.map +1 -1
  226. package/dist/src/init/settings-generator.d.ts.map +1 -1
  227. package/dist/src/init/settings-generator.js +39 -5
  228. package/dist/src/init/settings-generator.js.map +1 -1
  229. package/dist/src/init/statusline-generator.d.ts.map +1 -1
  230. package/dist/src/init/statusline-generator.js +25 -5
  231. package/dist/src/init/statusline-generator.js.map +1 -1
  232. package/dist/src/mcp-tools/browser-tools.d.ts +3 -5
  233. package/dist/src/mcp-tools/browser-tools.d.ts.map +1 -1
  234. package/dist/src/mcp-tools/browser-tools.js +619 -326
  235. package/dist/src/mcp-tools/browser-tools.js.map +1 -1
  236. package/dist/src/mcp-tools/hooks-embedding.d.ts +161 -0
  237. package/dist/src/mcp-tools/hooks-embedding.d.ts.map +1 -0
  238. package/dist/src/mcp-tools/hooks-embedding.js +506 -0
  239. package/dist/src/mcp-tools/hooks-embedding.js.map +1 -0
  240. package/dist/src/mcp-tools/hooks-intelligence.d.ts +26 -0
  241. package/dist/src/mcp-tools/hooks-intelligence.d.ts.map +1 -0
  242. package/dist/src/mcp-tools/hooks-intelligence.js +1328 -0
  243. package/dist/src/mcp-tools/hooks-intelligence.js.map +1 -0
  244. package/dist/src/mcp-tools/hooks-routing.d.ts +27 -0
  245. package/dist/src/mcp-tools/hooks-routing.d.ts.map +1 -0
  246. package/dist/src/mcp-tools/hooks-routing.js +1591 -0
  247. package/dist/src/mcp-tools/hooks-routing.js.map +1 -0
  248. package/dist/src/mcp-tools/hooks-tools.d.ts +3 -38
  249. package/dist/src/mcp-tools/hooks-tools.d.ts.map +1 -1
  250. package/dist/src/mcp-tools/hooks-tools.js +5 -3393
  251. package/dist/src/mcp-tools/hooks-tools.js.map +1 -1
  252. package/dist/src/mcp-tools/monograph-tools.d.ts.map +1 -1
  253. package/dist/src/mcp-tools/monograph-tools.js +24 -14
  254. package/dist/src/mcp-tools/monograph-tools.js.map +1 -1
  255. package/dist/src/mcp-tools/workflow-tools.d.ts.map +1 -1
  256. package/dist/src/mcp-tools/workflow-tools.js +54 -1
  257. package/dist/src/mcp-tools/workflow-tools.js.map +1 -1
  258. package/dist/src/memory/embedding-operations.d.ts +58 -0
  259. package/dist/src/memory/embedding-operations.d.ts.map +1 -0
  260. package/dist/src/memory/embedding-operations.js +299 -0
  261. package/dist/src/memory/embedding-operations.js.map +1 -0
  262. package/dist/src/memory/ewc-consolidation.d.ts.map +1 -1
  263. package/dist/src/memory/ewc-consolidation.js +37 -3
  264. package/dist/src/memory/ewc-consolidation.js.map +1 -1
  265. package/dist/src/memory/hnsw-operations.d.ts +130 -0
  266. package/dist/src/memory/hnsw-operations.d.ts.map +1 -0
  267. package/dist/src/memory/hnsw-operations.js +400 -0
  268. package/dist/src/memory/hnsw-operations.js.map +1 -0
  269. package/dist/src/memory/intelligence.d.ts.map +1 -1
  270. package/dist/src/memory/intelligence.js +42 -23
  271. package/dist/src/memory/intelligence.js.map +1 -1
  272. package/dist/src/memory/memory-bridge.d.ts.map +1 -1
  273. package/dist/src/memory/memory-bridge.js +52 -8
  274. package/dist/src/memory/memory-bridge.js.map +1 -1
  275. package/dist/src/memory/memory-crud.d.ts +67 -0
  276. package/dist/src/memory/memory-crud.d.ts.map +1 -0
  277. package/dist/src/memory/memory-crud.js +415 -0
  278. package/dist/src/memory/memory-crud.js.map +1 -0
  279. package/dist/src/memory/memory-initializer.d.ts +9 -322
  280. package/dist/src/memory/memory-initializer.d.ts.map +1 -1
  281. package/dist/src/memory/memory-initializer.js +17 -1794
  282. package/dist/src/memory/memory-initializer.js.map +1 -1
  283. package/dist/src/memory/memory-migrations.d.ts +30 -0
  284. package/dist/src/memory/memory-migrations.d.ts.map +1 -0
  285. package/dist/src/memory/memory-migrations.js +134 -0
  286. package/dist/src/memory/memory-migrations.js.map +1 -0
  287. package/dist/src/memory/memory-read.d.ts +78 -0
  288. package/dist/src/memory/memory-read.d.ts.map +1 -0
  289. package/dist/src/memory/memory-read.js +331 -0
  290. package/dist/src/memory/memory-read.js.map +1 -0
  291. package/dist/src/memory/memory-schema.d.ts +13 -0
  292. package/dist/src/memory/memory-schema.d.ts.map +1 -0
  293. package/dist/src/memory/memory-schema.js +167 -0
  294. package/dist/src/memory/memory-schema.js.map +1 -0
  295. package/dist/src/memory/sona-optimizer.d.ts.map +1 -1
  296. package/dist/src/memory/sona-optimizer.js +37 -4
  297. package/dist/src/memory/sona-optimizer.js.map +1 -1
  298. package/dist/src/monovector/route-outcomes.d.ts.map +1 -1
  299. package/dist/src/monovector/route-outcomes.js +16 -6
  300. package/dist/src/monovector/route-outcomes.js.map +1 -1
  301. package/dist/src/pricing/model-pricing.d.ts +41 -0
  302. package/dist/src/pricing/model-pricing.d.ts.map +1 -0
  303. package/dist/src/pricing/model-pricing.js +61 -0
  304. package/dist/src/pricing/model-pricing.js.map +1 -0
  305. package/dist/src/ui/.monomind/capture/active-run.json +1 -0
  306. package/dist/src/ui/.monomind/orgs/system-trial-qa/runs/real-events-1782290897.convs.jsonl +3 -0
  307. package/dist/src/ui/.monomind/orgs/system-trial-qa/runs/real-events-1782290897.jsonl +11 -0
  308. package/dist/src/ui/.monomind/orgs/system-trial-qa/runs/rigid-qa-restart-1782288201.jsonl +540 -0
  309. package/dist/src/ui/.monomind/orgs/system-trial-qa-threads.jsonl +3 -0
  310. package/dist/src/ui/.monomind/orgs/test-event-fix/runs/rigid-qa-restart-1782288201.jsonl +2 -0
  311. package/dist/src/ui/MODULARIZATION_PLAN.md +79 -0
  312. package/dist/src/ui/collector.mjs +23 -13
  313. package/dist/src/ui/dashboard.html +1653 -14
  314. package/dist/src/ui/data/known-projects.json +1 -0
  315. package/dist/src/ui/data/mastermind-events.jsonl +553 -0
  316. package/dist/src/ui/data/sessions/_index.json +1 -0
  317. package/dist/src/ui/data/sessions/final-sess-001.jsonl +542 -0
  318. package/dist/src/ui/data/unknown-events.jsonl +1 -0
  319. package/dist/src/ui/orgs.html +154 -10
  320. package/dist/src/ui/server.mjs +1162 -168
  321. package/dist/src/ui/sse-manager.mjs +119 -0
  322. package/dist/src/update/checker.js +1 -1
  323. package/dist/src/update/checker.js.map +1 -1
  324. package/dist/tsconfig.tsbuildinfo +1 -1
  325. package/dist/workflow/builtin-handlers.js +321 -0
  326. package/dist/workflow/engine.js +253 -0
  327. package/dist/workflow/expression.js +98 -0
  328. package/dist/workflow/types.js +2 -0
  329. package/package.json +8 -6
@@ -5,6 +5,7 @@ import os from 'os';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { createRequire } from 'module';
7
7
  import { collectAll, getWatchPaths, collectProject, collectSessions, collectSwarm, collectSwarmHistory, appendSwarmHistory, collectSwarmEvents, getSwarmDataSize, cleanSwarmData, collectAgents, collectTokens, collectHooks, collectKnowledge, collectMetrics, collectMemory, collectMemoryFiles, collectSystem } from './collector.mjs';
8
+ import { addSseClient, removeSseClient, broadcast, getSseClientCount, closeSseClients, addMmClient, removeMmClient, broadcastMm, getMmClientCount } from './sse-manager.mjs';
8
9
 
9
10
  const JSONL_SIZE_CAP = 10 * 1024 * 1024; // 10 MB — skip files larger than this in /api/graph
10
11
  const buildDocsState = new Map();
@@ -179,14 +180,96 @@ function pathToSections(filename) {
179
180
  return ['sessions', 'swarm', 'agents', 'tokens', 'hooks'];
180
181
  }
181
182
 
182
- // SSE client registry
183
- const sseClients = new Set();
184
- // Mastermind real-time event stream clients
185
- const mmSseClients = new Set();
183
+ // SSE client registry and mastermind SSE clients are managed by sse-manager.mjs
186
184
  // Active org run tracking: org -> runId (enables event routing for orgs without runId in payload)
187
185
  const activeOrgRuns = new Map();
188
186
  // Active session tracking: org -> {sessionId, ts} (enables linking agent events to sessions)
189
187
  const activeSessionsByOrg = new Map();
188
+ // Phase 3: Per-org SSE clients for run streaming tail endpoint
189
+ const runStreamClients = new Map(); // orgName → Set<res>
190
+
191
+ // Design doc Issue 2: concurrent write safety. Since server.mjs is the sole writer
192
+ // (all hook processes POST via HTTP), in-process serialization is sufficient.
193
+ // SQLite WAL (Issue 2 Phase 1.5): run events are indexed in an in-memory sql.js database
194
+ // with WAL mode and persisted to .monomind/run-events.db every 1000ms. JSONL files are
195
+ // still written (bash lifecycle scripts write them directly), but SQLite is the query layer
196
+ // for streaming tail replay and startup gap-fill.
197
+ //
198
+ // Serializing write queue — prevents concurrent JSONL corruption (Issue 2 from design doc)
199
+ const _writeQueue = new Map(); // filePath → Promise (in-flight write)
200
+
201
+ // ── sql.js WAL run-event index (Phase 1.5) ──────────────────────────────────
202
+ let _runDb = null; // sql.js in-memory Database
203
+ let _runDbPath = null; // disk path for persistence
204
+ let _runDbPersistTimer = null;
205
+ let _runDbInsertStmt = null; // prepared INSERT statement
206
+
207
+ const _require = createRequire(import.meta.url);
208
+
209
+ async function _initRunDb(monoHome) {
210
+ try {
211
+ const initSqlJs = _require('sql.js');
212
+ const SQL = await initSqlJs();
213
+ _runDbPath = path.join(monoHome, '.monomind', 'run-events.db');
214
+ fs.mkdirSync(path.dirname(_runDbPath), { recursive: true });
215
+ let fileData;
216
+ try { fileData = fs.readFileSync(_runDbPath); } catch (_) {}
217
+ _runDb = fileData ? new SQL.Database(fileData) : new SQL.Database();
218
+ _runDb.run('PRAGMA journal_mode=WAL');
219
+ _runDb.run('PRAGMA synchronous=NORMAL');
220
+ _runDb.run(`CREATE TABLE IF NOT EXISTS run_events (
221
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
222
+ org TEXT NOT NULL,
223
+ run_id TEXT NOT NULL,
224
+ type TEXT NOT NULL,
225
+ raw TEXT NOT NULL,
226
+ ts INTEGER NOT NULL,
227
+ source TEXT DEFAULT 'http',
228
+ UNIQUE(org, run_id, ts, type, raw)
229
+ )`);
230
+ _runDb.run('CREATE INDEX IF NOT EXISTS idx_re_org_id ON run_events(org, id)');
231
+ _runDb.run('CREATE INDEX IF NOT EXISTS idx_re_ts ON run_events(ts)');
232
+ _runDbInsertStmt = _runDb.prepare(
233
+ 'INSERT OR IGNORE INTO run_events (org, run_id, type, raw, ts, source) VALUES (?,?,?,?,?,?)'
234
+ );
235
+ // Compact old events at startup: keep last 30 days
236
+ const _cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
237
+ _runDb.run('DELETE FROM run_events WHERE ts < ?', [_cutoff]);
238
+ _persistRunDb();
239
+ } catch (_) {
240
+ _runDb = null; // graceful fallback — JSONL path continues to work
241
+ }
242
+ }
243
+
244
+ function _persistRunDb() {
245
+ if (!_runDb || !_runDbPath) return;
246
+ clearTimeout(_runDbPersistTimer);
247
+ _runDbPersistTimer = setTimeout(() => {
248
+ try { fs.writeFileSync(_runDbPath, Buffer.from(_runDb.export())); } catch (_) {}
249
+ }, 1000);
250
+ }
251
+
252
+ function _insertRunEvent(ev, source) {
253
+ if (!_runDb || !_runDbInsertStmt) return;
254
+ try {
255
+ const org = String(ev.org || '').trim();
256
+ const runId = String(ev.runId || '').trim();
257
+ if (!org || !runId) return;
258
+ _runDbInsertStmt.run([org, runId, String(ev.type || ''), JSON.stringify(ev), Number(ev.ts || Date.now()), source || 'http']);
259
+ _persistRunDb();
260
+ } catch (_) {}
261
+ }
262
+ // ─────────────────────────────────────────────────────────────────────────────
263
+
264
+ function appendToFile(filePath, line) {
265
+ const prev = _writeQueue.get(filePath) || Promise.resolve();
266
+ const next = prev.then(() => {
267
+ try { fs.appendFileSync(filePath, line); } catch (_) {}
268
+ });
269
+ _writeQueue.set(filePath, next);
270
+ next.then(() => { if (_writeQueue.get(filePath) === next) _writeQueue.delete(filePath); });
271
+ return next;
272
+ }
190
273
 
191
274
  // Returns the shared git directory parent so run files survive branch switches and
192
275
  // are shared across all worktrees. In a worktree, .git is a FILE pointing to the
@@ -221,6 +304,124 @@ function _getGitMonomindDir(workDir) {
221
304
  return result;
222
305
  }
223
306
 
307
+ // Returns the monomind home directory for server-level data (capture, control.json, loops).
308
+ // Priority: MONOMIND_HOME env var > walk up from cwd finding .monomind/control.json > cwd fallback
309
+ function getMonomindHome() {
310
+ if (process.env.MONOMIND_HOME) return path.resolve(process.env.MONOMIND_HOME);
311
+ let dir = process.cwd();
312
+ while (dir !== path.dirname(dir)) {
313
+ if (fs.existsSync(path.join(dir, '.monomind', 'control.json'))) return dir;
314
+ dir = path.dirname(dir);
315
+ }
316
+ return process.cwd();
317
+ }
318
+ const MONOMIND_HOME = getMonomindHome();
319
+
320
+ // Resolve an org's project directory by searching across known projects.
321
+ // Returns the first project dir where {dir}/.monomind/orgs/{orgName}.json exists, or null.
322
+ function _resolveOrgProjectDir(orgName, serverRoot) {
323
+ const dirs = new Set([serverRoot]);
324
+ try {
325
+ const kf = path.join(serverRoot, 'data', 'known-projects.json');
326
+ if (fs.existsSync(kf)) JSON.parse(fs.readFileSync(kf, 'utf8')).forEach(p => dirs.add(p));
327
+ } catch(_) {}
328
+ for (const d of dirs) {
329
+ if (fs.existsSync(path.join(d, '.monomind', 'orgs', `${orgName}.json`))) return d;
330
+ }
331
+ return null;
332
+ }
333
+
334
+ // ── Org run state helpers ────────────────────────────────────────────────
335
+ // Reads {name}-runstate.json from disk. Returns null if missing/corrupt.
336
+ function _readRunState(orgName, rootDir) {
337
+ const projDir = _resolveOrgProjectDir(orgName, rootDir) || rootDir;
338
+ const base = _getGitMonomindDir(projDir) || path.join(projDir, '.monomind');
339
+ const file = path.join(base, 'orgs', `${orgName}-runstate.json`);
340
+ if (!fs.existsSync(file)) return null;
341
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { return null; }
342
+ }
343
+
344
+ // Returns the current runId from runstate (for events that omit it after restart).
345
+ function _getActiveRunId(orgName, rootDir) {
346
+ return _readRunState(orgName, rootDir)?.runId || null;
347
+ }
348
+
349
+ // Returns all project dirs allowed for artifact reads (serverRoot + known-projects.json).
350
+ function _getAllowedArtifactDirs(serverRoot) {
351
+ const dirs = [path.resolve(serverRoot)];
352
+ try {
353
+ const kf = path.join(serverRoot, 'data', 'known-projects.json');
354
+ if (fs.existsSync(kf)) JSON.parse(fs.readFileSync(kf, 'utf8')).forEach(p => dirs.push(path.resolve(p)));
355
+ } catch (_) {}
356
+ return dirs;
357
+ }
358
+
359
+ // Detects a basic mime type from file extension for artifact responses.
360
+ function _detectMimeType(filePath) {
361
+ const ext = path.extname(filePath).toLowerCase();
362
+ const map = { '.ts': 'text/typescript', '.js': 'text/javascript', '.mjs': 'text/javascript',
363
+ '.json': 'application/json', '.md': 'text/markdown', '.txt': 'text/plain',
364
+ '.html': 'text/html', '.css': 'text/css', '.py': 'text/x-python',
365
+ '.sh': 'text/x-shellscript', '.yaml': 'text/yaml', '.yml': 'text/yaml',
366
+ '.toml': 'text/plain', '.env': 'text/plain', '.xml': 'text/xml' };
367
+ return map[ext] || 'application/octet-stream';
368
+ }
369
+
370
+ // Writes runstate.json for state-changing events. Debounces lastEventAt for frequent events.
371
+ const _runstateDebouncers = new Map();
372
+ function _updateRunState(event, rootDir) {
373
+ const orgName = String(event.org || '').trim().replace(/[^a-zA-Z0-9_-]/g, '_');
374
+ if (!orgName) return;
375
+ const projDir = _resolveOrgProjectDir(orgName, rootDir) || rootDir;
376
+ const base = _getGitMonomindDir(projDir) || path.join(projDir, '.monomind');
377
+ const orgsDir = path.join(base, 'orgs');
378
+ const file = path.join(orgsDir, `${orgName}-runstate.json`);
379
+ const stateChanging = ['org:start','org:stop','org:agent:online','org:agent:offline'];
380
+ const ts = event.ts || Date.now();
381
+
382
+ if (stateChanging.includes(event.type)) {
383
+ // State-changing: clear any pending debounced write, then write immediately
384
+ const pending = _runstateDebouncers.get(orgName);
385
+ if (pending?.timer) clearTimeout(pending.timer);
386
+ _runstateDebouncers.delete(orgName);
387
+ let cur = null;
388
+ try { cur = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {}; } catch (_) { cur = {}; }
389
+ if (event.type === 'org:start') {
390
+ cur.runId = event.runId || cur.runId;
391
+ cur.status = 'running';
392
+ cur.startedAt = ts;
393
+ cur.checkpointInterval = event.checkpointInterval || 600000;
394
+ cur.agentStates = {};
395
+ } else if (event.type === 'org:stop') {
396
+ cur.status = 'idle';
397
+ } else if (event.type === 'org:agent:online') {
398
+ cur.agentStates = cur.agentStates || {};
399
+ cur.agentStates[String(event.from || '').trim()] = { status: 'active', lastSeen: ts };
400
+ } else if (event.type === 'org:agent:offline') {
401
+ if (cur.agentStates?.[String(event.from || '').trim()]) {
402
+ cur.agentStates[String(event.from).trim()].status = 'idle';
403
+ }
404
+ }
405
+ cur.lastEventAt = ts;
406
+ try { fs.mkdirSync(orgsDir, { recursive: true }); fs.writeFileSync(file, JSON.stringify(cur, null, 2)); } catch (_) {}
407
+ } else {
408
+ // Frequent event: debounce lastEventAt write by 5s
409
+ const existing = _runstateDebouncers.get(orgName);
410
+ if (existing?.timer) clearTimeout(existing.timer);
411
+ const timer = setTimeout(() => {
412
+ _runstateDebouncers.delete(orgName);
413
+ try {
414
+ if (!fs.existsSync(file)) return;
415
+ const rs = JSON.parse(fs.readFileSync(file, 'utf8'));
416
+ rs.lastEventAt = Date.now();
417
+ fs.writeFileSync(file, JSON.stringify(rs, null, 2));
418
+ } catch (_) {}
419
+ }, 5000);
420
+ _runstateDebouncers.set(orgName, { timer });
421
+ }
422
+ }
423
+ // ── End runstate helpers ─────────────────────────────────────────────────
424
+
224
425
  // Server state
225
426
  let running = false;
226
427
  let currentPort = null;
@@ -228,20 +429,7 @@ let currentUrl = null;
228
429
  let activeServer = null;
229
430
  const activeWatchers = [];
230
431
 
231
- /**
232
- * Broadcasts a data payload to all connected SSE clients.
233
- * Silently removes clients that have disconnected.
234
- */
235
- function broadcast(data) {
236
- const msg = `data: ${JSON.stringify(data)}\n\n`;
237
- for (const client of sseClients) {
238
- try {
239
- client.write(msg);
240
- } catch {
241
- sseClients.delete(client);
242
- }
243
- }
244
- }
432
+ // broadcast() is imported from sse-manager.mjs
245
433
 
246
434
  /**
247
435
  * Opens a URL in the default browser, cross-platform.
@@ -375,6 +563,20 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
375
563
  let event = {};
376
564
  try { event = JSON.parse(body); } catch (_) {}
377
565
  event.ts = event.ts || Date.now();
566
+ // Event type validation: accept any {scope}:{action} pattern — future event types
567
+ // auto-work without whitelist maintenance. Malformed types are logged and rejected.
568
+ if (event.type != null) {
569
+ if (typeof event.type !== 'string' || !/^[a-z][a-z0-9-]*:[a-z][a-z0-9:-]*$/.test(event.type)) {
570
+ try {
571
+ const _badLog = path.join(projectDir || process.cwd(), 'data', 'unknown-events.jsonl');
572
+ fs.mkdirSync(path.dirname(_badLog), { recursive: true });
573
+ fs.appendFileSync(_badLog, JSON.stringify({ ts: Date.now(), type: event.type, body: body.slice(0, 256) }) + '\n');
574
+ } catch (_) {}
575
+ res.writeHead(400, { 'Content-Type': 'application/json' });
576
+ res.end(JSON.stringify({ ok: false, error: 'invalid event type' }));
577
+ return;
578
+ }
579
+ }
378
580
  // Use project path from event if provided (multi-project support).
379
581
  // Security: path.isAbsolute() alone is insufficient — an attacker can
380
582
  // supply event.project="/etc" and cause writes to system directories.
@@ -387,7 +589,7 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
387
589
  && path.isAbsolute(_rawProject)) {
388
590
  // Reject filesystem root and common system directories
389
591
  const _norm = path.resolve(_rawProject);
390
- const _systemPaths = ['/', '/etc', '/usr', '/bin', '/sbin', '/lib', '/lib64', '/boot', '/dev', '/sys', '/proc', '/tmp'];
592
+ const _systemPaths = ['/', '/etc', '/usr', '/bin', '/sbin', '/lib', '/lib64', '/boot', '/dev', '/sys', '/proc', '/tmp', os.tmpdir(), (() => { try { return fs.realpathSync(os.tmpdir()); } catch (_) { return ''; } })()].filter(Boolean);
391
593
  if (!_systemPaths.includes(_norm) && !_systemPaths.some(p => _norm.startsWith(p + '/'))) {
392
594
  eventProject = _norm;
393
595
  }
@@ -413,20 +615,38 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
413
615
  // Any event with both org+runId updates the active run map (run:start written directly to file so org:start is first via curl)
414
616
  if (event.runId) activeOrgRuns.set(_orgKey, String(event.runId).trim());
415
617
  else if (activeOrgRuns.has(_orgKey)) event.runId = activeOrgRuns.get(_orgKey);
416
- if (event.type === 'run:complete' || event.type === 'org:complete') activeOrgRuns.delete(_orgKey);
417
- // Persist active-run.json so capture-handler.cjs can find the current org/runId without HTTP calls
618
+ else { const _rsId = _getActiveRunId(_orgKey, root); if (_rsId) event.runId = _rsId; }
619
+ if (event.type === 'run:complete' || event.type === 'org:complete' || event.type === 'org:stop') activeOrgRuns.delete(_orgKey);
620
+ // Persist active-run.json so capture-handler.cjs can find the current org/runId without HTTP calls.
621
+ // Use process.cwd() (server's own dir, same as CLAUDE_PROJECT_DIR in the session) — not root (org project dir),
622
+ // because capture-handler.cjs reads from CLAUDE_PROJECT_DIR which is the server's working directory.
418
623
  try {
419
- const _captureDir = path.join(root, '.monomind', 'capture');
624
+ const _captureDir = path.join(MONOMIND_HOME, '.monomind', 'capture');
420
625
  const _activeRunFile = path.join(_captureDir, 'active-run.json');
421
- if (event.type === 'run:start' && event.org && event.runId) {
626
+ if ((event.type === 'run:start' || event.type === 'org:start') && event.org && event.runId) {
422
627
  fs.mkdirSync(_captureDir, { recursive: true });
423
628
  fs.writeFileSync(_activeRunFile, JSON.stringify({ org: String(event.org).trim(), runId: String(event.runId).trim(), ts: Date.now() }));
424
- } else if ((event.type === 'run:complete' || event.type === 'org:complete') && fs.existsSync(_activeRunFile)) {
629
+ } else if ((event.type === 'run:complete' || event.type === 'org:complete' || event.type === 'org:stop') && fs.existsSync(_activeRunFile)) {
425
630
  fs.unlinkSync(_activeRunFile);
631
+ // Phase 1: Clean up ppid-keyed files for this org (Issue 3)
632
+ try {
633
+ const _ppidDir = path.join(_captureDir, 'active-runs');
634
+ const _completedOrg = String(event.org || '').trim();
635
+ if (_completedOrg && fs.existsSync(_ppidDir)) {
636
+ fs.readdirSync(_ppidDir).filter(f => f.endsWith('.json')).forEach(_pf => {
637
+ try {
638
+ const _pData = JSON.parse(fs.readFileSync(path.join(_ppidDir, _pf), 'utf8'));
639
+ if (_pData.org === _completedOrg) fs.unlinkSync(path.join(_ppidDir, _pf));
640
+ } catch (_) {}
641
+ });
642
+ }
643
+ } catch (_e) {}
426
644
  }
427
645
  } catch(_e) {}
428
646
  }
429
- try { fs.appendFileSync(path.join(dataDir, 'mastermind-events.jsonl'), JSON.stringify(event) + '\n'); } catch (_) {}
647
+ // Update durable runstate.json survives server restarts
648
+ if (event.org) _updateRunState(event, root);
649
+ appendToFile(path.join(dataDir, 'mastermind-events.jsonl'), JSON.stringify(event) + '\n').catch(() => {});
430
650
  // Persist to git-safe run file (survives branch switches + shared across worktrees)
431
651
  if (event.org && event.runId) {
432
652
  try {
@@ -437,7 +657,8 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
437
657
  const _monoDir = _getGitMonomindDir(root) || path.join(root, '.monomind');
438
658
  const _runDir = path.join(_monoDir, 'orgs', _orn, 'runs');
439
659
  fs.mkdirSync(_runDir, { recursive: true });
440
- fs.appendFileSync(path.join(_runDir, `${_rid}.jsonl`), JSON.stringify(event) + '\n');
660
+ await appendToFile(path.join(_runDir, `${_rid}.jsonl`), JSON.stringify(event) + '\n');
661
+ _insertRunEvent(event, 'http');
441
662
  // agent:usage — persist per-role token/cost data to state.json (accumulated across runs)
442
663
  if (event.type === 'agent:usage' && event.role) {
443
664
  try {
@@ -462,11 +683,46 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
462
683
  // Solution 3: dedicated conversation log — org:comms only, for easy replay
463
684
  if (event.type === 'org:comms') {
464
685
  const _conv = { ts: event.ts, run_id: _rid, from: event.from, to: event.to, msg: event.msg };
465
- fs.appendFileSync(path.join(_runDir, `${_rid}.convs.jsonl`), JSON.stringify(_conv) + '\n');
686
+ await appendToFile(path.join(_runDir, `${_rid}.convs.jsonl`), JSON.stringify(_conv) + '\n');
466
687
  // Also write to org-level threads.jsonl so the dashboard Threads tab shows agent conversations
467
688
  const _orgThreadsFile = path.join(root, '.monomind', 'orgs', `${_orn}-threads.jsonl`);
468
689
  const _thread = { type: 'message', id: `${_rid}-${event.ts}`, run_id: _rid, ts: event.ts, from: event.from, to: event.to, msg: event.msg, subject: `Run ${_rid}` };
469
- try { fs.appendFileSync(_orgThreadsFile, JSON.stringify(_thread) + '\n'); } catch(_) {}
690
+ appendToFile(_orgThreadsFile, JSON.stringify(_thread) + '\n').catch(() => {});
691
+ }
692
+ // Phase 4: Compact completed run to three-tier retention (Issue 7)
693
+ // hot (SQLite JSONL in .monomind) → warm (flat JSONL in archive/) → cold (gzip)
694
+ // We use a lightweight approach: rename completed JSONL to .warm.jsonl, then gzip runs
695
+ // older than 24 hours to .cold.jsonl.gz — no external deps.
696
+ if (event.type === 'run:complete' || event.type === 'org:complete') {
697
+ setImmediate(() => {
698
+ try {
699
+ const _hotFile = path.join(_runDir, `${_rid}.jsonl`);
700
+ const _warmFile = path.join(_runDir, `${_rid}.warm.jsonl`);
701
+ // Promote: hot → warm (just rename — same dir, marks run as done)
702
+ if (fs.existsSync(_hotFile) && !fs.existsSync(_warmFile)) {
703
+ fs.renameSync(_hotFile, _warmFile);
704
+ }
705
+ // Compact warm files older than 24h to cold gzip
706
+ const _24h = 24 * 60 * 60 * 1000;
707
+ fs.readdirSync(_runDir).filter(f => f.endsWith('.warm.jsonl')).forEach(_wf => {
708
+ const _wp = path.join(_runDir, _wf);
709
+ try {
710
+ if (Date.now() - fs.statSync(_wp).mtimeMs < _24h) return;
711
+ const _coldPath = _wp.replace('.warm.jsonl', '.cold.jsonl.gz');
712
+ if (fs.existsSync(_coldPath)) return; // already compacted
713
+ const _warmData = fs.readFileSync(_wp);
714
+ const zlib = require('zlib');
715
+ zlib.gzip(_warmData, (_err, _gz) => {
716
+ if (_err) return;
717
+ try {
718
+ fs.writeFileSync(_coldPath, _gz);
719
+ fs.unlinkSync(_wp); // remove warm after cold written
720
+ } catch (_) {}
721
+ });
722
+ } catch (_) {}
723
+ });
724
+ } catch (_) {}
725
+ });
470
726
  }
471
727
  }
472
728
  } catch (_) {}
@@ -498,11 +754,11 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
498
754
  // Format: data/sessions/<sessionId>.jsonl + data/sessions/_index.json
499
755
  try {
500
756
  const _sid = String(event.session || '').trim();
501
- if (_sid.length > 0 && _sid.length <= 128 && /^[a-zA-Z0-9_.-]+$/.test(_sid)) {
757
+ if (_sid.length > 0 && _sid.length <= 128 && /^(?!.*\.\.)[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/.test(_sid)) {
502
758
  const sessDir = path.join(dataDir, 'sessions');
503
759
  fs.mkdirSync(sessDir, { recursive: true });
504
760
  // Append event to per-session JSONL (O(1), no read)
505
- fs.appendFileSync(path.join(sessDir, `${_sid}.jsonl`), JSON.stringify(event) + '\n');
761
+ appendToFile(path.join(sessDir, `${_sid}.jsonl`), JSON.stringify(event) + '\n').catch(() => {});
506
762
  // Update lightweight index (id, ts, prompt, status, org, startedAt, endedAt, domains only)
507
763
  const indexFile = path.join(sessDir, '_index.json');
508
764
  let _idx = [];
@@ -548,20 +804,36 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
548
804
  fs.writeFileSync(sessFile, JSON.stringify(sessions.slice(0, 500)));
549
805
  } catch (_) {}
550
806
  // For org:stop events, write a stop marker the boss agent can detect
551
- if (event.type === 'org:stop' && event.org) {
807
+ // For org:start events, remove any existing stop marker so the org shows as running again
808
+ if ((event.type === 'org:stop' || event.type === 'org:start') && event.org) {
552
809
  try {
553
810
  const orgName = String(event.org).trim();
554
811
  // Validate before any filesystem use — reject rather than strip
555
812
  if (orgName.length > 0 && orgName.length <= 64 && /^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) {
556
813
  const stopDir = path.join(root, '.monomind', 'orgs', '.stops');
557
- fs.mkdirSync(stopDir, { recursive: true });
558
- fs.writeFileSync(path.join(stopDir, `${orgName}.stop`), String(Date.now()));
814
+ if (event.type === 'org:stop') {
815
+ fs.mkdirSync(stopDir, { recursive: true });
816
+ fs.writeFileSync(path.join(stopDir, `${orgName}.stop`), String(Date.now()));
817
+ } else {
818
+ // org:start — remove stop file so the org can appear running
819
+ try { fs.unlinkSync(path.join(stopDir, `${orgName}.stop`)); } catch (_) {}
820
+ }
559
821
  }
560
822
  } catch (_) {}
561
823
  }
562
824
  // Broadcast to all mastermind SSE clients
563
- const msg = `data: ${JSON.stringify(event)}\n\n`;
564
- for (const c of mmSseClients) { try { c.write(msg); } catch (_) { mmSseClients.delete(c); } }
825
+ broadcastMm(event);
826
+ // Phase 3: Forward to per-org streaming tail clients
827
+ if (event.org) {
828
+ const _fwdOrg = String(event.org).trim();
829
+ const _fwdClients = runStreamClients.get(_fwdOrg);
830
+ if (_fwdClients && _fwdClients.size > 0) {
831
+ const _fwdLine = `data: ${JSON.stringify(event)}\n\n`;
832
+ for (const _fwdClient of _fwdClients) {
833
+ try { _fwdClient.write(_fwdLine); } catch (_) { _fwdClients.delete(_fwdClient); }
834
+ }
835
+ }
836
+ }
565
837
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
566
838
  res.end('{"ok":true}');
567
839
  }
@@ -3293,11 +3565,11 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
3293
3565
  }
3294
3566
  }, 20_000);
3295
3567
 
3296
- sseClients.add(res);
3568
+ addSseClient(res);
3297
3569
 
3298
3570
  req.on('close', () => {
3299
3571
  clearInterval(keepAlive);
3300
- sseClients.delete(res);
3572
+ removeSseClient(res);
3301
3573
  });
3302
3574
 
3303
3575
  // Send the initial snapshot immediately
@@ -3386,44 +3658,36 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
3386
3658
  if (req.method === 'GET' && url === '/api/orgs') {
3387
3659
  try {
3388
3660
  const _orgsQs = new URL(req.url, 'http://localhost').searchParams;
3389
- const orgsDir = path.join(path.resolve(_orgsQs.get('dir') || projectDir || process.cwd()), '.monomind', 'orgs');
3390
- let orgs = [];
3391
- if (fs.existsSync(orgsDir)) {
3392
- const _sidecarSuffixRe = /-(approvals|state|activity|goals|routines|projects|members|issues|workspaces|worktrees|environments|plugins|adapters|bootstrap|threads|budgets|project-workspaces|approval-comments|secrets|join-requests|skills)\.json$/;
3393
- const files = fs.readdirSync(orgsDir).filter(f => f.endsWith('.json') && !_sidecarSuffixRe.test(f));
3394
- // Read events file once, outside the per-org loop
3395
- let recentLines = [];
3661
+ const _orgsExplicitDir = _orgsQs.get('dir');
3662
+ const _orgsServerRoot = path.resolve(_orgsExplicitDir || projectDir || process.cwd());
3663
+ // Collect project dirs to search: explicit dir + known-projects (like sessions API)
3664
+ const _orgsProjDirs = new Set([_orgsServerRoot]);
3665
+ if (!_orgsExplicitDir) {
3396
3666
  try {
3397
- const evFile = path.join(path.resolve(_orgsQs.get('dir') || projectDir || process.cwd()), 'data', 'mastermind-events.jsonl');
3398
- if (fs.existsSync(evFile)) {
3399
- // Read only the last 64 KB to bound cost on large files
3400
- const stat = fs.statSync(evFile);
3401
- const TAIL = 65536;
3402
- const fd = fs.openSync(evFile, 'r');
3403
- const buf = Buffer.alloc(Math.min(TAIL, stat.size));
3404
- try {
3405
- fs.readSync(fd, buf, 0, buf.length, Math.max(0, stat.size - buf.length));
3406
- } finally {
3407
- fs.closeSync(fd);
3408
- }
3409
- recentLines = buf.toString('utf8').split('\n').filter(Boolean).reverse();
3667
+ const _knownOrgsFile = path.join(_orgsServerRoot, 'data', 'known-projects.json');
3668
+ if (fs.existsSync(_knownOrgsFile)) {
3669
+ JSON.parse(fs.readFileSync(_knownOrgsFile, 'utf8')).forEach(p => _orgsProjDirs.add(p));
3410
3670
  }
3411
3671
  } catch(_) {}
3672
+ }
3673
+ const _sidecarSuffixRe = /-(approvals|state|activity|goals|routines|projects|members|issues|workspaces|worktrees|environments|plugins|adapters|bootstrap|threads|budgets|project-workspaces|approval-comments|secrets|join-requests|skills)\.json$/;
3674
+ const _orgsSeen = new Set();
3675
+ let orgs = [];
3676
+ for (const _opd of _orgsProjDirs) {
3677
+ const orgsDir = path.join(_opd, '.monomind', 'orgs');
3678
+ if (!fs.existsSync(orgsDir)) continue;
3679
+ const files = fs.readdirSync(orgsDir).filter(f => f.endsWith('.json') && !_sidecarSuffixRe.test(f));
3412
3680
  for (const f of files) {
3413
3681
  try {
3414
3682
  const cfg = JSON.parse(fs.readFileSync(path.join(orgsDir, f), 'utf8'));
3415
- let running = false;
3416
- const lastStart = recentLines.find(l => { try { const e = JSON.parse(l); return e.type === 'org:start' && e.org === cfg.name; } catch(_) { return false; } });
3417
- const lastStop = recentLines.find(l => { try { const e = JSON.parse(l); return (e.type === 'org:stop' || e.type === 'org:complete') && e.org === cfg.name; } catch(_) { return false; } });
3418
- if (lastStart) {
3419
- const startTs = JSON.parse(lastStart).ts || 0;
3420
- const stopTs = lastStop ? (JSON.parse(lastStop).ts || 0) : 0;
3421
- running = startTs > stopTs;
3422
- }
3423
- // Also check in-memory activeOrgRuns so the list reflects LIVE immediately after launch
3424
3683
  const _lOrgName = cfg.name || '';
3425
- if (!running && _lOrgName && activeOrgRuns.has(_lOrgName)) running = true;
3426
- orgs.push({ name: cfg.name, goal: cfg.goal, roles: Array.isArray(cfg.roles) ? cfg.roles : [], topology: cfg.topology, created_at: cfg.created_at, running, status: cfg.status, loop: cfg.loop ? { poll_interval_minutes: cfg.loop.poll_interval_minutes, last_run: cfg.loop.last_run, next_run: cfg.loop.next_run } : undefined });
3684
+ if (!_lOrgName || _orgsSeen.has(_lOrgName)) continue;
3685
+ _orgsSeen.add(_lOrgName);
3686
+ const _rs = _readRunState(_lOrgName, _opd);
3687
+ const _ttl = Math.max((_rs?.checkpointInterval || 600000) * 2, 7200000);
3688
+ let running = (_rs?.status === 'running' && (Date.now() - (_rs?.lastEventAt || 0)) < _ttl)
3689
+ || activeOrgRuns.has(_lOrgName);
3690
+ orgs.push({ name: cfg.name, goal: cfg.goal, roles: Array.isArray(cfg.roles) ? cfg.roles : [], topology: cfg.topology, created_at: cfg.created_at, running, status: cfg.status, projectDir: _opd, lastEventAt: _rs?.lastEventAt || null, loop: cfg.loop ? { poll_interval_minutes: cfg.loop.poll_interval_minutes, last_run: cfg.loop.last_run, next_run: cfg.loop.next_run } : undefined });
3427
3691
  } catch(_) {}
3428
3692
  }
3429
3693
  }
@@ -3491,7 +3755,9 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
3491
3755
  const orgName = decodeURIComponent(url.slice('/api/orgs/'.length));
3492
3756
  if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return; }
3493
3757
  const _orgsOneQs = new URL(req.url, 'http://localhost').searchParams;
3494
- const f = path.join(path.resolve(_orgsOneQs.get('dir') || projectDir || process.cwd()), '.monomind', 'orgs', `${orgName}.json`);
3758
+ const _orgsOneRoot = path.resolve(_orgsOneQs.get('dir') || projectDir || process.cwd());
3759
+ const _orgsOneProjDir = _resolveOrgProjectDir(orgName, _orgsOneRoot) || _orgsOneRoot;
3760
+ const f = path.join(_orgsOneProjDir, '.monomind', 'orgs', `${orgName}.json`);
3495
3761
  if (!fs.existsSync(f)) { res.writeHead(404); res.end('{"error":"not found"}'); return; }
3496
3762
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
3497
3763
  res.end(fs.readFileSync(f, 'utf8'));
@@ -3505,7 +3771,9 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
3505
3771
  const orgName = decodeURIComponent(url.slice('/api/org/'.length));
3506
3772
  if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return; }
3507
3773
  const _orgQs = new URL(req.url, 'http://localhost').searchParams;
3508
- const d = path.resolve(_orgQs.get('dir') || projectDir || process.cwd());
3774
+ const _orgServerRoot = path.resolve(_orgQs.get('dir') || projectDir || process.cwd());
3775
+ // Resolve which project dir actually has this org's config
3776
+ const d = _resolveOrgProjectDir(orgName, _orgServerRoot) || _orgServerRoot;
3509
3777
  const orgsDir = path.join(d, '.monomind', 'orgs');
3510
3778
 
3511
3779
  const readJsonSafe = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch(_) { return null; } };
@@ -3530,7 +3798,7 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
3530
3798
  try { return fs.statSync(path.join(orgsDir, `${orgName}-state.json`)).mtimeMs; } catch { return 0; }
3531
3799
  })();
3532
3800
  // Also check org's most recent run file mtime
3533
- const orgRunsDir = path.join(d, '.monomind', 'orgs', orgName, 'runs');
3801
+ const orgRunsDir = path.join(_getGitMonomindDir(d) || path.join(d, '.monomind'), 'orgs', orgName, 'runs');
3534
3802
  const orgLastRunMtime = (() => {
3535
3803
  try {
3536
3804
  if (!fs.existsSync(orgRunsDir)) return 0;
@@ -3557,7 +3825,10 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
3557
3825
  });
3558
3826
  } catch { return false; }
3559
3827
  })();
3560
- const running = !fs.existsSync(stopFile) && (activeOrgRuns.has(orgName) || ['running','active'].includes(state.status) || Object.values(state.agents || {}).some(a => a.status === 'running') || _loopRunning);
3828
+ const _runstateData = _readRunState(orgName, d);
3829
+ const _runstateTtl = Math.max((_runstateData?.checkpointInterval || 600000) * 2, 7200000);
3830
+ const _runstateAlive = _runstateData?.status === 'running' && (Date.now() - (_runstateData?.lastEventAt || 0)) < _runstateTtl;
3831
+ const running = !fs.existsSync(stopFile) && (_runstateAlive || activeOrgRuns.has(orgName) || _loopRunning);
3561
3832
 
3562
3833
  // Read real tasks from the task store and group by status column
3563
3834
  const taskStoreData = readJsonSafe(path.join(d, '.monomind', 'tasks', 'store.json'));
@@ -3569,7 +3840,10 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
3569
3840
  };
3570
3841
 
3571
3842
  const result = { config, state, goals: goalsData.goals, routines: routinesData.routines,
3572
- approvals: approvalsData.approvals, running, tasks };
3843
+ approvals: approvalsData.approvals, running, tasks,
3844
+ runId: _runstateData?.runId || null,
3845
+ lastEventAt: _runstateData?.lastEventAt || null,
3846
+ agentStates: _runstateData?.agentStates || {} };
3573
3847
 
3574
3848
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
3575
3849
  res.end(JSON.stringify(result));
@@ -3584,7 +3858,8 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
3584
3858
  const orgName = decodeURIComponent(parts[3]);
3585
3859
  if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('[]'); return; }
3586
3860
  const _actQs = new URL(req.url, 'http://localhost').searchParams;
3587
- const d = path.resolve(_actQs.get('dir') || projectDir || process.cwd());
3861
+ const _actServerRoot = path.resolve(_actQs.get('dir') || projectDir || process.cwd());
3862
+ const d = _resolveOrgProjectDir(orgName, _actServerRoot) || _actServerRoot;
3588
3863
  const orgsDir = path.join(d, '.monomind', 'orgs');
3589
3864
  const readJ = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch(_) { return null; } };
3590
3865
  const events = [];
@@ -3672,7 +3947,8 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
3672
3947
  const orgName = decodeURIComponent(parts[3]);
3673
3948
  if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('{}'); return; }
3674
3949
  const _adaptersQs = new URL(req.url, 'http://localhost').searchParams;
3675
- const d = path.resolve(_adaptersQs.get('dir') || projectDir || process.cwd());
3950
+ const _adaptersRoot = path.resolve(_adaptersQs.get('dir') || projectDir || process.cwd());
3951
+ const d = _resolveOrgProjectDir(orgName, _adaptersRoot) || _adaptersRoot;
3676
3952
  const adaptersFile = path.join(d, '.monomind', 'orgs', `${orgName}-adapters.json`);
3677
3953
  if (!fs.existsSync(adaptersFile)) {
3678
3954
  // Return defaults derived from org config if available
@@ -4248,9 +4524,8 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4248
4524
  fs.renameSync(tmp, approvalsFile);
4249
4525
  // Emit org:approval:resolved event so boss agent unblocks
4250
4526
  const event = { type: 'org:approval:resolved', org: orgName, approval_id: approvalId, status, ts: Date.now() };
4251
- try { fs.appendFileSync(path.join(path.resolve(_postApprovalsQs.get('dir') || projectDir || process.cwd()), 'data', 'mastermind-events.jsonl'), JSON.stringify(event) + '\n'); } catch(_) {}
4252
- const msg = `data: ${JSON.stringify(event)}\n\n`;
4253
- for (const c of mmSseClients) { try { c.write(msg); } catch(_) { mmSseClients.delete(c); } }
4527
+ appendToFile(path.join(path.resolve(_postApprovalsQs.get('dir') || projectDir || process.cwd()), 'data', 'mastermind-events.jsonl'), JSON.stringify(event) + '\n').catch(() => {});
4528
+ broadcastMm(event);
4254
4529
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
4255
4530
  res.end(JSON.stringify({ ok: true, status }));
4256
4531
  } catch(_) { res.writeHead(500); res.end('{}'); }
@@ -4368,7 +4643,9 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4368
4643
  const orgName = decodeURIComponent(url.split('/')[3]);
4369
4644
  if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return; }
4370
4645
  const _threadsQs = new URL(req.url, 'http://localhost').searchParams;
4371
- const threadsFile = path.join(path.resolve(_threadsQs.get('dir') || projectDir || process.cwd()), '.monomind', 'orgs', `${orgName}-threads.jsonl`);
4646
+ const _threadsRoot = path.resolve(_threadsQs.get('dir') || projectDir || process.cwd());
4647
+ const _threadsProjDir = _resolveOrgProjectDir(orgName, _threadsRoot) || _threadsRoot;
4648
+ const threadsFile = path.join(_threadsProjDir, '.monomind', 'orgs', `${orgName}-threads.jsonl`);
4372
4649
  let threads = [];
4373
4650
  try {
4374
4651
  const lines = fs.readFileSync(threadsFile, 'utf8').split('\n').filter(l => l.trim());
@@ -4429,7 +4706,9 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4429
4706
  const orgName = decodeURIComponent(url.split('/')[3]);
4430
4707
  if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return; }
4431
4708
  const _goalsQs = new URL(req.url, 'http://localhost').searchParams;
4432
- const goalsFile = path.join(path.resolve(_goalsQs.get('dir') || projectDir || process.cwd()), '.monomind', 'orgs', `${orgName}-goals.json`);
4709
+ const _goalsRoot = path.resolve(_goalsQs.get('dir') || projectDir || process.cwd());
4710
+ const _goalsProjDir = _resolveOrgProjectDir(orgName, _goalsRoot) || _goalsRoot;
4711
+ const goalsFile = path.join(_goalsProjDir, '.monomind', 'orgs', `${orgName}-goals.json`);
4433
4712
  let data = { goals: [] };
4434
4713
  try { data = JSON.parse(fs.readFileSync(goalsFile, 'utf8')); } catch(_) {}
4435
4714
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
@@ -4445,13 +4724,14 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4445
4724
  if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return; }
4446
4725
  const _routinesQs = new URL(req.url, 'http://localhost').searchParams;
4447
4726
  const _routinesBase = path.resolve(_routinesQs.get('dir') || projectDir || process.cwd());
4448
- const routinesFile = path.join(_routinesBase, '.monomind', 'orgs', `${orgName}-routines.json`);
4727
+ const _routinesProjDir = _resolveOrgProjectDir(orgName, _routinesBase) || _routinesBase;
4728
+ const routinesFile = path.join(_routinesProjDir, '.monomind', 'orgs', `${orgName}-routines.json`);
4449
4729
  let data = { routines: [] };
4450
4730
  try { data = JSON.parse(fs.readFileSync(routinesFile, 'utf8')); } catch(_) {}
4451
4731
  // Synthesize routines from org config's loop/schedule settings when no explicit routines are defined
4452
4732
  if (!data.routines || !data.routines.length) {
4453
4733
  try {
4454
- const orgCfg = JSON.parse(fs.readFileSync(path.join(_routinesBase, '.monomind', 'orgs', `${orgName}.json`), 'utf8'));
4734
+ const orgCfg = JSON.parse(fs.readFileSync(path.join(_routinesProjDir, '.monomind', 'orgs', `${orgName}.json`), 'utf8'));
4455
4735
  const loop = orgCfg.loop;
4456
4736
  if (loop && (loop.poll_interval_minutes || loop.interval_minutes)) {
4457
4737
  const intervalMin = loop.poll_interval_minutes || loop.interval_minutes;
@@ -4643,9 +4923,8 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4643
4923
  try { const lpf = path.join(path.resolve(projectDir || process.cwd()), '.monomind', 'loops', `${orgName}.md`); if (fs.existsSync(lpf)) fs.unlinkSync(lpf); } catch(_) {}
4644
4924
  // Emit org:delete event
4645
4925
  const deleteEvent = { type: 'org:delete', org: orgName, ts: Date.now() };
4646
- try { fs.appendFileSync(path.join(projectDir || process.cwd(), 'data', 'mastermind-events.jsonl'), JSON.stringify(deleteEvent) + '\n'); } catch(_) {}
4647
- const msg = `data: ${JSON.stringify(deleteEvent)}\n\n`;
4648
- for (const c of mmSseClients) { try { c.write(msg); } catch(_) { mmSseClients.delete(c); } }
4926
+ appendToFile(path.join(projectDir || process.cwd(), 'data', 'mastermind-events.jsonl'), JSON.stringify(deleteEvent) + '\n').catch(() => {});
4927
+ broadcastMm(deleteEvent);
4649
4928
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
4650
4929
  res.end('{"ok":true}');
4651
4930
  } catch(_) { res.writeHead(500); res.end('{}'); }
@@ -4662,15 +4941,14 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4662
4941
  const stopEvent = { type: 'org:stop', org: orgName, ts: Date.now() };
4663
4942
  const dataDir = path.join(_stopOrgBase, 'data');
4664
4943
  try { fs.mkdirSync(dataDir, { recursive: true }); } catch(_) {}
4665
- try { fs.appendFileSync(path.join(dataDir, 'mastermind-events.jsonl'), JSON.stringify(stopEvent) + '\n'); } catch(_) {}
4944
+ appendToFile(path.join(dataDir, 'mastermind-events.jsonl'), JSON.stringify(stopEvent) + '\n').catch(() => {});
4666
4945
  // Write stop marker file for boss agent to detect
4667
4946
  try {
4668
4947
  const stopDir = path.join(_stopOrgBase, '.monomind', 'orgs', '.stops');
4669
4948
  fs.mkdirSync(stopDir, { recursive: true });
4670
4949
  fs.writeFileSync(path.join(stopDir, `${orgName}.stop`), String(Date.now()));
4671
4950
  } catch(_) {}
4672
- const msg = `data: ${JSON.stringify(stopEvent)}\n\n`;
4673
- for (const c of mmSseClients) { try { c.write(msg); } catch(_) { mmSseClients.delete(c); } }
4951
+ broadcastMm(stopEvent);
4674
4952
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
4675
4953
  res.end('{"ok":true}');
4676
4954
  } catch(_) { res.writeHead(500); res.end('{}'); }
@@ -4708,33 +4986,77 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4708
4986
  try {
4709
4987
  const _rQs = new URL(req.url, 'http://localhost').searchParams;
4710
4988
  const _rOrgName = decodeURIComponent(url.split('/')[3] || '');
4711
- const _rWorkDir = path.resolve(_rQs.get('dir') || projectDir || process.cwd());
4712
- const _rMonoDir = _getGitMonomindDir(_rWorkDir) || path.join(_rWorkDir, '.monomind');
4713
- const _rDir = path.join(_rMonoDir, 'orgs', _rOrgName, 'runs');
4989
+ if (_rOrgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(_rOrgName)) { res.writeHead(400); res.end('{"error":"Invalid org name"}'); return; }
4990
+ const _rExplicitDir = _rQs.get('dir');
4991
+ const _rServerRoot = path.resolve(_rExplicitDir || projectDir || process.cwd());
4992
+ // Search across known projects (same logic as /api/orgs) unless explicit dir given
4993
+ const _rProjDirs = new Set([_rServerRoot]);
4994
+ if (!_rExplicitDir) {
4995
+ try {
4996
+ const _rKnown = JSON.parse(fs.readFileSync(path.join(_rServerRoot, 'data', 'known-projects.json'), 'utf8'));
4997
+ _rKnown.forEach(p => _rProjDirs.add(p));
4998
+ } catch(_) {}
4999
+ }
5000
+ const _rSeenFiles = new Set();
4714
5001
  const runs = [];
4715
- if (fs.existsSync(_rDir)) {
4716
- const files = fs.readdirSync(_rDir).filter(f => f.endsWith('.jsonl')).sort().reverse();
4717
- for (const f of files.slice(0, 50)) {
4718
- try {
4719
- const raw = fs.readFileSync(path.join(_rDir, f), 'utf8');
4720
- const allLines = raw.split('\n').filter(Boolean);
4721
- const eventCount = allLines.length;
4722
- const parse = l => { try { return JSON.parse(l); } catch { return null; } };
4723
- const headEvents = allLines.slice(0, 10).map(parse).filter(Boolean);
4724
- const tailEvents = allLines.slice(-5).map(parse).filter(Boolean);
4725
- const first = headEvents.find(e => e.type === 'run:start') || headEvents[0];
4726
- const last = tailEvents.slice().reverse().find(e => e.type === 'run:complete' || e.type === 'org:complete');
4727
- const cycles = allLines.filter(l => l.includes('"org:checkpoint"')).length;
4728
- const lastEvent = tailEvents[tailEvents.length - 1] || headEvents[headEvents.length - 1];
4729
- const ageMs = lastEvent?.ts ? Date.now() - lastEvent.ts : Infinity;
4730
- const isStale = !last && ageMs > 30 * 60 * 1000;
4731
- // Derive a human-readable goal from the first boss directive when run:start lacks one
4732
- const firstBossComms = headEvents.find(e => e.type === 'org:comms' && (e.from === 'boss' || e.role === 'boss') && e.msg);
4733
- const derivedGoal = first?.goal || firstBossComms?.msg?.slice(0, 80) || '';
4734
- runs.push({ runId: f.replace('.jsonl', ''), startedAt: first?.ts || 0, endedAt: last?.ts || 0,
4735
- status: last ? 'complete' : isStale ? 'stale' : 'running',
4736
- eventCount, cycleCount: cycles, goal: derivedGoal, bossRole: first?.bossRole || '' });
4737
- } catch (_) {}
5002
+ const _parseRun = (filePath, f) => {
5003
+ try {
5004
+ const raw = fs.readFileSync(filePath, 'utf8');
5005
+ const allLines = raw.split('\n').filter(Boolean);
5006
+ const parse = l => { try { return JSON.parse(l); } catch { return null; } };
5007
+ // Merge .warm.jsonl (promoted pre-complete events) for accurate event count + metadata
5008
+ const warmFile = filePath.replace(/\.jsonl$/, '.warm.jsonl');
5009
+ let warmLines = [];
5010
+ let warmEvents = [];
5011
+ try { if (fs.existsSync(warmFile)) { warmLines = fs.readFileSync(warmFile, 'utf8').split('\n').filter(Boolean); warmEvents = warmLines.map(parse).filter(Boolean); } } catch(_) {}
5012
+ const combinedLines = [...warmLines, ...allLines];
5013
+ const eventCount = combinedLines.length;
5014
+ const headEvents = (warmEvents.length ? warmEvents : allLines.map(parse).filter(Boolean)).slice(0, 10);
5015
+ const tailEvents = (allLines.map(parse).filter(Boolean).slice(-5).length ? allLines.map(parse).filter(Boolean).slice(-5) : warmEvents.slice(-5));
5016
+ const first = headEvents.find(e => e.type === 'run:start') || headEvents[0];
5017
+ const last = [...(warmEvents.slice(-5)), ...(allLines.map(parse).filter(Boolean).slice(-3))].slice().reverse().find(e => e.type === 'run:complete' || e.type === 'org:complete');
5018
+ const cycles = combinedLines.filter(l => l.includes('"org:checkpoint"')).length;
5019
+ const lastEvent = allLines.map(parse).filter(Boolean).slice(-1)[0] || warmEvents.slice(-1)[0];
5020
+ const ageMs = lastEvent?.ts ? Date.now() - lastEvent.ts : Infinity;
5021
+ const isStale = !last && ageMs > 30 * 60 * 1000;
5022
+ const firstBossComms = headEvents.find(e => e.type === 'org:comms' && (e.from === 'boss' || e.role === 'boss') && e.msg);
5023
+ const derivedGoal = first?.goal || firstBossComms?.msg?.slice(0, 80) || '';
5024
+ return { runId: f.replace('.jsonl', ''), startedAt: first?.ts || 0, endedAt: last?.ts || 0,
5025
+ status: last ? 'complete' : isStale ? 'stale' : 'running',
5026
+ eventCount, cycleCount: cycles, goal: derivedGoal, bossRole: first?.bossRole || '' };
5027
+ } catch(_) { return null; }
5028
+ };
5029
+ for (const _rpd of _rProjDirs) {
5030
+ // Check both .monomind and .git/monomind locations
5031
+ const _rMonoDir = _getGitMonomindDir(_rpd) || path.join(_rpd, '.monomind');
5032
+ const _rSearchDirs = [path.join(_rMonoDir, 'orgs', _rOrgName, 'runs')];
5033
+ if (_rMonoDir !== path.join(_rpd, '.monomind')) _rSearchDirs.push(path.join(_rpd, '.monomind', 'orgs', _rOrgName, 'runs'));
5034
+ for (const _rDir of _rSearchDirs) {
5035
+ if (!fs.existsSync(_rDir)) continue;
5036
+ const files = fs.readdirSync(_rDir).filter(f => f.endsWith('.jsonl') && !f.endsWith('.convs.jsonl') && !f.endsWith('.warm.jsonl') && !f.endsWith('.cold.jsonl')).sort().reverse();
5037
+ for (const f of files.slice(0, 50)) {
5038
+ if (_rSeenFiles.has(f)) continue;
5039
+ _rSeenFiles.add(f);
5040
+ const r = _parseRun(path.join(_rDir, f), f);
5041
+ if (r) runs.push(r);
5042
+ }
5043
+ }
5044
+ }
5045
+ runs.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0));
5046
+ // Threads fallback: if no run files found, synthesize a run from the org threads file
5047
+ // so orgs whose boss never emitted runId-tagged events still show in the chat dropdown.
5048
+ if (runs.length === 0) {
5049
+ for (const _rpd of _rProjDirs) {
5050
+ const _tf = path.join(_rpd, '.monomind', 'orgs', `${_rOrgName}-threads.jsonl`);
5051
+ if (!fs.existsSync(_tf)) continue;
5052
+ const _tLines = fs.readFileSync(_tf, 'utf8').split('\n').filter(Boolean);
5053
+ if (!_tLines.length) continue;
5054
+ const _tEvs = _tLines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
5055
+ if (!_tEvs.length) continue;
5056
+ const _firstTs = _tEvs[0].ts ? (typeof _tEvs[0].ts === 'number' ? _tEvs[0].ts : new Date(_tEvs[0].ts).getTime()) : Date.now();
5057
+ const _lastTs = _tEvs[_tEvs.length-1].ts ? (typeof _tEvs[_tEvs.length-1].ts === 'number' ? _tEvs[_tEvs.length-1].ts : new Date(_tEvs[_tEvs.length-1].ts).getTime()) : _firstTs;
5058
+ runs.push({ id: `threads-${_rOrgName}`, orgName: _rOrgName, goal: `${_rOrgName} threads`, status: 'complete', startedAt: _firstTs, endedAt: _lastTs, eventCount: _tEvs.length, _threadsFile: _tf });
5059
+ break;
4738
5060
  }
4739
5061
  }
4740
5062
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-cache' });
@@ -4750,18 +5072,87 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4750
5072
  const _rvParts = url.replace(/\?.*$/, '').split('/');
4751
5073
  const _rvOrgName = decodeURIComponent(_rvParts[3] || '');
4752
5074
  const _rvRunId = decodeURIComponent(_rvParts[5] || '');
4753
- const _rvWorkDir = path.resolve(_rvQs.get('dir') || projectDir || process.cwd());
4754
- const _rvMonoDir = _getGitMonomindDir(_rvWorkDir) || path.join(_rvWorkDir, '.monomind');
4755
- const _rvFile = path.join(_rvMonoDir, 'orgs', _rvOrgName, 'runs', `${_rvRunId}.jsonl`);
4756
- if (!fs.existsSync(_rvFile)) { res.writeHead(404); res.end('{"error":"run not found"}'); return; }
4757
- const events = fs.readFileSync(_rvFile, 'utf8').split('\n').filter(Boolean)
4758
- .map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
5075
+ if (_rvOrgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(_rvOrgName) ||
5076
+ _rvRunId.length > 80 || !/^[a-z0-9][a-z0-9_-]*$/i.test(_rvRunId)) { res.writeHead(400); res.end('{"error":"Invalid org or run id"}'); return; }
5077
+ const _rvExplicitDir = _rvQs.get('dir');
5078
+ const _rvServerRoot = path.resolve(_rvExplicitDir || projectDir || process.cwd());
5079
+ // Threads fallback: threads-${orgName} is a synthetic runId served from threads file
5080
+ if (_rvRunId === `threads-${_rvOrgName}`) {
5081
+ const _rvProjDirsT = new Set([_rvServerRoot]);
5082
+ if (!_rvExplicitDir) { try { JSON.parse(fs.readFileSync(path.join(_rvServerRoot, 'data', 'known-projects.json'), 'utf8')).forEach(p => _rvProjDirsT.add(p)); } catch(_) {} }
5083
+ for (const _rvpd of _rvProjDirsT) {
5084
+ const _tf = path.join(_rvpd, '.monomind', 'orgs', `${_rvOrgName}-threads.jsonl`);
5085
+ if (!fs.existsSync(_tf)) continue;
5086
+ const _tLines = fs.readFileSync(_tf, 'utf8').split('\n').filter(Boolean);
5087
+ const _tEvs = _tLines.map(l => { try { const e = JSON.parse(l); return { type: 'org:comms', from: e.role || e.from || 'agent', to: e.to || 'all', msg: e.message || e.msg || '', ts: e.ts ? (typeof e.ts === 'number' ? e.ts : new Date(e.ts).getTime()) : Date.now(), org: _rvOrgName, runId: _rvRunId }; } catch { return null; } }).filter(Boolean);
5088
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
5089
+ res.end(JSON.stringify(_tEvs));
5090
+ return;
5091
+ }
5092
+ res.writeHead(404); res.end('{"error":"threads file not found"}'); return;
5093
+ }
5094
+ // Search across known projects
5095
+ const _rvProjDirs = new Set([_rvServerRoot]);
5096
+ if (!_rvExplicitDir) {
5097
+ try {
5098
+ JSON.parse(fs.readFileSync(path.join(_rvServerRoot, 'data', 'known-projects.json'), 'utf8')).forEach(p => _rvProjDirs.add(p));
5099
+ } catch(_) {}
5100
+ }
5101
+ let _rvFile = null;
5102
+ for (const _rvpd of _rvProjDirs) {
5103
+ const _rvMonoDir = _getGitMonomindDir(_rvpd) || path.join(_rvpd, '.monomind');
5104
+ const _candidates = [path.join(_rvMonoDir, 'orgs', _rvOrgName, 'runs', `${_rvRunId}.jsonl`)];
5105
+ if (_rvMonoDir !== path.join(_rvpd, '.monomind')) _candidates.push(path.join(_rvpd, '.monomind', 'orgs', _rvOrgName, 'runs', `${_rvRunId}.jsonl`));
5106
+ for (const c of _candidates) { if (fs.existsSync(c)) { _rvFile = c; break; } }
5107
+ if (_rvFile) break;
5108
+ }
5109
+ if (!_rvFile) { res.writeHead(404); res.end('{"error":"run not found"}'); return; }
5110
+ const _parseLines = p => { try { return fs.readFileSync(p, 'utf8').split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return []; } };
5111
+ const events = _parseLines(_rvFile);
5112
+ // Merge .warm.jsonl (pre-run:complete events, including org:comms) if it exists.
5113
+ // When run:complete fires, the hot .jsonl is renamed to .warm.jsonl so all pre-complete
5114
+ // events live there. The current .jsonl then only holds post-complete events (e.g. org:stop).
5115
+ const _rvWarmFile = _rvFile.replace(/\.jsonl$/, '.warm.jsonl');
5116
+ if (fs.existsSync(_rvWarmFile)) {
5117
+ events.push(..._parseLines(_rvWarmFile));
5118
+ }
5119
+ // For in-progress runs (no .warm.jsonl), org:comms also go to .convs.jsonl (stripped form).
5120
+ // They're already in .jsonl as full events, so .convs.jsonl would duplicate — skip it.
5121
+ events.sort((a, b) => (a.ts || 0) - (b.ts || 0));
4759
5122
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-cache' });
4760
5123
  res.end(JSON.stringify(events));
4761
5124
  } catch (_) { res.writeHead(500); res.end('[]'); }
4762
5125
  return;
4763
5126
  }
4764
5127
 
5128
+ // GET /api/org/:name/artifact — serve file content for chat "View" button
5129
+ if (req.method === 'GET' && /^\/api\/org\/[^/]+\/artifact/.test(url)) {
5130
+ try {
5131
+ const _artQp = new URL('http://x' + req.url).searchParams;
5132
+ const _rawPath = _artQp.get('path');
5133
+ if (!_rawPath) { res.writeHead(400); res.end(JSON.stringify({ error: 'path required' })); return; }
5134
+ const _filePath = path.resolve(decodeURIComponent(_rawPath));
5135
+ // Path traversal guard: only allow reads within known project dirs
5136
+ const _allowed = _getAllowedArtifactDirs(projectDir || process.cwd());
5137
+ const _safe = _allowed.some(d => _filePath.startsWith(d + path.sep) || _filePath === d);
5138
+ if (!_safe) { res.writeHead(403); res.end(JSON.stringify({ error: 'path not allowed' })); return; }
5139
+ if (!fs.existsSync(_filePath)) { res.writeHead(404); res.end(JSON.stringify({ error: 'file not found' })); return; }
5140
+ const _mime = _detectMimeType(_filePath);
5141
+ const _size = fs.statSync(_filePath).size;
5142
+ // Reject files >2MB to avoid blocking the event loop
5143
+ if (_size > 2 * 1024 * 1024) { res.writeHead(413); res.end(JSON.stringify({ error: 'file too large', size: _size })); return; }
5144
+ if (!_mime.startsWith('text/') && _mime !== 'application/json') {
5145
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
5146
+ res.end(JSON.stringify({ binary: true, mimeType: _mime, size: _size }));
5147
+ return;
5148
+ }
5149
+ const _content = fs.readFileSync(_filePath, 'utf8');
5150
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
5151
+ res.end(JSON.stringify({ content: _content, mimeType: _mime, size: _size }));
5152
+ } catch (_e) { res.writeHead(500); res.end(JSON.stringify({ error: 'read failed' })); }
5153
+ return;
5154
+ }
5155
+
4765
5156
  // ------------------------------------------------- Mastermind event system
4766
5157
  // POST /api/mastermind/event — ingest event from mastermind skill
4767
5158
  if (req.method === 'POST' && url === '/api/mastermind/event') {
@@ -4777,7 +5168,7 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4777
5168
  'Access-Control-Allow-Origin': '*',
4778
5169
  });
4779
5170
  res.write(': connected\n\n');
4780
- mmSseClients.add(res);
5171
+ addMmClient(res);
4781
5172
  // Replay last 50 events from disk (use ?project= param if provided)
4782
5173
  try {
4783
5174
  const _sseQp = new URL('http://x' + req.url).searchParams;
@@ -4787,8 +5178,8 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4787
5178
  const lines = fs.readFileSync(evFile, 'utf8').trim().split('\n').filter(Boolean).slice(-50);
4788
5179
  for (const l of lines) res.write(`data: ${l}\n\n`);
4789
5180
  } catch (_) {}
4790
- const ka = setInterval(() => { try { res.write(': ping\n\n'); } catch (_) { clearInterval(ka); mmSseClients.delete(res); } }, 20000);
4791
- req.on('close', () => { mmSseClients.delete(res); clearInterval(ka); });
5181
+ const ka = setInterval(() => { try { res.write(': ping\n\n'); } catch (_) { clearInterval(ka); removeMmClient(res); } }, 20000);
5182
+ req.on('close', () => { removeMmClient(res); clearInterval(ka); });
4792
5183
  return;
4793
5184
  }
4794
5185
 
@@ -4817,7 +5208,7 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4817
5208
  const top = idx.slice(0, limitParam);
4818
5209
  for (const entry of top) {
4819
5210
  const _sid = String(entry.id || '').trim();
4820
- if (!_sid || !/^[a-zA-Z0-9_.-]+$/.test(_sid)) continue;
5211
+ if (!_sid || !/^(?!.*\.\.)[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/.test(_sid)) continue;
4821
5212
  let events = [];
4822
5213
  try {
4823
5214
  const jl = fs.readFileSync(path.join(sessDir, `${_sid}.jsonl`), 'utf8');
@@ -4965,7 +5356,7 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4965
5356
  ts: Date.now(),
4966
5357
  uptime: process.uptime(),
4967
5358
  dir: root,
4968
- sseClients: mmSseClients.size,
5359
+ sseClients: getMmClientCount(),
4969
5360
  activeOrgs: Object.keys(orgRuns).length,
4970
5361
  orgRuns,
4971
5362
  recentEvents,
@@ -5035,12 +5426,387 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
5035
5426
  return;
5036
5427
  }
5037
5428
 
5429
+ // ----------------------------------------------- GET /api/monoagent/platforms
5430
+ // Returns all supported platforms from `monoes connect list --all --json`
5431
+ if (req.method === 'GET' && url === '/api/monoagent/platforms') {
5432
+ try {
5433
+ const { execFile } = await import('child_process');
5434
+ const out = await new Promise((resolve, reject) => {
5435
+ execFile('monoes', ['connect', 'list', '--all', '--json'], { timeout: 8000 }, (err, stdout) => {
5436
+ if (err) reject(err); else resolve(stdout);
5437
+ });
5438
+ });
5439
+ // Parse + re-serialize to ensure only valid JSON reaches the client
5440
+ // (monoes may emit warning lines before the JSON array)
5441
+ let parsed; try { parsed = JSON.parse(out); } catch (_) { parsed = []; }
5442
+ res.writeHead(200, { 'Content-Type': 'application/json' });
5443
+ res.end(JSON.stringify(Array.isArray(parsed) ? parsed : []));
5444
+ } catch (e) { res.writeHead(200); res.end('[]'); }
5445
+ return;
5446
+ }
5447
+
5448
+ // ----------------------------------------------- GET /api/monoagent/connections
5449
+ // Returns active API/OAuth connections + browser sessions merged
5450
+ if (req.method === 'GET' && url === '/api/monoagent/connections') {
5451
+ try {
5452
+ const { execFile } = await import('child_process');
5453
+ const [connsOut, sessOut] = await Promise.all([
5454
+ new Promise((resolve) => {
5455
+ execFile('monoes', ['connect', 'list', '--json'], { timeout: 8000 }, (err, stdout) => resolve(err ? '[]' : stdout));
5456
+ }),
5457
+ new Promise((resolve) => {
5458
+ execFile('monoes', ['--json', 'login', 'status'], { timeout: 8000 }, (err, stdout) => resolve(err ? '[]' : stdout));
5459
+ }),
5460
+ ]);
5461
+ let connections = []; try { connections = JSON.parse(connsOut); } catch (_) {}
5462
+ let sessions = []; try { sessions = JSON.parse(sessOut); } catch (_) {}
5463
+ res.writeHead(200, { 'Content-Type': 'application/json' });
5464
+ res.end(JSON.stringify({ connections, sessions }));
5465
+ } catch (e) { res.writeHead(200); res.end(JSON.stringify({ connections: [], sessions: [] })); }
5466
+ return;
5467
+ }
5468
+
5469
+ // ----------------------------------------------- POST /api/monoagent/login
5470
+ // Launches browser login for social platforms via `monoes login <platform>`
5471
+ if (req.method === 'POST' && url === '/api/monoagent/login') {
5472
+ try {
5473
+ let body = '';
5474
+ await new Promise((resolve, reject) => { req.on('data', d => { body += d; if (body.length > 65536) { req.destroy(); reject(new Error('body too large')); } }); req.on('end', resolve); req.on('error', reject); });
5475
+ const { id } = JSON.parse(body);
5476
+ if (!id || !/^[a-z][a-z0-9_-]*$/.test(id)) { res.writeHead(400); res.end(JSON.stringify({ error: 'invalid id' })); return; }
5477
+ const { spawn } = await import('child_process');
5478
+ const child = spawn('monoes', ['login', id, '--timeout', '10m'], { detached: true, stdio: 'ignore' });
5479
+ // Defer response until spawn confirms or errors — prevents race where error fires after res.end()
5480
+ child.once('spawn', () => {
5481
+ child.unref();
5482
+ res.writeHead(200, { 'Content-Type': 'application/json' });
5483
+ res.end(JSON.stringify({ ok: true }));
5484
+ });
5485
+ child.once('error', (err) => {
5486
+ res.writeHead(500, { 'Content-Type': 'application/json' });
5487
+ res.end(JSON.stringify({ error: err.message }));
5488
+ });
5489
+ } catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); }
5490
+ return;
5491
+ }
5492
+
5493
+ // ----------------------------------------------- POST /api/monoagent/connect
5494
+ if (req.method === 'POST' && url === '/api/monoagent/connect') {
5495
+ try {
5496
+ let body = '';
5497
+ await new Promise((resolve, reject) => { req.on('data', d => { body += d; if (body.length > 65536) { req.destroy(); reject(new Error('body too large')); } }); req.on('end', resolve); req.on('error', reject); });
5498
+ const { id, method, fields } = JSON.parse(body);
5499
+ if (!id || !/^[a-z][a-z0-9_-]*$/.test(id)) { res.writeHead(400); res.end(JSON.stringify({ error: 'invalid id' })); return; }
5500
+ if (method && !/^[a-z][a-z0-9_-]*$/.test(method)) { res.writeHead(400); res.end(JSON.stringify({ error: 'invalid method' })); return; }
5501
+ const { execFile } = await import('child_process');
5502
+ const args = ['connect', id];
5503
+ if (method) args.push('--method', method);
5504
+ if (fields && typeof fields === 'object') {
5505
+ for (const [k, v] of Object.entries(fields)) {
5506
+ // Only allow simple word keys — prevents --flag injection
5507
+ if (!/^[a-z][a-z0-9_]*$/.test(k)) continue;
5508
+ args.push(`--${k}`, String(v).slice(0, 2048));
5509
+ }
5510
+ }
5511
+ await new Promise((resolve, reject) => {
5512
+ execFile('monoes', args, { timeout: 30000 }, (err, stdout, stderr) => {
5513
+ const ok = !err;
5514
+ res.writeHead(200, { 'Content-Type': 'application/json' });
5515
+ res.end(JSON.stringify({ ok, stdout: (stdout || '').trim(), stderr: (stderr || '').trim() }));
5516
+ resolve();
5517
+ });
5518
+ });
5519
+ } catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); }
5520
+ return;
5521
+ }
5522
+
5523
+ // ----------------------------------------------- POST /api/monoagent/test
5524
+ if (req.method === 'POST' && url === '/api/monoagent/test') {
5525
+ try {
5526
+ let body = '';
5527
+ await new Promise((resolve, reject) => { req.on('data', d => { body += d; if (body.length > 65536) { req.destroy(); reject(new Error('body too large')); } }); req.on('end', resolve); req.on('error', reject); });
5528
+ const { id } = JSON.parse(body);
5529
+ if (!id || !/^[a-z0-9][a-z0-9_:-]*$/.test(id)) { res.writeHead(400); res.end(JSON.stringify({ error: 'id required' })); return; }
5530
+ const { execFile } = await import('child_process');
5531
+ await new Promise((resolve, reject) => {
5532
+ execFile('monoes', ['connect', 'test', id], { timeout: 15000 }, (err, stdout, stderr) => {
5533
+ if (err) reject(new Error(stderr || err.message)); else resolve(stdout);
5534
+ });
5535
+ });
5536
+ res.writeHead(200, { 'Content-Type': 'application/json' });
5537
+ res.end(JSON.stringify({ ok: true }));
5538
+ } catch (e) { res.writeHead(200); res.end(JSON.stringify({ ok: false, error: e.message })); }
5539
+ return;
5540
+ }
5541
+
5542
+ // ----------------------------------------------- POST /api/monoagent/disconnect
5543
+ if (req.method === 'POST' && url === '/api/monoagent/disconnect') {
5544
+ try {
5545
+ let body = '';
5546
+ await new Promise((resolve, reject) => { req.on('data', d => { body += d; if (body.length > 65536) { req.destroy(); reject(new Error('body too large')); } }); req.on('end', resolve); req.on('error', reject); });
5547
+ const { id, type } = JSON.parse(body);
5548
+ if (!id || !/^[a-z0-9][a-z0-9_:-]*$/.test(id)) { res.writeHead(400); res.end(JSON.stringify({ error: 'id required' })); return; }
5549
+ if (type !== 'session' && type !== 'connection') { res.writeHead(400); res.end(JSON.stringify({ error: 'invalid type' })); return; }
5550
+ const { execFile } = await import('child_process');
5551
+ const cmd = type === 'session' ? ['logout', id] : ['connect', 'remove', id];
5552
+ await new Promise((resolve, reject) => {
5553
+ execFile('monoes', cmd, { timeout: 10000 }, (err, _stdout, stderr) => {
5554
+ if (err) reject(new Error(stderr || err.message)); else resolve();
5555
+ });
5556
+ });
5557
+ res.writeHead(200, { 'Content-Type': 'application/json' });
5558
+ res.end(JSON.stringify({ ok: true }));
5559
+ } catch (e) { res.writeHead(200); res.end(JSON.stringify({ ok: false, error: e.message })); }
5560
+ return;
5561
+ }
5562
+
5563
+ // ------------------------------------------------- POST /api/playbooks
5564
+ // Save a playbook definition to .monomind/playbooks/<id>.json
5565
+ if (req.method === 'POST' && url === '/api/playbooks') {
5566
+ try {
5567
+ let body = '';
5568
+ await new Promise((resolve, reject) => {
5569
+ req.on('data', d => { body += d; });
5570
+ req.on('end', resolve);
5571
+ req.on('error', reject);
5572
+ });
5573
+ const pb = JSON.parse(body);
5574
+ if (!pb.id || !pb.name) {
5575
+ res.writeHead(400, { 'Content-Type': 'application/json' });
5576
+ res.end(JSON.stringify({ error: 'id and name are required' }));
5577
+ return;
5578
+ }
5579
+ const dir = projectDir || process.cwd();
5580
+ const playbookDir = path.join(dir, '.monomind', 'playbooks');
5581
+ fs.mkdirSync(playbookDir, { recursive: true });
5582
+ const filePath = path.join(playbookDir, pb.id + '.json');
5583
+ fs.writeFileSync(filePath, JSON.stringify(pb, null, 2));
5584
+ res.writeHead(200, { 'Content-Type': 'application/json' });
5585
+ res.end(JSON.stringify({ ok: true, id: pb.id, file: pb.id + '.json' }));
5586
+ } catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); }
5587
+ return;
5588
+ }
5589
+
5590
+ // ------------------------------------------------- GET /api/workflow-defs
5591
+ if (req.method === 'GET' && url === '/api/workflow-defs') {
5592
+ try {
5593
+ const qp = new URL(req.url, 'http://x').searchParams;
5594
+ const dir = qp.get('dir') || projectDir || process.cwd();
5595
+ const playbookDir = path.join(dir, '.monomind', 'playbooks');
5596
+ const result = [];
5597
+ if (fs.existsSync(playbookDir)) {
5598
+ const files = fs.readdirSync(playbookDir).filter(f => f.endsWith('.json'));
5599
+ for (const file of files) {
5600
+ try {
5601
+ const fpath = path.join(playbookDir, file);
5602
+ const stat = fs.statSync(fpath);
5603
+ const def = JSON.parse(fs.readFileSync(fpath, 'utf8'));
5604
+ const params = (def.params || []).map(p => typeof p === 'string' ? p : (p.name || p.key || ''));
5605
+ result.push({
5606
+ id: def.id || file.replace('.json', ''),
5607
+ name: def.name || file.replace('.json', ''),
5608
+ description: def.description || null,
5609
+ file,
5610
+ nodeCount: Array.isArray(def.nodes) ? def.nodes.length : 0,
5611
+ params,
5612
+ modifiedAt: stat.mtimeMs,
5613
+ });
5614
+ } catch (_) {}
5615
+ }
5616
+ }
5617
+ res.writeHead(200, { 'Content-Type': 'application/json' });
5618
+ res.end(JSON.stringify(result));
5619
+ } catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); }
5620
+ return;
5621
+ }
5622
+
5623
+ // ------------------------------------------------- GET /api/workflow-runs
5624
+ if (req.method === 'GET' && url === '/api/workflow-runs') {
5625
+ // Reads from ~/.monomind/browse-runs.json written by the monobrowse dashboard server.
5626
+ try {
5627
+ const runsFile = path.join(os.homedir(), '.monomind', 'browse-runs.json');
5628
+ if (fs.existsSync(runsFile)) {
5629
+ const raw = fs.readFileSync(runsFile, 'utf-8');
5630
+ const runs = JSON.parse(raw);
5631
+ res.writeHead(200, { 'Content-Type': 'application/json' });
5632
+ res.end(JSON.stringify(Array.isArray(runs) ? runs : []));
5633
+ } else {
5634
+ res.writeHead(200, { 'Content-Type': 'application/json' });
5635
+ res.end('[]');
5636
+ }
5637
+ } catch {
5638
+ res.writeHead(200, { 'Content-Type': 'application/json' });
5639
+ res.end('[]');
5640
+ }
5641
+ return;
5642
+ }
5643
+
5644
+ // ---- POST /api/orgs/:name/mark-complete — manual STALE recovery ----
5645
+ if (req.method === 'POST' && /^\/api\/orgs\/[a-z0-9][a-z0-9_-]{0,63}\/mark-complete$/i.test(url)) {
5646
+ const _mcOrgName = decodeURIComponent(url.split('/')[3]);
5647
+ if (_mcOrgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(_mcOrgName)) {
5648
+ res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid org name' })); return;
5649
+ }
5650
+ const _mcRoot = projectDir || process.cwd();
5651
+ const _mcMonoDir = _getGitMonomindDir(_mcRoot) || path.join(_mcRoot, '.monomind');
5652
+ const _mcRunId = activeOrgRuns.get(_mcOrgName) || _getActiveRunId(_mcOrgName, _mcRoot);
5653
+ if (!_mcRunId) {
5654
+ res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No active run for org: ' + _mcOrgName })); return;
5655
+ }
5656
+ const _mcEvent = { type: 'run:complete', org: _mcOrgName, runId: _mcRunId, ts: Date.now(), reason: 'manual' };
5657
+ try {
5658
+ const _mcRunFile = path.join(_mcMonoDir, 'orgs', _mcOrgName, 'runs', `${_mcRunId}.jsonl`);
5659
+ if (fs.existsSync(_mcRunFile)) await appendToFile(_mcRunFile, JSON.stringify(_mcEvent) + '\n');
5660
+ activeOrgRuns.delete(_mcOrgName);
5661
+ // Clean up ppid-keyed active-run files for this org
5662
+ const _mcCapDir = path.join(MONOMIND_HOME, '.monomind', 'capture');
5663
+ try {
5664
+ const _mcPpidDir = path.join(_mcCapDir, 'active-runs');
5665
+ if (fs.existsSync(_mcPpidDir)) {
5666
+ fs.readdirSync(_mcPpidDir).filter(f => f.endsWith('.json')).forEach(_pf => {
5667
+ try { const _pd = JSON.parse(fs.readFileSync(path.join(_mcPpidDir, _pf), 'utf8')); if (_pd.org === _mcOrgName) fs.unlinkSync(path.join(_mcPpidDir, _pf)); } catch (_) {}
5668
+ });
5669
+ }
5670
+ const _mcActiveFile = path.join(_mcCapDir, 'active-run.json');
5671
+ if (fs.existsSync(_mcActiveFile)) {
5672
+ try { const _a = JSON.parse(fs.readFileSync(_mcActiveFile, 'utf8')); if (_a.org === _mcOrgName) fs.unlinkSync(_mcActiveFile); } catch (_) {}
5673
+ }
5674
+ } catch (_) {}
5675
+ _updateRunState(_mcEvent, _mcRoot);
5676
+ broadcastMm(_mcEvent);
5677
+ const _mcFwdClients = runStreamClients.get(_mcOrgName);
5678
+ if (_mcFwdClients && _mcFwdClients.size > 0) {
5679
+ const _mcLine = `data: ${JSON.stringify(_mcEvent)}\n\n`;
5680
+ for (const _cl of _mcFwdClients) { try { _cl.write(_mcLine); } catch (_) { _mcFwdClients.delete(_cl); } }
5681
+ }
5682
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
5683
+ res.end(JSON.stringify({ ok: true, runId: _mcRunId }));
5684
+ } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: e.message })); }
5685
+ return;
5686
+ }
5687
+
5688
+ // ---- GET /api/orgs/:name/runs/current/stream — Phase 3 streaming tail ----
5689
+ if (req.method === 'GET' && /^\/api\/orgs\/[a-z0-9][a-z0-9_-]{0,63}\/runs\/current\/stream$/i.test(url)) {
5690
+ const _stOrgName = decodeURIComponent(url.split('/')[3]);
5691
+ if (_stOrgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(_stOrgName)) {
5692
+ res.writeHead(400); res.end('Invalid org name'); return;
5693
+ }
5694
+ const _stQs = new URL(req.url, 'http://localhost').searchParams;
5695
+ const _stSince = Math.max(0, parseInt(_stQs.get('since') || '0', 10) || 0);
5696
+ res.writeHead(200, {
5697
+ 'Content-Type': 'text/event-stream',
5698
+ 'Cache-Control': 'no-cache',
5699
+ 'Connection': 'keep-alive',
5700
+ 'Access-Control-Allow-Origin': '*',
5701
+ 'X-Accel-Buffering': 'no',
5702
+ });
5703
+ res.write(': connected\n\n');
5704
+ // Register client for live events
5705
+ if (!runStreamClients.has(_stOrgName)) runStreamClients.set(_stOrgName, new Set());
5706
+ runStreamClients.get(_stOrgName).add(res);
5707
+ // Replay events since `since` (SQLite row id cursor; falls back to JSONL line offset)
5708
+ try {
5709
+ if (_runDb) {
5710
+ // SQLite path: cursor is last row id seen (client sends 0 on first connect)
5711
+ const _stStmt = _runDb.prepare(
5712
+ 'SELECT id, raw FROM run_events WHERE org=? AND id > ? ORDER BY id LIMIT 2000'
5713
+ );
5714
+ _stStmt.bind([_stOrgName, _stSince]);
5715
+ let _stLastId = _stSince;
5716
+ while (_stStmt.step()) {
5717
+ const _stRow = _stStmt.getAsObject();
5718
+ try { res.write(`data: ${_stRow.raw}\n\n`); _stLastId = _stRow.id; } catch (_) { break; }
5719
+ }
5720
+ _stStmt.free();
5721
+ res.write(`data: ${JSON.stringify({ type: 'stream:replay-done', count: _stLastId })}\n\n`);
5722
+ } else {
5723
+ // JSONL fallback: since = 0-based line offset
5724
+ const _stRoot = projectDir || process.cwd();
5725
+ const _stRunId = activeOrgRuns.get(_stOrgName) || _getActiveRunId(_stOrgName, _stRoot);
5726
+ if (_stRunId) {
5727
+ const _stMono = _getGitMonomindDir(_stRoot) || path.join(_stRoot, '.monomind');
5728
+ const _stRunFile = path.join(_stMono, 'orgs', _stOrgName, 'runs', `${_stRunId}.jsonl`);
5729
+ if (fs.existsSync(_stRunFile)) {
5730
+ const _stLines = fs.readFileSync(_stRunFile, 'utf8').trim().split('\n').filter(Boolean);
5731
+ for (let _i = _stSince; _i < _stLines.length; _i++) {
5732
+ try { res.write(`data: ${_stLines[_i]}\n\n`); } catch (_) { break; }
5733
+ }
5734
+ res.write(`data: ${JSON.stringify({ type: 'stream:replay-done', count: _stLines.length })}\n\n`);
5735
+ }
5736
+ }
5737
+ }
5738
+ } catch (_) {}
5739
+ const _stKa = setInterval(() => { try { res.write(': ping\n\n'); } catch (_) { clearInterval(_stKa); } }, 20000);
5740
+ req.on('close', () => {
5741
+ clearInterval(_stKa);
5742
+ const _stClients = runStreamClients.get(_stOrgName);
5743
+ if (_stClients) { _stClients.delete(res); if (_stClients.size === 0) runStreamClients.delete(_stOrgName); }
5744
+ });
5745
+ return;
5746
+ }
5747
+
5038
5748
  // ------------------------------------------------------------------ 404
5039
5749
  res.writeHead(404, { 'Content-Type': 'text/plain' });
5040
5750
  res.end('Not found');
5041
5751
  });
5042
5752
 
5043
- // Bind to available port
5753
+ // ── Gap-fill ordering (ADR Issue 7): rebuild activeOrgRuns BEFORE the server
5754
+ // starts accepting connections so the first incoming event already has runId context.
5755
+ // Uses SQLite when available; falls back to JSONL scan.
5756
+ await _initRunDb(MONOMIND_HOME);
5757
+ try {
5758
+ if (_runDb) {
5759
+ // SQLite gap-fill: for each org, find latest run_id and check if it has run:complete
5760
+ const _gfOrgsStmt = _runDb.prepare('SELECT DISTINCT org FROM run_events');
5761
+ while (_gfOrgsStmt.step()) {
5762
+ const _gfOrg = _gfOrgsStmt.getAsObject().org;
5763
+ if (!_gfOrg || !/^[a-z0-9][a-z0-9_-]*$/i.test(_gfOrg)) continue;
5764
+ // Resolve the latest run_id for this org, then check if it has a terminal event
5765
+ const _gfLatRunStmt = _runDb.prepare(
5766
+ "SELECT run_id FROM run_events WHERE org=? ORDER BY id DESC LIMIT 1"
5767
+ );
5768
+ _gfLatRunStmt.bind([_gfOrg]);
5769
+ let _gfLatestRun = null;
5770
+ if (_gfLatRunStmt.step()) _gfLatestRun = _gfLatRunStmt.getAsObject().run_id;
5771
+ _gfLatRunStmt.free();
5772
+ let _gfDone = false;
5773
+ if (_gfLatestRun) {
5774
+ const _gfRunStmt = _runDb.prepare(
5775
+ "SELECT type FROM run_events WHERE org=? AND run_id=? AND type IN ('run:complete','org:complete','org:stop') LIMIT 1"
5776
+ );
5777
+ _gfRunStmt.bind([_gfOrg, _gfLatestRun]);
5778
+ if (_gfRunStmt.step()) _gfDone = true;
5779
+ _gfRunStmt.free();
5780
+ }
5781
+ if (_gfLatestRun && !_gfDone) activeOrgRuns.set(_gfOrg, _gfLatestRun);
5782
+ }
5783
+ _gfOrgsStmt.free();
5784
+ } else {
5785
+ // JSONL fallback
5786
+ const _gfOrgsDir = path.join(MONOMIND_HOME, '.monomind', 'orgs');
5787
+ if (fs.existsSync(_gfOrgsDir)) {
5788
+ for (const _gfOrg of fs.readdirSync(_gfOrgsDir)) {
5789
+ if (!_gfOrg || _gfOrg.startsWith('.') || !/^[a-z0-9][a-z0-9_-]*$/i.test(_gfOrg)) continue;
5790
+ const _gfRunsDir = path.join(_gfOrgsDir, _gfOrg, 'runs');
5791
+ if (!fs.existsSync(_gfRunsDir)) continue;
5792
+ const _gfFiles = fs.readdirSync(_gfRunsDir)
5793
+ .filter(f => f.endsWith('.jsonl') && !f.endsWith('.convs.jsonl'))
5794
+ .sort().reverse();
5795
+ for (const _gfF of _gfFiles.slice(0, 5)) {
5796
+ try {
5797
+ const _gfId = _gfF.replace('.jsonl', '');
5798
+ const _gfContent = fs.readFileSync(path.join(_gfRunsDir, _gfF), 'utf8');
5799
+ const _gfLast = _gfContent.trim().split('\n').filter(Boolean).slice(-10);
5800
+ const _gfDone = _gfLast.some(l => { try { const e = JSON.parse(l); return e.type === 'run:complete' || e.type === 'org:complete'; } catch { return false; } });
5801
+ if (!_gfDone) { activeOrgRuns.set(_gfOrg, _gfId); break; }
5802
+ } catch (_) {}
5803
+ }
5804
+ }
5805
+ }
5806
+ }
5807
+ } catch (_) {}
5808
+
5809
+ // Bind to available port (after activeOrgRuns is populated — no race window)
5044
5810
  const boundPort = await bindServer(server, port);
5045
5811
  const url = `http://localhost:${boundPort}`;
5046
5812
 
@@ -5059,7 +5825,7 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
5059
5825
  const _migIndex = [];
5060
5826
  for (const sess of (_migOld || [])) {
5061
5827
  const _msid = String(sess.id || '').trim();
5062
- if (!_msid || !/^[a-zA-Z0-9_.-]+$/.test(_msid)) continue;
5828
+ if (!_msid || !/^(?!.*\.\.)[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/.test(_msid)) continue;
5063
5829
  // Write per-session JSONL
5064
5830
  const _mEvts = (sess.events || []);
5065
5831
  const _mLines = _mEvts.map(e => JSON.stringify(e)).join('\n');
@@ -5075,32 +5841,6 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
5075
5841
  }
5076
5842
  } catch (_) {}
5077
5843
 
5078
- // Rebuild activeOrgRuns from disk so event enrichment (runId injection) still works
5079
- // after a server restart. Without this, org events emitted mid-run that lack runId
5080
- // are broadcast without it and _odtHandleLiveEvent drops them.
5081
- try {
5082
- const _rbOrgsDir = path.join(projectDir || process.cwd(), '.monomind', 'orgs');
5083
- if (fs.existsSync(_rbOrgsDir)) {
5084
- for (const _rbOrg of fs.readdirSync(_rbOrgsDir)) {
5085
- if (!_rbOrg || _rbOrg.startsWith('.') || !/^[a-z0-9][a-z0-9_-]*$/i.test(_rbOrg)) continue;
5086
- const _rbRunsDir = path.join(_rbOrgsDir, _rbOrg, 'runs');
5087
- if (!fs.existsSync(_rbRunsDir)) continue;
5088
- const _rbFiles = fs.readdirSync(_rbRunsDir)
5089
- .filter(f => f.endsWith('.jsonl') && !f.endsWith('.convs.jsonl'))
5090
- .sort().reverse();
5091
- for (const _rbF of _rbFiles.slice(0, 5)) {
5092
- try {
5093
- const _rbId = _rbF.replace('.jsonl', '');
5094
- const _rbContent = fs.readFileSync(path.join(_rbRunsDir, _rbF), 'utf8');
5095
- const _rbLast = _rbContent.trim().split('\n').filter(Boolean).slice(-10);
5096
- const _rbDone = _rbLast.some(l => { try { const e = JSON.parse(l); return e.type === 'run:complete' || e.type === 'org:complete'; } catch { return false; } });
5097
- if (!_rbDone) { activeOrgRuns.set(_rbOrg, _rbId); break; }
5098
- } catch (_) {}
5099
- }
5100
- }
5101
- }
5102
- } catch (_) {}
5103
-
5104
5844
  // ---------------------------------------------------------------- Watchers
5105
5845
  let debounceTimer = null;
5106
5846
  let pendingSections = new Set();
@@ -5129,6 +5869,128 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
5129
5869
  }
5130
5870
  }
5131
5871
 
5872
+ // ── Phase 1: fs.watch orgs dir — pick up run events written directly to JSONL files
5873
+ // without going through the HTTP endpoint (e.g. when runorg.md bash writes run:start directly).
5874
+ // Also forwards new bytes to per-org SSE clients (runStreamClients) so the chat tab
5875
+ // receives bash-written lifecycle events in real-time (Phase 3 gap-fill).
5876
+ const _orgsFileSizes = new Map(); // absPath → last known byte offset
5877
+ function _readNewOrgLines(absPath, orgName, runId) {
5878
+ try {
5879
+ const stat = fs.statSync(absPath);
5880
+ const prevSize = _orgsFileSizes.get(absPath) || 0;
5881
+ if (stat.size <= prevSize) return; // nothing new
5882
+ _orgsFileSizes.set(absPath, stat.size);
5883
+ // Read only the new bytes to avoid re-processing existing lines
5884
+ const fd = fs.openSync(absPath, 'r');
5885
+ const newLen = stat.size - prevSize;
5886
+ const buf = Buffer.alloc(newLen);
5887
+ fs.readSync(fd, buf, 0, newLen, prevSize);
5888
+ fs.closeSync(fd);
5889
+ const newText = buf.toString('utf8');
5890
+ const newLines = newText.split('\n').filter(Boolean);
5891
+ const clients = runStreamClients.get(orgName);
5892
+ for (const _rawLine of newLines) {
5893
+ let ev;
5894
+ try { ev = JSON.parse(_rawLine); } catch { continue; }
5895
+ if (!ev || !ev.type) continue;
5896
+ // Index in SQLite (watcher path — bash-written lifecycle events)
5897
+ if (!ev.org) ev.org = orgName;
5898
+ if (!ev.runId) ev.runId = runId;
5899
+ _insertRunEvent(ev, 'watcher');
5900
+ // Update activeOrgRuns based on file-watcher evidence
5901
+ if ((ev.type === 'run:start' || ev.type === 'org:start') && ev.runId) {
5902
+ activeOrgRuns.set(orgName, String(ev.runId).trim());
5903
+ } else if (ev.type === 'run:complete' || ev.type === 'org:complete' || ev.type === 'org:stop') {
5904
+ activeOrgRuns.delete(orgName);
5905
+ }
5906
+ // Forward to per-org SSE clients so the chat tab gets live bash-written events
5907
+ if (clients && clients.size > 0) {
5908
+ const _sseData = `data: ${_rawLine}\n\n`;
5909
+ for (const _cl of clients) { try { _cl.write(_sseData); } catch (_) { clients.delete(_cl); } }
5910
+ }
5911
+ // Also broadcast to mastermind-stream for the org activity strip
5912
+ if (ev.org && ev.org === orgName) broadcastMm({ ...ev, _fromWatcher: true });
5913
+ }
5914
+ } catch (_) {}
5915
+ }
5916
+
5917
+ function watchOrgsDir() {
5918
+ const _orgsDir = path.join(MONOMIND_HOME, '.monomind', 'orgs');
5919
+ if (!fs.existsSync(_orgsDir)) {
5920
+ // Orgs dir may not exist yet; watch parent and re-try when it appears
5921
+ const _parentDir = path.join(MONOMIND_HOME, '.monomind');
5922
+ if (fs.existsSync(_parentDir)) {
5923
+ try {
5924
+ fs.watch(_parentDir, (_evType, _fname) => {
5925
+ if (_fname === 'orgs' && fs.existsSync(_orgsDir)) watchOrgsDir();
5926
+ });
5927
+ } catch (_) {}
5928
+ }
5929
+ return;
5930
+ }
5931
+ // Seed initial file sizes so the watcher only forwards NEW bytes after startup
5932
+ try {
5933
+ for (const _org of fs.readdirSync(_orgsDir)) {
5934
+ const _runsDir = path.join(_orgsDir, _org, 'runs');
5935
+ if (!fs.existsSync(_runsDir)) continue;
5936
+ for (const _f of fs.readdirSync(_runsDir).filter(f => f.endsWith('.jsonl') && !f.endsWith('.warm.jsonl') && !f.endsWith('.convs.jsonl'))) {
5937
+ try { _orgsFileSizes.set(path.join(_runsDir, _f), fs.statSync(path.join(_runsDir, _f)).size); } catch (_) {}
5938
+ }
5939
+ }
5940
+ } catch (_) {}
5941
+ // Use chokidar when available (Linux requires it — fs.watch { recursive } is macOS/Windows only).
5942
+ // Falls back to fs.watch for environments where chokidar is absent.
5943
+ let _watcherStarted = false;
5944
+ try {
5945
+ const chokidar = _require('chokidar');
5946
+ const _chokidarWatcher = chokidar.watch(_orgsDir, {
5947
+ persistent: false,
5948
+ ignoreInitial: true,
5949
+ depth: 3,
5950
+ ignored: (p) => {
5951
+ const b = path.basename(p);
5952
+ return b.endsWith('.warm.jsonl') || b.endsWith('.convs.jsonl') || b.startsWith('.');
5953
+ },
5954
+ awaitWriteFinish: false,
5955
+ });
5956
+ const _handleChokidarPath = (absPath) => {
5957
+ if (!absPath.endsWith('.jsonl')) return;
5958
+ const rel = path.relative(_orgsDir, absPath).replace(/\\/g, '/');
5959
+ const parts = rel.split('/');
5960
+ if (parts.length >= 3 && parts[1] === 'runs') {
5961
+ const _wOrgName = parts[0];
5962
+ const _wRunId = parts[2].replace('.jsonl', '');
5963
+ if (_wOrgName && _wRunId && /^[a-z0-9][a-z0-9_-]*$/i.test(_wOrgName) && /^[a-z0-9][a-z0-9_-]*$/i.test(_wRunId)) {
5964
+ _readNewOrgLines(absPath, _wOrgName, _wRunId);
5965
+ }
5966
+ }
5967
+ };
5968
+ _chokidarWatcher.on('add', _handleChokidarPath);
5969
+ _chokidarWatcher.on('change', _handleChokidarPath);
5970
+ activeWatchers.push({ close: () => _chokidarWatcher.close() });
5971
+ _watcherStarted = true;
5972
+ } catch (_chokidarErr) { /* chokidar unavailable — fall through to fs.watch */ }
5973
+ if (!_watcherStarted) {
5974
+ try {
5975
+ const _orgsWatcher = fs.watch(_orgsDir, { recursive: true, persistent: false }, (_evType, _fname) => {
5976
+ if (!_fname || !_fname.endsWith('.jsonl') || _fname.endsWith('.warm.jsonl') || _fname.endsWith('.convs.jsonl')) return;
5977
+ const _parts = _fname.replace(/\\/g, '/').split('/');
5978
+ if (_parts.length >= 3 && _parts[1] === 'runs') {
5979
+ const _wOrgName = _parts[0];
5980
+ const _wRunId = _parts[2].replace('.jsonl', '');
5981
+ if (_wOrgName && _wRunId && /^[a-z0-9][a-z0-9_-]*$/i.test(_wOrgName) && /^[a-z0-9][a-z0-9_-]*$/i.test(_wRunId)) {
5982
+ _readNewOrgLines(path.join(_orgsDir, _fname.replace(/\\/g, '/')), _wOrgName, _wRunId);
5983
+ }
5984
+ }
5985
+ });
5986
+ activeWatchers.push(_orgsWatcher);
5987
+ } catch (_wErr) {
5988
+ console.warn('[monomind] watchOrgsDir: both chokidar and fs.watch failed — bash-written lifecycle events will not reach SSE clients. HTTP-posted events still work via spool DLQ.');
5989
+ }
5990
+ }
5991
+ }
5992
+ watchOrgsDir();
5993
+
5132
5994
  // Watch .claude/sessions/ if present
5133
5995
  const claudeSessionsDir = path.join(projectDir || process.cwd(), '.claude', 'sessions');
5134
5996
  if (fs.existsSync(claudeSessionsDir)) {
@@ -5140,6 +6002,134 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
5140
6002
  }
5141
6003
  }
5142
6004
 
6005
+ // ── Phase 2: Spool polling — replay undelivered events from capture-handler (Issue 5) ──
6006
+ // capture-handler writes events to spool/ before HTTP POST. If the POST fails (server
6007
+ // down, timeout), the file stays. We poll every 5s and replay them.
6008
+ const _spoolBaseDir = path.join(MONOMIND_HOME, '.monomind', 'capture', 'spool');
6009
+ const _spoolTimer = setInterval(() => {
6010
+ if (!fs.existsSync(_spoolBaseDir)) return;
6011
+ try {
6012
+ const _spoolFiles = fs.readdirSync(_spoolBaseDir)
6013
+ .filter(f => f.endsWith('.json') && !f.startsWith('.'))
6014
+ .sort() // chronological (timestamp prefix)
6015
+ .slice(0, 20); // max 20 per cycle to avoid flooding
6016
+ for (const _sf of _spoolFiles) {
6017
+ const _sfPath = path.join(_spoolBaseDir, _sf);
6018
+ try {
6019
+ const _spoolEvent = JSON.parse(fs.readFileSync(_sfPath, 'utf8'));
6020
+ const _spoolBody = JSON.stringify(_spoolEvent);
6021
+ const _spoolReq = http.request({
6022
+ hostname: 'localhost', port: boundPort,
6023
+ path: '/api/mastermind/event', method: 'POST',
6024
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(_spoolBody) },
6025
+ }, (_spoolRes) => {
6026
+ // Delete only after confirmed delivery; leave file on failure for next poll cycle
6027
+ if (_spoolRes.statusCode >= 200 && _spoolRes.statusCode < 300) {
6028
+ try { fs.unlinkSync(_sfPath); } catch (_) {}
6029
+ }
6030
+ _spoolRes.resume();
6031
+ });
6032
+ _spoolReq.on('error', () => {});
6033
+ _spoolReq.setTimeout(2000, () => { _spoolReq.destroy(); });
6034
+ _spoolReq.write(_spoolBody);
6035
+ _spoolReq.end();
6036
+ } catch (_e) {}
6037
+ }
6038
+ } catch (_) {}
6039
+ }, 5000);
6040
+ // Clean up spool files older than 8 hours on startup (stale captures from crashed sessions)
6041
+ try {
6042
+ if (fs.existsSync(_spoolBaseDir)) {
6043
+ const _staleMs = 8 * 60 * 60 * 1000;
6044
+ fs.readdirSync(_spoolBaseDir).filter(f => f.endsWith('.json')).forEach(_staleF => {
6045
+ const _staleP = path.join(_spoolBaseDir, _staleF);
6046
+ try {
6047
+ if (Date.now() - fs.statSync(_staleP).mtimeMs > _staleMs) fs.unlinkSync(_staleP);
6048
+ } catch (_) {}
6049
+ });
6050
+ }
6051
+ } catch (_) {}
6052
+
6053
+ // ── Phase 3: Read-batch polling — aggregate file-read events from capture-handler (Issue 9) ──
6054
+ // capture-handler writes Read tool calls to capture/read-batch-{ppid}-{pid}.json (per-subagent, no sharing).
6055
+ // Server polls every 3s, aggregates all matching files per session, emits agent:read:batch, removes files.
6056
+ const _rbDir = path.join(MONOMIND_HOME, '.monomind', 'capture');
6057
+ const _rbTimer = setInterval(() => {
6058
+ if (!fs.existsSync(_rbDir)) return;
6059
+ try {
6060
+ fs.readdirSync(_rbDir)
6061
+ .filter(f => f.startsWith('read-batch-') && f.endsWith('.json'))
6062
+ .forEach(_rbf => {
6063
+ const _rbPath = path.join(_rbDir, _rbf);
6064
+ try {
6065
+ const _rbData = JSON.parse(fs.readFileSync(_rbPath, 'utf8'));
6066
+ fs.unlinkSync(_rbPath);
6067
+ if (!Array.isArray(_rbData) || _rbData.length === 0) return;
6068
+ const _rbOrg = String(_rbData[0].org || '').trim();
6069
+ const _rbRunId = String(_rbData[0].runId || '').trim();
6070
+ const _rbEvent = {
6071
+ type: 'agent:read:batch',
6072
+ org: _rbOrg,
6073
+ runId: _rbRunId,
6074
+ paths: _rbData.map(e => String(e.path || '').slice(0, 256)),
6075
+ count: _rbData.length,
6076
+ ts: Date.now(),
6077
+ };
6078
+ const _rbBody = JSON.stringify(_rbEvent);
6079
+ const _rbReq = http.request({
6080
+ hostname: 'localhost', port: boundPort,
6081
+ path: '/api/mastermind/event', method: 'POST',
6082
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(_rbBody) },
6083
+ }, () => {});
6084
+ _rbReq.on('error', () => {});
6085
+ _rbReq.setTimeout(2000, () => { _rbReq.destroy(); });
6086
+ _rbReq.write(_rbBody);
6087
+ _rbReq.end();
6088
+ } catch (_e) {}
6089
+ });
6090
+ } catch (_) {}
6091
+ }, 3000);
6092
+
6093
+ // ── Phase 4: Daemon heartbeat — ps -p {ppid} liveness check (Issue 8) ──
6094
+ // Periodically checks if the Claude Code session (tracked via ppid-keyed files) is still alive.
6095
+ // If the parent process is gone, auto-emits org:stop to close stale LIVE orgs in the dashboard.
6096
+ const _ppidCheckDir = path.join(MONOMIND_HOME, '.monomind', 'capture', 'active-runs');
6097
+ const _heartbeatTimer = setInterval(() => {
6098
+ if (!fs.existsSync(_ppidCheckDir)) return;
6099
+ try {
6100
+ fs.readdirSync(_ppidCheckDir).filter(f => f.endsWith('.json')).forEach(_ppf => {
6101
+ const _ppPath = path.join(_ppidCheckDir, _ppf);
6102
+ try {
6103
+ const _ppData = JSON.parse(fs.readFileSync(_ppPath, 'utf8'));
6104
+ const _ppid = parseInt(_ppf.replace('.json', ''), 10);
6105
+ if (!_ppid || isNaN(_ppid)) return;
6106
+ // Check if the ppid process is still alive (signal 0 = probe, no kill)
6107
+ try {
6108
+ process.kill(_ppid, 0);
6109
+ // Process alive — no action
6110
+ } catch (_psErr) {
6111
+ // Process gone — emit org:stop and remove the ppid file
6112
+ fs.unlinkSync(_ppPath);
6113
+ const _staleOrg = String(_ppData.org || '').trim();
6114
+ const _staleRun = String(_ppData.runId || '').trim();
6115
+ if (_staleOrg && activeOrgRuns.has(_staleOrg)) {
6116
+ const _stopBody = JSON.stringify({ type: 'org:stop', org: _staleOrg, runId: _staleRun, reason: 'ppid-dead', ts: Date.now() });
6117
+ const _stopReq = http.request({
6118
+ hostname: 'localhost', port: boundPort,
6119
+ path: '/api/mastermind/event', method: 'POST',
6120
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(_stopBody) },
6121
+ }, () => {});
6122
+ _stopReq.on('error', () => {});
6123
+ _stopReq.setTimeout(2000, () => { _stopReq.destroy(); });
6124
+ _stopReq.write(_stopBody);
6125
+ _stopReq.end();
6126
+ }
6127
+ }
6128
+ } catch (_) {}
6129
+ });
6130
+ } catch (_) {}
6131
+ }, 60000); // every 60s — intentionally infrequent, just a safety net
6132
+
5143
6133
  // Update module-level state
5144
6134
  running = true;
5145
6135
  currentPort = boundPort;
@@ -5148,6 +6138,14 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
5148
6138
 
5149
6139
  // --------------------------------------------------------- Graceful shutdown
5150
6140
  function shutdown() {
6141
+ clearInterval(_spoolTimer);
6142
+ clearInterval(_rbTimer);
6143
+ clearInterval(_heartbeatTimer);
6144
+ // Flush SQLite run-event index to disk before exit (bypasses 1000ms debounce timer)
6145
+ clearTimeout(_runDbPersistTimer);
6146
+ if (_runDb && _runDbPath) {
6147
+ try { fs.writeFileSync(_runDbPath, Buffer.from(_runDb.export())); } catch (_) {}
6148
+ }
5151
6149
  for (const w of activeWatchers) {
5152
6150
  try {
5153
6151
  w.close();
@@ -5158,20 +6156,16 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
5158
6156
  activeWatchers.length = 0;
5159
6157
 
5160
6158
  // Close all SSE connections
5161
- for (const client of sseClients) {
5162
- try {
5163
- client.end();
5164
- } catch {
5165
- // Already ended
5166
- }
5167
- }
5168
- sseClients.clear();
5169
-
5170
- server.close(() => {
5171
- running = false;
5172
- currentPort = null;
5173
- currentUrl = null;
5174
- activeServer = null;
6159
+ closeSseClients();
6160
+
6161
+ // Drain in-flight JSONL appends before closing (prevents truncated writes on fast SIGTERM)
6162
+ Promise.all([..._writeQueue.values()]).catch(() => {}).finally(() => {
6163
+ server.close(() => {
6164
+ running = false;
6165
+ currentPort = null;
6166
+ currentUrl = null;
6167
+ activeServer = null;
6168
+ });
5175
6169
  });
5176
6170
  }
5177
6171
 
@@ -5196,7 +6190,7 @@ export function getServerStatus() {
5196
6190
  running,
5197
6191
  port: currentPort,
5198
6192
  url: currentUrl,
5199
- clientCount: sseClients.size,
6193
+ clientCount: getSseClientCount(),
5200
6194
  };
5201
6195
  }
5202
6196