@proflandrigan/shards 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (397) hide show
  1. package/README.md +475 -0
  2. package/package.json +37 -0
  3. package/src/agents/academic.md +276 -0
  4. package/src/agents/ai-engineer.md +377 -0
  5. package/src/agents/analytics-engineer.md +364 -0
  6. package/src/agents/applied-ml-scientist.md +410 -0
  7. package/src/agents/backend-engineer.md +255 -0
  8. package/src/agents/bi-engineer.md +333 -0
  9. package/src/agents/data-analyst.md +343 -0
  10. package/src/agents/data-engineer.md +260 -0
  11. package/src/agents/data-modeller.md +386 -0
  12. package/src/agents/data-scientist.md +366 -0
  13. package/src/agents/deep-learning-engineer.md +389 -0
  14. package/src/agents/ml-engineer.md +424 -0
  15. package/src/agents/mlops-engineer.md +339 -0
  16. package/src/agents/researcher.md +187 -0
  17. package/src/agents/specific_instructions/academic/critical_review.md +263 -0
  18. package/src/agents/specific_instructions/academic/report.md +113 -0
  19. package/src/agents/specific_instructions/ai_engineer/advise.md +162 -0
  20. package/src/agents/specific_instructions/ai_engineer/bi_engineer_handoff.md +86 -0
  21. package/src/agents/specific_instructions/ai_engineer/experiment.md +471 -0
  22. package/src/agents/specific_instructions/ai_engineer/experiment_ui_mode.md +44 -0
  23. package/src/agents/specific_instructions/ai_engineer/phases/index.md +45 -0
  24. package/src/agents/specific_instructions/ai_engineer/phases/phase-1.md +55 -0
  25. package/src/agents/specific_instructions/ai_engineer/phases/phase-2.md +86 -0
  26. package/src/agents/specific_instructions/ai_engineer/phases/phase-3.md +96 -0
  27. package/src/agents/specific_instructions/ai_engineer/phases/phase-4.md +138 -0
  28. package/src/agents/specific_instructions/ai_engineer/phases/phase-5.md +157 -0
  29. package/src/agents/specific_instructions/ai_engineer/phases/phase-6.md +196 -0
  30. package/src/agents/specific_instructions/ai_engineer/phases/phase-7.md +313 -0
  31. package/src/agents/specific_instructions/ai_engineer/phases.md +1011 -0
  32. package/src/agents/specific_instructions/ai_engineer/prompt_lab.md +161 -0
  33. package/src/agents/specific_instructions/ai_engineer/prompt_lab_ui_mode.md +28 -0
  34. package/src/agents/specific_instructions/ai_engineer/research.md +393 -0
  35. package/src/agents/specific_instructions/ai_engineer/research_ui_mode.md +66 -0
  36. package/src/agents/specific_instructions/ai_engineer/review.md +159 -0
  37. package/src/agents/specific_instructions/ai_engineer/validation_checklist.md +182 -0
  38. package/src/agents/specific_instructions/analytics_engineer/advise.md +155 -0
  39. package/src/agents/specific_instructions/analytics_engineer/bi_engineer_handoff.md +91 -0
  40. package/src/agents/specific_instructions/analytics_engineer/data_analyst_handoff.md +84 -0
  41. package/src/agents/specific_instructions/analytics_engineer/deep_phases.md +818 -0
  42. package/src/agents/specific_instructions/analytics_engineer/phases_deep/index.md +24 -0
  43. package/src/agents/specific_instructions/analytics_engineer/phases_deep/phase-1.md +77 -0
  44. package/src/agents/specific_instructions/analytics_engineer/phases_deep/phase-2.md +106 -0
  45. package/src/agents/specific_instructions/analytics_engineer/phases_deep/phase-3.md +93 -0
  46. package/src/agents/specific_instructions/analytics_engineer/phases_deep/phase-4.md +79 -0
  47. package/src/agents/specific_instructions/analytics_engineer/phases_deep/phase-5.md +61 -0
  48. package/src/agents/specific_instructions/analytics_engineer/phases_deep/phase-6.md +45 -0
  49. package/src/agents/specific_instructions/analytics_engineer/phases_deep/phase-7.md +235 -0
  50. package/src/agents/specific_instructions/analytics_engineer/phases_deep/phase-8.md +221 -0
  51. package/src/agents/specific_instructions/analytics_engineer/phases_quick/index.md +19 -0
  52. package/src/agents/specific_instructions/analytics_engineer/phases_quick/phase-1.md +47 -0
  53. package/src/agents/specific_instructions/analytics_engineer/phases_quick/phase-2.md +78 -0
  54. package/src/agents/specific_instructions/analytics_engineer/quick_phases.md +112 -0
  55. package/src/agents/specific_instructions/analytics_engineer/review.md +167 -0
  56. package/src/agents/specific_instructions/analytics_engineer/service_mode.md +369 -0
  57. package/src/agents/specific_instructions/analytics_engineer/ui_mode.md +45 -0
  58. package/src/agents/specific_instructions/analytics_engineer/update.md +162 -0
  59. package/src/agents/specific_instructions/analytics_engineer/validation_checklist.md +121 -0
  60. package/src/agents/specific_instructions/applied_ml_scientist/advise.md +143 -0
  61. package/src/agents/specific_instructions/applied_ml_scientist/phases/index.md +21 -0
  62. package/src/agents/specific_instructions/applied_ml_scientist/phases/phase-1.md +51 -0
  63. package/src/agents/specific_instructions/applied_ml_scientist/phases/phase-2.md +66 -0
  64. package/src/agents/specific_instructions/applied_ml_scientist/phases/phase-3.md +113 -0
  65. package/src/agents/specific_instructions/applied_ml_scientist/phases/phase-4.md +104 -0
  66. package/src/agents/specific_instructions/applied_ml_scientist/phases/phase-5.md +156 -0
  67. package/src/agents/specific_instructions/applied_ml_scientist/phases.md +428 -0
  68. package/src/agents/specific_instructions/applied_ml_scientist/research.md +379 -0
  69. package/src/agents/specific_instructions/applied_ml_scientist/review.md +142 -0
  70. package/src/agents/specific_instructions/applied_ml_scientist/validation_checklist.md +136 -0
  71. package/src/agents/specific_instructions/backend_engineer/clean.md +149 -0
  72. package/src/agents/specific_instructions/backend_engineer/review.md +91 -0
  73. package/src/agents/specific_instructions/backend_engineer/review_checklist.md +54 -0
  74. package/src/agents/specific_instructions/backend_engineer/service_mode.md +67 -0
  75. package/src/agents/specific_instructions/bi_engineer/advise.md +137 -0
  76. package/src/agents/specific_instructions/bi_engineer/data_analyst_handoff.md +77 -0
  77. package/src/agents/specific_instructions/bi_engineer/incoming_handoff.md +45 -0
  78. package/src/agents/specific_instructions/bi_engineer/phases/index.md +20 -0
  79. package/src/agents/specific_instructions/bi_engineer/phases/phase-1.md +164 -0
  80. package/src/agents/specific_instructions/bi_engineer/phases/phase-2.md +92 -0
  81. package/src/agents/specific_instructions/bi_engineer/phases/phase-3.md +121 -0
  82. package/src/agents/specific_instructions/bi_engineer/phases/phase-4.md +106 -0
  83. package/src/agents/specific_instructions/bi_engineer/phases.md +451 -0
  84. package/src/agents/specific_instructions/bi_engineer/review.md +166 -0
  85. package/src/agents/specific_instructions/bi_engineer/update.md +147 -0
  86. package/src/agents/specific_instructions/bi_engineer/validation_checklist.md +124 -0
  87. package/src/agents/specific_instructions/data_analyst/advise.md +138 -0
  88. package/src/agents/specific_instructions/data_analyst/explain.md +221 -0
  89. package/src/agents/specific_instructions/data_analyst/incoming_handoff.md +40 -0
  90. package/src/agents/specific_instructions/data_analyst/phases/index.md +20 -0
  91. package/src/agents/specific_instructions/data_analyst/phases/phase-1.md +159 -0
  92. package/src/agents/specific_instructions/data_analyst/phases/phase-2.md +112 -0
  93. package/src/agents/specific_instructions/data_analyst/phases/phase-3.md +265 -0
  94. package/src/agents/specific_instructions/data_analyst/phases/phase-4.md +100 -0
  95. package/src/agents/specific_instructions/data_analyst/phases.md +501 -0
  96. package/src/agents/specific_instructions/data_analyst/review.md +138 -0
  97. package/src/agents/specific_instructions/data_analyst/ui_mode.md +26 -0
  98. package/src/agents/specific_instructions/data_analyst/update.md +144 -0
  99. package/src/agents/specific_instructions/data_analyst/validation_checklist.md +95 -0
  100. package/src/agents/specific_instructions/data_engineer/advise.md +137 -0
  101. package/src/agents/specific_instructions/data_engineer/phases.md +466 -0
  102. package/src/agents/specific_instructions/data_engineer/phases_deep/index.md +23 -0
  103. package/src/agents/specific_instructions/data_engineer/phases_deep/phase-1.md +49 -0
  104. package/src/agents/specific_instructions/data_engineer/phases_deep/phase-2.md +93 -0
  105. package/src/agents/specific_instructions/data_engineer/phases_deep/phase-3.md +55 -0
  106. package/src/agents/specific_instructions/data_engineer/phases_deep/phase-4.md +48 -0
  107. package/src/agents/specific_instructions/data_engineer/phases_deep/phase-5.md +40 -0
  108. package/src/agents/specific_instructions/data_engineer/phases_deep/phase-6.md +102 -0
  109. package/src/agents/specific_instructions/data_engineer/phases_deep/phase-7.md +87 -0
  110. package/src/agents/specific_instructions/data_engineer/phases_quick/index.md +19 -0
  111. package/src/agents/specific_instructions/data_engineer/phases_quick/phase-1.md +45 -0
  112. package/src/agents/specific_instructions/data_engineer/phases_quick/phase-2.md +54 -0
  113. package/src/agents/specific_instructions/data_engineer/review.md +135 -0
  114. package/src/agents/specific_instructions/data_engineer/validation_checklist.md +136 -0
  115. package/src/agents/specific_instructions/data_modeller/advise.md +137 -0
  116. package/src/agents/specific_instructions/data_modeller/phases.md +581 -0
  117. package/src/agents/specific_instructions/data_modeller/phases_deep/index.md +23 -0
  118. package/src/agents/specific_instructions/data_modeller/phases_deep/phase-1.md +52 -0
  119. package/src/agents/specific_instructions/data_modeller/phases_deep/phase-2.md +113 -0
  120. package/src/agents/specific_instructions/data_modeller/phases_deep/phase-3.md +47 -0
  121. package/src/agents/specific_instructions/data_modeller/phases_deep/phase-4.md +51 -0
  122. package/src/agents/specific_instructions/data_modeller/phases_deep/phase-5.md +45 -0
  123. package/src/agents/specific_instructions/data_modeller/phases_deep/phase-6.md +105 -0
  124. package/src/agents/specific_instructions/data_modeller/phases_deep/phase-7.md +136 -0
  125. package/src/agents/specific_instructions/data_modeller/phases_quick/index.md +19 -0
  126. package/src/agents/specific_instructions/data_modeller/phases_quick/phase-1.md +47 -0
  127. package/src/agents/specific_instructions/data_modeller/phases_quick/phase-2.md +65 -0
  128. package/src/agents/specific_instructions/data_modeller/review.md +141 -0
  129. package/src/agents/specific_instructions/data_modeller/service_mode.md +218 -0
  130. package/src/agents/specific_instructions/data_modeller/validation_checklist.md +125 -0
  131. package/src/agents/specific_instructions/data_scientist/advise.md +158 -0
  132. package/src/agents/specific_instructions/data_scientist/bi_engineer_handoff.md +63 -0
  133. package/src/agents/specific_instructions/data_scientist/experiment.md +482 -0
  134. package/src/agents/specific_instructions/data_scientist/experiment_ui_mode.md +44 -0
  135. package/src/agents/specific_instructions/data_scientist/explain.md +247 -0
  136. package/src/agents/specific_instructions/data_scientist/greenfield_data.md +35 -0
  137. package/src/agents/specific_instructions/data_scientist/ml_engineer_handoff.md +52 -0
  138. package/src/agents/specific_instructions/data_scientist/notebook_walkthrough.md +76 -0
  139. package/src/agents/specific_instructions/data_scientist/phases/index.md +24 -0
  140. package/src/agents/specific_instructions/data_scientist/phases/phase-1.md +45 -0
  141. package/src/agents/specific_instructions/data_scientist/phases/phase-2.md +67 -0
  142. package/src/agents/specific_instructions/data_scientist/phases/phase-3.md +89 -0
  143. package/src/agents/specific_instructions/data_scientist/phases/phase-4.md +143 -0
  144. package/src/agents/specific_instructions/data_scientist/phases/phase-5.md +71 -0
  145. package/src/agents/specific_instructions/data_scientist/phases/phase-6.md +239 -0
  146. package/src/agents/specific_instructions/data_scientist/phases/phase-7.md +207 -0
  147. package/src/agents/specific_instructions/data_scientist/phases.md +651 -0
  148. package/src/agents/specific_instructions/data_scientist/research.md +345 -0
  149. package/src/agents/specific_instructions/data_scientist/research_ui_mode.md +52 -0
  150. package/src/agents/specific_instructions/data_scientist/review.md +136 -0
  151. package/src/agents/specific_instructions/data_scientist/service_mode.md +247 -0
  152. package/src/agents/specific_instructions/data_scientist/validation_checklist.md +183 -0
  153. package/src/agents/specific_instructions/deep_learning_engineer/advise.md +145 -0
  154. package/src/agents/specific_instructions/deep_learning_engineer/phases/index.md +21 -0
  155. package/src/agents/specific_instructions/deep_learning_engineer/phases/phase-1.md +74 -0
  156. package/src/agents/specific_instructions/deep_learning_engineer/phases/phase-2.md +98 -0
  157. package/src/agents/specific_instructions/deep_learning_engineer/phases/phase-3.md +76 -0
  158. package/src/agents/specific_instructions/deep_learning_engineer/phases/phase-4.md +128 -0
  159. package/src/agents/specific_instructions/deep_learning_engineer/phases/phase-5.md +292 -0
  160. package/src/agents/specific_instructions/deep_learning_engineer/phases.md +567 -0
  161. package/src/agents/specific_instructions/deep_learning_engineer/research.md +389 -0
  162. package/src/agents/specific_instructions/deep_learning_engineer/review.md +155 -0
  163. package/src/agents/specific_instructions/deep_learning_engineer/validation_checklist.md +147 -0
  164. package/src/agents/specific_instructions/ml_engineer/advise.md +174 -0
  165. package/src/agents/specific_instructions/ml_engineer/bi_engineer_handoff.md +71 -0
  166. package/src/agents/specific_instructions/ml_engineer/experiment.md +474 -0
  167. package/src/agents/specific_instructions/ml_engineer/experiment_ui_mode.md +44 -0
  168. package/src/agents/specific_instructions/ml_engineer/notebook_walkthrough.md +75 -0
  169. package/src/agents/specific_instructions/ml_engineer/phases/index.md +25 -0
  170. package/src/agents/specific_instructions/ml_engineer/phases/phase-1.md +49 -0
  171. package/src/agents/specific_instructions/ml_engineer/phases/phase-2.md +75 -0
  172. package/src/agents/specific_instructions/ml_engineer/phases/phase-3.md +124 -0
  173. package/src/agents/specific_instructions/ml_engineer/phases/phase-4.md +279 -0
  174. package/src/agents/specific_instructions/ml_engineer/phases/phase-5.md +160 -0
  175. package/src/agents/specific_instructions/ml_engineer/phases/phase-6-5.md +170 -0
  176. package/src/agents/specific_instructions/ml_engineer/phases/phase-6.md +295 -0
  177. package/src/agents/specific_instructions/ml_engineer/phases/phase-7.md +337 -0
  178. package/src/agents/specific_instructions/ml_engineer/phases.md +1068 -0
  179. package/src/agents/specific_instructions/ml_engineer/research.md +437 -0
  180. package/src/agents/specific_instructions/ml_engineer/research_ui_mode.md +71 -0
  181. package/src/agents/specific_instructions/ml_engineer/review.md +187 -0
  182. package/src/agents/specific_instructions/ml_engineer/service_mode.md +273 -0
  183. package/src/agents/specific_instructions/ml_engineer/validation_checklist.md +185 -0
  184. package/src/agents/specific_instructions/mlops_engineer/advise.md +139 -0
  185. package/src/agents/specific_instructions/mlops_engineer/phases/index.md +23 -0
  186. package/src/agents/specific_instructions/mlops_engineer/phases/phase-1.md +52 -0
  187. package/src/agents/specific_instructions/mlops_engineer/phases/phase-2.md +86 -0
  188. package/src/agents/specific_instructions/mlops_engineer/phases/phase-3.md +105 -0
  189. package/src/agents/specific_instructions/mlops_engineer/phases/phase-4.md +128 -0
  190. package/src/agents/specific_instructions/mlops_engineer/phases/phase-5.md +106 -0
  191. package/src/agents/specific_instructions/mlops_engineer/phases/phase-6.md +128 -0
  192. package/src/agents/specific_instructions/mlops_engineer/phases/phase-7.md +144 -0
  193. package/src/agents/specific_instructions/mlops_engineer/phases.md +671 -0
  194. package/src/agents/specific_instructions/mlops_engineer/review.md +164 -0
  195. package/src/agents/specific_instructions/mlops_engineer/service_mode.md +81 -0
  196. package/src/agents/specific_instructions/mlops_engineer/validation_checklist.md +151 -0
  197. package/src/agents/specific_instructions/researcher/critical_review.md +292 -0
  198. package/src/agents/specific_instructions/researcher/review_checklist.md +67 -0
  199. package/src/agents/specific_instructions/researcher/service_mode.md +224 -0
  200. package/src/agents/specific_instructions/shared/auto_verify_mode.md +141 -0
  201. package/src/agents/specific_instructions/shared/autonomous_research.md +1289 -0
  202. package/src/agents/specific_instructions/shared/behavioral_rules.md +36 -0
  203. package/src/agents/specific_instructions/shared/diverge_protocol.md +387 -0
  204. package/src/agents/specific_instructions/shared/engineering_guidelines.md +136 -0
  205. package/src/agents/specific_instructions/shared/experiment_versioning.md +184 -0
  206. package/src/agents/specific_instructions/shared/goal_mode.md +187 -0
  207. package/src/agents/specific_instructions/shared/incremental_testing.md +139 -0
  208. package/src/agents/specific_instructions/shared/intent_discovery.md +223 -0
  209. package/src/agents/specific_instructions/shared/join_path_protocol.md +168 -0
  210. package/src/agents/specific_instructions/shared/knowledge_checkpoint.md +83 -0
  211. package/src/agents/specific_instructions/shared/knowledge_harvest.md +220 -0
  212. package/src/agents/specific_instructions/shared/knowledge_retrieval.md +100 -0
  213. package/src/agents/specific_instructions/shared/notebook_walkthrough_protocol.md +367 -0
  214. package/src/agents/specific_instructions/shared/reviewer_verdict_protocol.md +74 -0
  215. package/src/agents/specific_instructions/shared/swarm_protocol.md +97 -0
  216. package/src/agents/specific_instructions/shared/validation_protocol.md +139 -0
  217. package/src/agents/specific_instructions/syn/arbiter.md +140 -0
  218. package/src/agents/specific_instructions/syn/brainstorm.md +550 -0
  219. package/src/agents/specific_instructions/syn/code_review.md +232 -0
  220. package/src/agents/specific_instructions/syn/diff.md +239 -0
  221. package/src/agents/specific_instructions/syn/final_review.md +65 -0
  222. package/src/agents/specific_instructions/syn/fixer.md +240 -0
  223. package/src/agents/specific_instructions/syn/free_form.md +130 -0
  224. package/src/agents/specific_instructions/syn/knowledge.md +468 -0
  225. package/src/agents/specific_instructions/syn/notebook_walkthrough.md +78 -0
  226. package/src/agents/specific_instructions/syn/panel_review.md +634 -0
  227. package/src/agents/specific_instructions/syn/pm.md +453 -0
  228. package/src/agents/specific_instructions/syn/pr_review.md +255 -0
  229. package/src/agents/specific_instructions/syn/slides.md +417 -0
  230. package/src/agents/syn.md +729 -0
  231. package/src/commands/academic.md +41 -0
  232. package/src/commands/ai-engineer.md +45 -0
  233. package/src/commands/analytics-engineer.md +48 -0
  234. package/src/commands/applied-ml-scientist.md +45 -0
  235. package/src/commands/backend-engineer.md +35 -0
  236. package/src/commands/bi-engineer.md +40 -0
  237. package/src/commands/brainstorm.md +24 -0
  238. package/src/commands/data-analyst.md +38 -0
  239. package/src/commands/data-engineer.md +37 -0
  240. package/src/commands/data-modeller.md +38 -0
  241. package/src/commands/data-scientist.md +38 -0
  242. package/src/commands/deep-learning-engineer.md +47 -0
  243. package/src/commands/end.md +49 -0
  244. package/src/commands/knowledge.md +24 -0
  245. package/src/commands/ml-engineer.md +42 -0
  246. package/src/commands/mlops-engineer.md +47 -0
  247. package/src/commands/notebook-walkthrough.md +58 -0
  248. package/src/commands/researcher.md +40 -0
  249. package/src/commands/resume.md +57 -0
  250. package/src/commands/review-pr.md +26 -0
  251. package/src/commands/shards-guide.md +41 -0
  252. package/src/commands/shards-ui.md +32 -0
  253. package/src/commands/shards.md +41 -0
  254. package/src/docs/01-getting-started/concepts.md +109 -0
  255. package/src/docs/01-getting-started/first-session.md +79 -0
  256. package/src/docs/01-getting-started/install.md +61 -0
  257. package/src/docs/02-agents/academic.md +71 -0
  258. package/src/docs/02-agents/ai-engineer.md +78 -0
  259. package/src/docs/02-agents/analytics-engineer.md +58 -0
  260. package/src/docs/02-agents/applied-ml-scientist.md +59 -0
  261. package/src/docs/02-agents/backend-engineer.md +58 -0
  262. package/src/docs/02-agents/bi-engineer.md +65 -0
  263. package/src/docs/02-agents/data-analyst.md +67 -0
  264. package/src/docs/02-agents/data-engineer.md +57 -0
  265. package/src/docs/02-agents/data-modeller.md +51 -0
  266. package/src/docs/02-agents/data-scientist.md +78 -0
  267. package/src/docs/02-agents/deep-learning-engineer.md +64 -0
  268. package/src/docs/02-agents/ml-engineer.md +80 -0
  269. package/src/docs/02-agents/mlops-engineer.md +59 -0
  270. package/src/docs/02-agents/overview.md +62 -0
  271. package/src/docs/02-agents/researcher.md +73 -0
  272. package/src/docs/02-agents/syn.md +88 -0
  273. package/src/docs/03-protocols/auto-verify.md +82 -0
  274. package/src/docs/03-protocols/autonomous-research.md +59 -0
  275. package/src/docs/03-protocols/behavioral-rules.md +35 -0
  276. package/src/docs/03-protocols/diverge.md +50 -0
  277. package/src/docs/03-protocols/engineering-guidelines.md +56 -0
  278. package/src/docs/03-protocols/experiment-versioning.md +38 -0
  279. package/src/docs/03-protocols/gate-pattern.md +65 -0
  280. package/src/docs/03-protocols/incremental-testing.md +68 -0
  281. package/src/docs/03-protocols/join-path.md +46 -0
  282. package/src/docs/03-protocols/knowledge-ledger.md +70 -0
  283. package/src/docs/03-protocols/reviewer-verdicts.md +39 -0
  284. package/src/docs/03-protocols/swarm.md +40 -0
  285. package/src/docs/03-protocols/validation.md +174 -0
  286. package/src/docs/04-ui/activity-bar.md +70 -0
  287. package/src/docs/04-ui/chat-pane.md +80 -0
  288. package/src/docs/04-ui/code-intel.md +62 -0
  289. package/src/docs/04-ui/file-editing.md +61 -0
  290. package/src/docs/04-ui/git.md +54 -0
  291. package/src/docs/04-ui/keybindings.md +79 -0
  292. package/src/docs/04-ui/knowledge-map.md +76 -0
  293. package/src/docs/04-ui/overview.md +93 -0
  294. package/src/docs/04-ui/panels.md +49 -0
  295. package/src/docs/04-ui/pinboard-selection.md +66 -0
  296. package/src/docs/04-ui/quick-open-palette.md +56 -0
  297. package/src/docs/04-ui/sessions.md +81 -0
  298. package/src/docs/04-ui/settings-permissions.md +56 -0
  299. package/src/docs/05-commands/reference.md +59 -0
  300. package/src/docs/06-outputs/directory-map.md +116 -0
  301. package/src/docs/07-workflows/ai-eval-first.md +57 -0
  302. package/src/docs/07-workflows/deep-study-to-production.md +76 -0
  303. package/src/docs/07-workflows/diverge-exploration.md +77 -0
  304. package/src/docs/07-workflows/quick-analysis.md +45 -0
  305. package/src/docs/08-integrations/claude-code-auto-mode.md +191 -0
  306. package/src/docs/08-integrations/google-slides.md +175 -0
  307. package/src/docs/README.md +30 -0
  308. package/src/docs/manifest.json +108 -0
  309. package/src/templates/analysis-template.md +20 -0
  310. package/src/templates/branch-report.md +46 -0
  311. package/src/templates/diff-report.md +88 -0
  312. package/src/templates/knowledge-index.md +7 -0
  313. package/src/templates/model-card-schema.json +186 -0
  314. package/src/templates/model-card-schema.md +88 -0
  315. package/src/templates/model-card.md +124 -0
  316. package/src/templates/project-plan.md +47 -0
  317. package/src/templates/project-specs.md +81 -0
  318. package/src/templates/report-template.md +43 -0
  319. package/src/templates/study-template.md +25 -0
  320. package/src/ui/cc-readonly.js +181 -0
  321. package/src/ui/chat-session.js +466 -0
  322. package/src/ui/css/base.css +136 -0
  323. package/src/ui/css/brainstorm.css +525 -0
  324. package/src/ui/css/chat.css +1405 -0
  325. package/src/ui/css/editor.css +546 -0
  326. package/src/ui/css/eval-dashboard.css +157 -0
  327. package/src/ui/css/experiment.css +237 -0
  328. package/src/ui/css/guide.css +186 -0
  329. package/src/ui/css/knowledge-map.css +383 -0
  330. package/src/ui/css/layout.css +431 -0
  331. package/src/ui/css/model-card.css +161 -0
  332. package/src/ui/css/notebook-walkthrough.css +271 -0
  333. package/src/ui/css/pr-review.css +403 -0
  334. package/src/ui/css/prompt-lab.css +325 -0
  335. package/src/ui/css/sessions.css +258 -0
  336. package/src/ui/css/sidebar.css +661 -0
  337. package/src/ui/css/terminal.css +113 -0
  338. package/src/ui/css/theme-light.css +542 -0
  339. package/src/ui/index.html +389 -0
  340. package/src/ui/js/agents.js +32 -0
  341. package/src/ui/js/bookmarks.js +230 -0
  342. package/src/ui/js/chat.js +1776 -0
  343. package/src/ui/js/code-intel.js +328 -0
  344. package/src/ui/js/command-palette.js +142 -0
  345. package/src/ui/js/events.js +591 -0
  346. package/src/ui/js/explorer.js +317 -0
  347. package/src/ui/js/file-view.js +477 -0
  348. package/src/ui/js/git.js +536 -0
  349. package/src/ui/js/guide.js +198 -0
  350. package/src/ui/js/hud.js +75 -0
  351. package/src/ui/js/init.js +351 -0
  352. package/src/ui/js/knowledge-map.js +906 -0
  353. package/src/ui/js/markdown.js +114 -0
  354. package/src/ui/js/monaco.js +164 -0
  355. package/src/ui/js/notebook-walkthrough.js +272 -0
  356. package/src/ui/js/notebook.js +448 -0
  357. package/src/ui/js/panels.js +2681 -0
  358. package/src/ui/js/pinboard.js +186 -0
  359. package/src/ui/js/quick-open.js +164 -0
  360. package/src/ui/js/selection-context.js +131 -0
  361. package/src/ui/js/sessions.js +256 -0
  362. package/src/ui/js/settings.js +476 -0
  363. package/src/ui/js/split-view.js +82 -0
  364. package/src/ui/js/state.js +343 -0
  365. package/src/ui/js/table.js +161 -0
  366. package/src/ui/js/tabs.js +284 -0
  367. package/src/ui/js/tabular.js +125 -0
  368. package/src/ui/js/terminal.js +354 -0
  369. package/src/ui/js/timeline.js +137 -0
  370. package/src/ui/js/utils.js +293 -0
  371. package/src/ui/notebook-kernel.py +790 -0
  372. package/src/ui/open-browser.js +55 -0
  373. package/src/ui/permission-pattern.js +42 -0
  374. package/src/ui/relay.js +513 -0
  375. package/src/ui/server.js +3072 -0
  376. package/src/ui/session-index.js +225 -0
  377. package/src/ui/shards_icon.png +0 -0
  378. package/src/ui/spawn-server.js +41 -0
  379. package/src/ui/symbol-index.js +813 -0
  380. package/src/ui/ui-push.js +177 -0
  381. package/tools/gate-hook/VALIDATION_SPEC.md +273 -0
  382. package/tools/gate-hook/__tests__/auto-verify.test.js +343 -0
  383. package/tools/gate-hook/auto-allowlist.js +179 -0
  384. package/tools/gate-hook/auto-state.js +68 -0
  385. package/tools/gate-hook/classify.js +21 -0
  386. package/tools/gate-hook/log.js +57 -0
  387. package/tools/gate-hook/parser.js +205 -0
  388. package/tools/gate-hook/sql-guard.js +230 -0
  389. package/tools/gate-hook/state.js +170 -0
  390. package/tools/gate-hook/sweep.js +139 -0
  391. package/tools/gate-hook/transcript.js +45 -0
  392. package/tools/gate-hook/validation.js +321 -0
  393. package/tools/gate-hook.js +475 -0
  394. package/tools/install.js +914 -0
  395. package/tools/shards-gates.js +311 -0
  396. package/tools/shards-sessions.js +261 -0
  397. package/tools/shards-ui.js +377 -0
@@ -0,0 +1,3072 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ const http = require('http');
6
+ const https = require('https');
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const os = require('os');
10
+ const { execSync, execFileSync } = require('child_process');
11
+ const { randomUUID } = require('crypto');
12
+ const { ChatSession, reconnectOrCleanup } = require('./chat-session');
13
+ const symbolIndex = require('./symbol-index');
14
+ const { permissionPattern } = require('./permission-pattern');
15
+ const { isCcReadOnlyAutoApprovable } = require('./cc-readonly');
16
+ const sessionIndex = require('./session-index');
17
+
18
+ let PROJECT_DIR = process.cwd();
19
+ let SHARDS_DIR = path.join(PROJECT_DIR, '.shards');
20
+ let PORT_FILE = path.join(SHARDS_DIR, 'ui.port');
21
+ let PID_FILE = path.join(SHARDS_DIR, 'ui.pid');
22
+ let LOG_FILE = path.join(SHARDS_DIR, 'ui.log');
23
+ let AGENTS_DIR = path.join(PROJECT_DIR, '.claude', 'agents');
24
+ let COMMANDS_DIR = path.join(PROJECT_DIR, '.claude', 'commands');
25
+ let SESSIONS_DIR = path.join(SHARDS_DIR, 'sessions');
26
+ let KNOWLEDGE_DIR = path.join(SHARDS_DIR, 'knowledge');
27
+ let INDEX_HTML = path.join(__dirname, 'index.html');
28
+
29
+ function initPaths(projectDir, options = {}) {
30
+ PROJECT_DIR = projectDir;
31
+ SHARDS_DIR = path.join(PROJECT_DIR, '.shards');
32
+ PORT_FILE = path.join(SHARDS_DIR, 'ui.port');
33
+ PID_FILE = path.join(SHARDS_DIR, 'ui.pid');
34
+ LOG_FILE = path.join(SHARDS_DIR, 'ui.log');
35
+ AGENTS_DIR = path.join(PROJECT_DIR, '.claude', 'agents');
36
+ COMMANDS_DIR = path.join(PROJECT_DIR, '.claude', 'commands');
37
+ SESSIONS_DIR = path.join(SHARDS_DIR, 'sessions');
38
+ KNOWLEDGE_DIR = path.join(SHARDS_DIR, 'knowledge');
39
+ const uiDir = options.uiDir || __dirname;
40
+ INDEX_HTML = path.join(uiDir, 'index.html');
41
+ ELECTRON_MODE = !!options.electronMode;
42
+ VENDOR_DIR = options.vendorDir || null;
43
+ }
44
+
45
+ let ELECTRON_MODE = false;
46
+ let VENDOR_DIR = null;
47
+
48
+ // ─── Logging ─────────────────────────────────────────────────────────────────
49
+
50
+ function log(msg) {
51
+ const ts = new Date().toISOString();
52
+ const line = `[${ts}] ${msg}\n`;
53
+ try { fs.appendFileSync(LOG_FILE, line); } catch {}
54
+ }
55
+
56
+ const PORTS = [7842, 7843, 7844, 7845];
57
+ const OUTPUT_DIRS = ['analysis', 'studies', 'models', 'services', 'research', 'dashboards', 'brainstorm', 'data_models', 'experiments'];
58
+
59
+ // ─── P1: Auth token ──────────────────────────────────────────────────────────
60
+
61
+ const AUTH_TOKEN = randomUUID();
62
+
63
+ // ─── P5: TLS configuration ──────────────────────────────────────────────────
64
+
65
+ const TLS_CERT = process.env.SHARDS_TLS_CERT || null;
66
+ const TLS_KEY = process.env.SHARDS_TLS_KEY || null;
67
+ const BIND_ADDR = process.env.SHARDS_BIND || '127.0.0.1';
68
+ const TRUST_PROXY = process.env.SHARDS_TRUST_PROXY === '1';
69
+
70
+ // ─── File-poll guards (see pollFiles / scanDir) ─────────────────────────────
71
+
72
+ const POLL_MAX_BYTES = Number(process.env.SHARDS_POLL_MAX_BYTES) || 2 * 1024 * 1024; // 2 MB
73
+ const POLL_SKIP_EXT = new Set([
74
+ // Binary data formats
75
+ '.pkl', '.pickle', '.parquet', '.feather', '.h5', '.hdf5',
76
+ '.npy', '.npz', '.arrow', '.orc',
77
+ // Archives
78
+ '.zip', '.gz', '.tgz', '.tar', '.bz2', '.xz', '.7z',
79
+ // Compiled / opaque
80
+ '.so', '.dylib', '.dll', '.exe', '.bin',
81
+ // Images & PDFs (served via /browse/file/raw, no need to broadcast)
82
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico', '.svg', '.pdf',
83
+ // Media
84
+ '.mp4', '.mov', '.mp3', '.wav',
85
+ ]);
86
+ const pollSkipWarned = new Set();
87
+
88
+ // ─── P3: Session Store ──────────────────────────────────────────────────────
89
+
90
+ // Output dirs Shards specialists write under — used to derive a project slug
91
+ // from a touched file path (e.g. analysis/churn-q4/queries.sql → "churn-q4").
92
+ const PROJECT_OUTPUT_DIRS = [
93
+ 'analysis', 'studies', 'models', 'services', 'data_models', 'research',
94
+ 'dashboards', 'brainstorm', 'experiments', 'fixes', 'presentations', 'projects',
95
+ ];
96
+ const PROJECT_PATH_RE = new RegExp(`^(${PROJECT_OUTPUT_DIRS.join('|')})/([^/]+)/`);
97
+
98
+ const AGENT_META = {
99
+ 'syn': { color: '#FFD700', label: 'Syn (Orchestrator)', category: 'route', keywords: [] },
100
+ 'data-analyst': { color: '#4CAF50', label: 'Data Analyst', category: 'analytics', keywords: ['sql', 'query', 'adhoc'] },
101
+ 'data-scientist': { color: '#2196F3', label: 'Data Scientist', category: 'data', keywords: ['eda', 'exploratory', 'statistical'] },
102
+ 'data-engineer': { color: '#FF9800', label: 'Data Engineer', category: 'data', keywords: ['pipeline', 'etl', 'warehouse'] },
103
+ 'data-modeller': { color: '#00BCD4', label: 'Data Modeller', category: 'data', keywords: ['schema', 'erd', 'dimensional'] },
104
+ 'analytics-engineer': { color: '#8BC34A', label: 'Analytics Engineer', category: 'data', keywords: ['dbt', 'transform', 'staging'] },
105
+ 'ml-engineer': { color: '#F44336', label: 'ML Engineer', category: 'mlai', keywords: ['recommender', 'classification', 'production ml'] },
106
+ 'ai-engineer': { color: '#9C27B0', label: 'AI Engineer', category: 'mlai', keywords: ['llm', 'rag', 'prompt'] },
107
+ 'applied-ml-scientist': { color: '#673AB7', label: 'Applied ML Scientist', category: 'mlai', keywords: ['novel', 'research', 'architecture'] },
108
+ 'deep-learning-engineer': { color: '#03A9F4', label: 'Deep Learning Engineer', category: 'mlai', keywords: ['neural', 'pytorch', 'transformer'] },
109
+ 'mlops-engineer': { color: '#FF5722', label: 'MLOps Engineer', category: 'mlai', keywords: ['deploy', 'serving', 'monitoring'] },
110
+ 'bi-engineer': { color: '#E91E63', label: 'BI Engineer', category: 'analytics', keywords: ['dashboard', 'visualization', 'streamlit'] },
111
+ 'backend-engineer': { color: '#9E9E9E', label: 'Backend Engineer', category: 'review', keywords: ['python', 'fastapi', 'code review'] },
112
+ 'researcher': { color: '#795548', label: 'Researcher', category: 'review', keywords: ['statistics', 'methodology'] },
113
+ 'academic': { color: '#607D8B', label: 'Academic', category: 'review', keywords: ['safety', 'ethics', 'bias'] },
114
+ };
115
+
116
+ function detectProjectFromRelPath(relPath) {
117
+ if (!relPath || relPath.startsWith('..')) return null;
118
+ const norm = relPath.replace(/\\/g, '/');
119
+ const m = norm.match(PROJECT_PATH_RE);
120
+ if (!m) return null;
121
+ return { projectDir: `${m[1]}/${m[2]}`, projectName: m[2] };
122
+ }
123
+
124
+ // Read the gate state file and reduce it to a (phase, gateOpenAtEnd) snapshot
125
+ // suitable for stamping into the session index. We treat the "current phase"
126
+ // as: the open gate's phase if one is open, otherwise the most recent closed
127
+ // gate's phase. Returns nulls if there's no gate activity to report.
128
+ function readGateSnapshot(shardsDir) {
129
+ const file = path.join(shardsDir, 'gates', 'state.json');
130
+ try {
131
+ const raw = fs.readFileSync(file, 'utf8');
132
+ const state = JSON.parse(raw);
133
+ // Reduce to the single most-recent open gate. Handles both the legacy
134
+ // single-slot shape ({ open, ... }) and the v2 per-session shape
135
+ // ({ version: 2, sessions: { ... } }).
136
+ const open = reduceOpenGate(state);
137
+ if (open) {
138
+ return { phase: typeof open.phase === 'number' ? open.phase : null, gateOpenAtEnd: open.id || null };
139
+ }
140
+ const history = Array.isArray(state && state.history) ? state.history : [];
141
+ for (let i = history.length - 1; i >= 0; i--) {
142
+ const h = history[i];
143
+ if (h && typeof h.phase === 'number') return { phase: h.phase, gateOpenAtEnd: null };
144
+ }
145
+ return { phase: null, gateOpenAtEnd: null };
146
+ } catch {
147
+ return { phase: null, gateOpenAtEnd: null };
148
+ }
149
+ }
150
+
151
+ class SessionStore {
152
+ constructor({ sessionId, agent }) {
153
+ this.sessionId = sessionId;
154
+ this.agent = agent;
155
+ this.title = null;
156
+ this.transcript = [];
157
+ this.sessionFiles = new Set();
158
+ this.chatSession = null;
159
+ this.createdAt = new Date();
160
+ this.lastActivityAt = new Date();
161
+ this.projectName = null;
162
+ this.projectDir = null;
163
+ this.contextUsage = null;
164
+
165
+ // Attempt to load existing session data from disk
166
+ try {
167
+ const filePath = path.join(SESSIONS_DIR, `${sessionId}-transcript.json`);
168
+ if (fs.existsSync(filePath)) {
169
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
170
+ if (data.transcript) this.transcript = data.transcript;
171
+ if (data.title) this.title = data.title;
172
+ if (data.createdAt) this.createdAt = new Date(data.createdAt);
173
+ if (data.lastActivityAt) this.lastActivityAt = new Date(data.lastActivityAt);
174
+ if (data.projectName) this.projectName = data.projectName;
175
+ if (data.projectDir) this.projectDir = data.projectDir;
176
+ if (data.contextUsage) this.contextUsage = data.contextUsage;
177
+ }
178
+ } catch (e) {
179
+ // Ignore errors loading session data — starts fresh
180
+ }
181
+ }
182
+
183
+ touch() {
184
+ this.lastActivityAt = new Date();
185
+ }
186
+
187
+ // Returns true if projectName/projectDir changed (caller should broadcast).
188
+ setProjectFromRelPath(relPath) {
189
+ if (this.projectName) return false;
190
+ const detected = detectProjectFromRelPath(relPath);
191
+ if (!detected) return false;
192
+ this.projectName = detected.projectName;
193
+ this.projectDir = detected.projectDir;
194
+ return true;
195
+ }
196
+
197
+ save() {
198
+ try {
199
+ if (!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true });
200
+ const filePath = path.join(SESSIONS_DIR, `${this.sessionId}-transcript.json`);
201
+ fs.writeFileSync(filePath, JSON.stringify({
202
+ sessionId: this.sessionId,
203
+ agent: this.agent,
204
+ title: this.title,
205
+ transcript: this.transcript,
206
+ createdAt: this.createdAt,
207
+ lastActivityAt: this.lastActivityAt,
208
+ projectName: this.projectName,
209
+ projectDir: this.projectDir,
210
+ contextUsage: this.contextUsage,
211
+ }, null, 2));
212
+ } catch (err) {
213
+ log(`Error saving session ${this.sessionId}: ${err.message}`);
214
+ }
215
+ }
216
+ }
217
+
218
+ const sessions = new Map(); // sessionId -> SessionStore
219
+
220
+ function getSession(sessionId) {
221
+ const s = sessions.get(sessionId);
222
+ if (s) s.touch();
223
+ return s || null;
224
+ }
225
+
226
+ // ─── Permission request store ────────────────────────────────────────────────
227
+
228
+ const permissionRequests = new Map();
229
+ // id → { id, tool, command, sessionId, decision: null|'allow'|'deny', createdAt }
230
+
231
+ // Pending entries used to be retained indefinitely "in case the user comes
232
+ // back to decide" — but a permission card whose browser session ended (closed
233
+ // tab, refresh, abandoned chat) is never going to be decided. The relay
234
+ // polling that entry will sit in `pending` for its full poll timeout
235
+ // (SHARDS_UI_PERMISSION_TIMEOUT_MS, default 10 min) and the entry itself never
236
+ // frees. Cleanup now removes pending entries that have exceeded the relay's
237
+ // poll timeout — by then the relay has already fallen through to CC's native
238
+ // prompt, so the entry serves no purpose. Decided entries still age out at
239
+ // the 5-minute mark.
240
+ const DECIDED_TTL_MS = 5 * 60 * 1000;
241
+ const PENDING_TTL_MS = 11 * 60 * 1000; // relay default + 1min buffer
242
+ setInterval(() => {
243
+ const now = Date.now();
244
+ for (const [id, req] of permissionRequests) {
245
+ const ttl = req.decision === null ? PENDING_TTL_MS : DECIDED_TTL_MS;
246
+ if (now - req.createdAt > ttl) permissionRequests.delete(id);
247
+ }
248
+ }, 60_000);
249
+
250
+ // ─── Settings cache for permission fast paths ───────────────────────────────
251
+
252
+ const READONLY_PRESET = [
253
+ // Git read-only
254
+ 'Bash(git log:*)', 'Bash(git status:*)', 'Bash(git diff:*)',
255
+ 'Bash(git branch:*)', 'Bash(git show:*)', 'Bash(git rev-parse:*)',
256
+ 'Bash(git remote:*)', 'Bash(git tag:*)', 'Bash(git stash list:*)',
257
+ // File exploration
258
+ 'Bash(find:*)', 'Bash(ls:*)', 'Bash(tree:*)', 'Bash(file:*)',
259
+ 'Bash(stat:*)', 'Bash(du:*)', 'Bash(df:*)',
260
+ // File reading
261
+ 'Bash(cat:*)', 'Bash(head:*)', 'Bash(tail:*)', 'Bash(less:*)',
262
+ // Search
263
+ 'Bash(grep:*)', 'Bash(rg:*)', 'Bash(ag:*)', 'Bash(fzf:*)',
264
+ // Inspection
265
+ 'Bash(wc:*)', 'Bash(echo:*)', 'Bash(pwd:*)', 'Bash(which:*)',
266
+ 'Bash(whoami:*)', 'Bash(env:*)', 'Bash(printenv:*)',
267
+ 'Bash(type:*)', 'Bash(command -v:*)', 'Bash(uname:*)',
268
+ // Data inspection
269
+ 'Bash(sort:*)', 'Bash(uniq:*)', 'Bash(cut:*)', 'Bash(awk:*)',
270
+ 'Bash(diff:*)', 'Bash(comm:*)', 'Bash(jq:*)',
271
+ ];
272
+
273
+ let settingsCache = { allow: [], deny: [] };
274
+
275
+ function loadSettingsCache() {
276
+ let allow = [];
277
+ let deny = [];
278
+ let hasSettingsJson = false;
279
+
280
+ // Read .claude/settings.json (project-level, managed by UI)
281
+ try {
282
+ const raw = JSON.parse(fs.readFileSync(
283
+ path.join(PROJECT_DIR, '.claude', 'settings.json'), 'utf8'
284
+ ));
285
+ allow = (raw.permissions && raw.permissions.allow) || [];
286
+ deny = (raw.permissions && raw.permissions.deny) || [];
287
+ hasSettingsJson = true;
288
+ } catch {}
289
+
290
+ // Merge .claude/settings.local.json (user-level, managed by Claude Code)
291
+ try {
292
+ const local = JSON.parse(fs.readFileSync(
293
+ path.join(PROJECT_DIR, '.claude', 'settings.local.json'), 'utf8'
294
+ ));
295
+ const localAllow = (local.permissions && local.permissions.allow) || [];
296
+ const localDeny = (local.permissions && local.permissions.deny) || [];
297
+ for (const entry of localAllow) {
298
+ if (!allow.includes(entry)) allow.push(entry);
299
+ }
300
+ for (const entry of localDeny) {
301
+ if (!deny.includes(entry)) deny.push(entry);
302
+ }
303
+ } catch {}
304
+
305
+ // Fall back to readonly preset when no settings.json exists
306
+ if (!hasSettingsJson && allow.length === 0) {
307
+ allow = [...READONLY_PRESET];
308
+ }
309
+
310
+ settingsCache = { allow, deny };
311
+ }
312
+ loadSettingsCache();
313
+
314
+ // Ensure the PreToolUse hook exists — without it, non-whitelisted Bash commands
315
+ // are denied by Claude Code's internal permission system in stream-json mode
316
+ // and never reach the browser permission card flow.
317
+ //
318
+ // Hook timeout is in seconds. Default Claude Code hook timeout is 600s;
319
+ // we extend to 1800s (30 min) so users have a realistic window to respond
320
+ // to a permission card before Claude Code falls back to its internal rules.
321
+ const PRETOOL_HOOK_TIMEOUT_SEC = 1800;
322
+
323
+ function ensurePreToolUseHook() {
324
+ const settingsPath = path.join(PROJECT_DIR, '.claude', 'settings.json');
325
+ let settings = {};
326
+ try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
327
+
328
+ if (!settings.hooks) settings.hooks = {};
329
+ if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
330
+
331
+ const relayPath = path.join(__dirname, 'relay.js');
332
+ let mutated = false;
333
+
334
+ // Find an existing relay entry if any, and patch its timeout in place
335
+ // so older installations pick up the longer window without manual edits.
336
+ let hasRelay = false;
337
+ for (const entry of settings.hooks.PreToolUse) {
338
+ if (!entry.hooks) continue;
339
+ for (const h of entry.hooks) {
340
+ if (h.command && h.command.includes('relay.js')) {
341
+ hasRelay = true;
342
+ if (h.timeout !== PRETOOL_HOOK_TIMEOUT_SEC) {
343
+ h.timeout = PRETOOL_HOOK_TIMEOUT_SEC;
344
+ mutated = true;
345
+ }
346
+ }
347
+ }
348
+ }
349
+
350
+ if (!hasRelay) {
351
+ settings.hooks.PreToolUse.push({
352
+ matcher: 'Bash',
353
+ hooks: [{
354
+ type: 'command',
355
+ command: `node ${relayPath} pre-tool-use`,
356
+ timeout: PRETOOL_HOOK_TIMEOUT_SEC,
357
+ }],
358
+ });
359
+ mutated = true;
360
+ console.log('[shards-ui] Added missing PreToolUse hook to settings.json');
361
+ }
362
+
363
+ if (mutated) {
364
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
365
+ }
366
+ }
367
+ ensurePreToolUseHook();
368
+
369
+ function matchesPermission(command, patterns) {
370
+ for (const pattern of patterns) {
371
+ const m = pattern.match(/^Bash\((.+)\)$/);
372
+ if (!m) continue;
373
+ const glob = m[1];
374
+ // The colon in Bash(cmd:*) is Claude Code's delimiter between command prefix
375
+ // and argument glob — replace with a pattern matching space-or-end-of-string
376
+ const regex = new RegExp('^' + glob
377
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
378
+ .replace(/:/, '( |$)')
379
+ .replace(/\*/g, '.*') + '$');
380
+ if (regex.test(command)) return true;
381
+ }
382
+ return false;
383
+ }
384
+
385
+ function persistPermission(command, decision) {
386
+ const settingsPath = path.join(PROJECT_DIR, '.claude', 'settings.json');
387
+ let settings = {};
388
+ try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
389
+
390
+ if (!settings.permissions) settings.permissions = {};
391
+ const key = decision === 'allow' ? 'allow' : 'deny';
392
+ if (!settings.permissions[key]) settings.permissions[key] = [];
393
+
394
+ // Persist as a prefix glob (Bash(cmd:*)) rather than the literal command, so
395
+ // clicking Always Allow on `python3 foo.py` also covers `python3 bar.py`.
396
+ // For launcher commands (git, dbt, npm, …) the subcommand is included in the
397
+ // prefix — Bash(git status:*) — so scope doesn't widen to every git call.
398
+ const entry = permissionPattern(command);
399
+ if (!settings.permissions[key].includes(entry)) {
400
+ settings.permissions[key].push(entry);
401
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
402
+ loadSettingsCache();
403
+ }
404
+ }
405
+
406
+ // ─── State ───────────────────────────────────────────────────────────────────
407
+
408
+ let clients = []; // SSE response objects: { res, sessionId (optional filter) }
409
+ let files = {}; // relPath -> content
410
+ let panelSources = {}; // panelId -> { filePath, panel, title, agent, lastContent }
411
+ let panelWatchers = {}; // panelId -> fs.FSWatcher
412
+
413
+ // Dedicated SSE channel for gate-state / gate-block / auto-state events.
414
+ // Kept separate from the main `clients` channel so subscribers that only care
415
+ // about gate transitions don't pay the cost of decoding every tool / file
416
+ // broadcast. Each entry: { res } — no per-session filtering since gates are
417
+ // project-scoped.
418
+ let gateClients = [];
419
+
420
+ // ─── Gate-mode env vars ─────────────────────────────────────────────────────
421
+ // Captured at server startup. The Claude Code hook processes spawn in their
422
+ // own env (which may or may not inherit these), but the UI server can only
423
+ // honestly report what *it* sees. If the user disables enforcement only in
424
+ // the CC side without also exporting it to the UI server, the banner won't
425
+ // fire — documented in the /gate-mode handler below.
426
+ const GATE_ENFORCE = process.env.SHARDS_GATE_ENFORCE !== '0';
427
+ const CHECKPOINT_ENFORCE = process.env.SHARDS_CHECKPOINT_ENFORCE !== '0';
428
+ const AUTO_VERIFY = process.env.SHARDS_AUTO_VERIFY !== '0';
429
+
430
+ // ─── Broadcast ───────────────────────────────────────────────────────────────
431
+
432
+ function broadcast(event) {
433
+ let data;
434
+ try {
435
+ data = `data: ${JSON.stringify(event)}\n\n`;
436
+ } catch (err) {
437
+ const type = event && event.type;
438
+ const path = event && event.path;
439
+ console.error(`broadcast: failed to serialize event type=${type} path=${path}: ${err.message}`);
440
+ return;
441
+ }
442
+ clients = clients.filter((client) => {
443
+ // If client subscribed to a specific session, only send matching events
444
+ if (client.sessionId && event.sessionId && client.sessionId !== event.sessionId) {
445
+ return true; // keep client, just skip this event
446
+ }
447
+ try {
448
+ client.res.write(data);
449
+ return true;
450
+ } catch {
451
+ return false;
452
+ }
453
+ });
454
+ }
455
+
456
+ // ─── Gate SSE broadcast ──────────────────────────────────────────────────────
457
+ //
458
+ // Broadcasts gate-related events (gate-state changes, gate-block reasons,
459
+ // auto-state changes) on a dedicated SSE channel. Replaces the 2s polling the
460
+ // browser previously did on /gate-state — see Bug H2 in the integration audit.
461
+
462
+ function broadcastGate(event) {
463
+ let data;
464
+ try {
465
+ data = `data: ${JSON.stringify(event)}\n\n`;
466
+ } catch (err) {
467
+ log(`broadcastGate: failed to serialize event type=${event && event.type}: ${err.message}`);
468
+ return;
469
+ }
470
+ gateClients = gateClients.filter((client) => {
471
+ try { client.res.write(data); return true; } catch { return false; }
472
+ });
473
+ }
474
+
475
+ // ─── Gate / auto / violations watchers ──────────────────────────────────────
476
+ //
477
+ // fs.watch on the JSON state files (plus violations log). We debounce by 50ms
478
+ // to coalesce mid-write events (atomic-write replaces emit two change events
479
+ // in quick succession on most platforms) and ignore JSON.parse errors so the
480
+ // watcher survives a half-written file — the next change event will retry.
481
+
482
+ const GATE_STATE_FILE = () => path.join(SHARDS_DIR, 'gates', 'state.json');
483
+ const AUTO_STATE_FILE = () => path.join(SHARDS_DIR, 'auto', 'state.json');
484
+ const VIOLATIONS_FILE = () => path.join(SHARDS_DIR, 'gates', 'violations.jsonl');
485
+
486
+ let _gateWatchers = [];
487
+ let _gateStateDebounce = null;
488
+ let _autoStateDebounce = null;
489
+ let _violationsDebounce = null;
490
+ let _violationsOffset = 0;
491
+
492
+ // Reduce a parsed gate-state object to its single most-recent OPEN gate, or
493
+ // null if none is open. Handles the legacy single-slot shape ({ open, id, ...})
494
+ // and the v2 per-session shape ({ version: 2, sessions: { "<id>": {...} } }).
495
+ function reduceOpenGate(state) {
496
+ if (!state || typeof state !== 'object') return null;
497
+ // v2 per-session map.
498
+ if (state.sessions && typeof state.sessions === 'object') {
499
+ let best = null;
500
+ let bestT = -Infinity;
501
+ for (const g of Object.values(state.sessions)) {
502
+ if (!g || !g.open) continue;
503
+ const t = g.opened_at ? Date.parse(g.opened_at) : NaN;
504
+ const score = Number.isNaN(t) ? -Infinity : t;
505
+ if (best === null || score > bestT) { best = g; bestT = score; }
506
+ }
507
+ return best;
508
+ }
509
+ // Legacy single slot.
510
+ return state.open ? state : null;
511
+ }
512
+
513
+ // Reduce a parsed gate-state object to the flat shape the browser expects:
514
+ // { open, id, phase, kind, agent, opened_at, history }. The v2 per-session map
515
+ // collapses to the single most-recent open gate; `history` is always the shared
516
+ // top-level array. This preserves the browser-facing JSON contract unchanged.
517
+ function reduceGateState(state) {
518
+ const history = Array.isArray(state && state.history) ? state.history : [];
519
+ const open = reduceOpenGate(state);
520
+ if (!open) return { open: false, history };
521
+ return {
522
+ open: true,
523
+ id: open.id || null,
524
+ phase: typeof open.phase !== 'undefined' ? open.phase : null,
525
+ kind: open.kind || null,
526
+ agent: open.agent || null,
527
+ opened_at: open.opened_at || null,
528
+ history,
529
+ };
530
+ }
531
+
532
+ // Read state.json and reduce it to the flat browser contract. Returns null only
533
+ // when the file is unreadable (caller substitutes a closed default).
534
+ function readGateStateFile() {
535
+ try {
536
+ const raw = fs.readFileSync(GATE_STATE_FILE(), 'utf8');
537
+ return reduceGateState(JSON.parse(raw));
538
+ } catch {
539
+ return null;
540
+ }
541
+ }
542
+
543
+ function readAutoStateFile() {
544
+ try {
545
+ const raw = fs.readFileSync(AUTO_STATE_FILE(), 'utf8');
546
+ return JSON.parse(raw);
547
+ } catch {
548
+ return null;
549
+ }
550
+ }
551
+
552
+ // Pull only the bytes appended since we last read (so we don't replay every
553
+ // historical violation each time the file changes).
554
+ function readNewViolations() {
555
+ const file = VIOLATIONS_FILE();
556
+ let stat;
557
+ try { stat = fs.statSync(file); } catch { return []; }
558
+ if (stat.size < _violationsOffset) {
559
+ // File was truncated/rotated — reset offset and treat from start.
560
+ _violationsOffset = 0;
561
+ }
562
+ if (stat.size === _violationsOffset) return [];
563
+ let chunk;
564
+ try {
565
+ const fd = fs.openSync(file, 'r');
566
+ const length = stat.size - _violationsOffset;
567
+ const buf = Buffer.alloc(length);
568
+ fs.readSync(fd, buf, 0, length, _violationsOffset);
569
+ fs.closeSync(fd);
570
+ chunk = buf.toString('utf8');
571
+ } catch {
572
+ return [];
573
+ }
574
+ _violationsOffset = stat.size;
575
+ const out = [];
576
+ for (const line of chunk.split('\n')) {
577
+ const trimmed = line.trim();
578
+ if (!trimmed) continue;
579
+ try { out.push(JSON.parse(trimmed)); } catch { /* skip half-written line; next event retries */ }
580
+ }
581
+ return out;
582
+ }
583
+
584
+ function emitGateState() {
585
+ const state = readGateStateFile() || { open: false, history: [] };
586
+ broadcastGate({ type: 'gate-state', state });
587
+ }
588
+
589
+ function emitAutoState() {
590
+ const state = readAutoStateFile();
591
+ if (state) broadcastGate({ type: 'auto-state', state });
592
+ }
593
+
594
+ function emitViolations() {
595
+ const entries = readNewViolations();
596
+ for (const v of entries) {
597
+ broadcastGate({
598
+ type: 'gate-block',
599
+ reason: v.reason || v.type || 'Gate violation',
600
+ kind: v.type || null,
601
+ gateId: v.gate_id || null,
602
+ agent: v.agent || null,
603
+ phase: v.phase || null,
604
+ sessionId: v.session_id || null,
605
+ ts: v.ts || null,
606
+ });
607
+ }
608
+ }
609
+
610
+ function startGateWatchers() {
611
+ // Initialise violations offset to current size so we don't replay history.
612
+ try {
613
+ const stat = fs.statSync(VIOLATIONS_FILE());
614
+ _violationsOffset = stat.size;
615
+ } catch {
616
+ _violationsOffset = 0;
617
+ }
618
+
619
+ const setupWatch = (file, kind) => {
620
+ try {
621
+ fs.mkdirSync(path.dirname(file), { recursive: true });
622
+ } catch {}
623
+ try {
624
+ const watcher = fs.watch(path.dirname(file), (event, filename) => {
625
+ if (!filename) return;
626
+ if (path.basename(file) !== filename) return;
627
+ if (kind === 'gate') {
628
+ if (_gateStateDebounce) clearTimeout(_gateStateDebounce);
629
+ _gateStateDebounce = setTimeout(emitGateState, 50);
630
+ } else if (kind === 'auto') {
631
+ if (_autoStateDebounce) clearTimeout(_autoStateDebounce);
632
+ _autoStateDebounce = setTimeout(emitAutoState, 50);
633
+ } else if (kind === 'violations') {
634
+ if (_violationsDebounce) clearTimeout(_violationsDebounce);
635
+ _violationsDebounce = setTimeout(emitViolations, 50);
636
+ }
637
+ });
638
+ watcher.on('error', (err) => {
639
+ log(`gate watcher (${kind}) error: ${err.message}`);
640
+ });
641
+ _gateWatchers.push(watcher);
642
+ } catch (err) {
643
+ log(`gate watcher (${kind}) setup failed: ${err.message}`);
644
+ }
645
+ };
646
+
647
+ setupWatch(GATE_STATE_FILE(), 'gate');
648
+ setupWatch(AUTO_STATE_FILE(), 'auto');
649
+ setupWatch(VIOLATIONS_FILE(), 'violations');
650
+ }
651
+
652
+ function stopGateWatchers() {
653
+ for (const w of _gateWatchers) {
654
+ try { w.close(); } catch {}
655
+ }
656
+ _gateWatchers = [];
657
+ }
658
+
659
+ function stopPanelWatcher(panelId) {
660
+ if (panelWatchers[panelId]) {
661
+ try { panelWatchers[panelId].close(); } catch {}
662
+ delete panelWatchers[panelId];
663
+ }
664
+ }
665
+
666
+ function startPanelWatcher(panelId, info) {
667
+ stopPanelWatcher(panelId); // ensure clean start
668
+
669
+ try {
670
+ const watcher = fs.watch(info.filePath, (event) => {
671
+ if (event === 'change') {
672
+ // debounce slightly to ensure file is written
673
+ setTimeout(() => {
674
+ const currentInfo = panelSources[panelId];
675
+ if (!currentInfo) return; // panel was closed
676
+ const content = readFileSafe(currentInfo.filePath);
677
+ if (content !== null && content !== currentInfo.lastContent) {
678
+ panelSources[panelId] = { ...currentInfo, lastContent: content };
679
+ const parsedData = parsePanelFileContent(currentInfo.filePath, content);
680
+ broadcast({
681
+ type: 'ui-panel-update',
682
+ panelId,
683
+ panel: currentInfo.panel,
684
+ title: currentInfo.title,
685
+ agent: currentInfo.agent,
686
+ data: parsedData,
687
+ });
688
+ }
689
+ }, 100);
690
+ }
691
+ });
692
+ watcher.on('error', (err) => {
693
+ log(`Panel watcher error for ${info.filePath}: ${err.message}`);
694
+ stopPanelWatcher(panelId);
695
+ });
696
+ panelWatchers[panelId] = watcher;
697
+ } catch (err) {
698
+ log(`Failed to start watcher for ${info.filePath}: ${err.message}`);
699
+ }
700
+ }
701
+
702
+ // ─── File scanning (polling) ──────────────────────────────────────────────────
703
+
704
+ function readFileSafe(fullPath) {
705
+ try {
706
+ return fs.readFileSync(fullPath, 'utf8');
707
+ } catch {
708
+ return null;
709
+ }
710
+ }
711
+
712
+ // ─── CSV/TSV parser (RFC 4180) ────────────────────────────────────────────
713
+
714
+ function parseDelimited(text, delimiter) {
715
+ const rows = [];
716
+ let row = [];
717
+ let field = '';
718
+ let inQuotes = false;
719
+ let i = 0;
720
+
721
+ while (i < text.length) {
722
+ const ch = text[i];
723
+
724
+ if (inQuotes) {
725
+ if (ch === '"') {
726
+ if (i + 1 < text.length && text[i + 1] === '"') {
727
+ field += '"';
728
+ i += 2;
729
+ } else {
730
+ inQuotes = false;
731
+ i++;
732
+ }
733
+ } else {
734
+ field += ch;
735
+ i++;
736
+ }
737
+ } else {
738
+ if (ch === '"') {
739
+ inQuotes = true;
740
+ i++;
741
+ } else if (ch === delimiter) {
742
+ row.push(field);
743
+ field = '';
744
+ i++;
745
+ } else if (ch === '\r') {
746
+ if (i + 1 < text.length && text[i + 1] === '\n') i++;
747
+ row.push(field);
748
+ field = '';
749
+ rows.push(row);
750
+ row = [];
751
+ i++;
752
+ } else if (ch === '\n') {
753
+ row.push(field);
754
+ field = '';
755
+ rows.push(row);
756
+ row = [];
757
+ i++;
758
+ } else {
759
+ field += ch;
760
+ i++;
761
+ }
762
+ }
763
+ }
764
+
765
+ // Last field/row
766
+ if (field || row.length > 0) {
767
+ row.push(field);
768
+ rows.push(row);
769
+ }
770
+
771
+ if (rows.length === 0) return { columns: [], data: [] };
772
+
773
+ const columns = rows[0];
774
+ const data = [];
775
+ for (let r = 1; r < rows.length; r++) {
776
+ const obj = {};
777
+ for (let c = 0; c < columns.length; c++) {
778
+ obj[columns[c]] = rows[r][c] !== undefined ? rows[r][c] : '';
779
+ }
780
+ data.push(obj);
781
+ }
782
+
783
+ return { columns, data };
784
+ }
785
+
786
+ // Cap recursion depth for the 3s output-dir poll. 12 is more permissive than
787
+ // walkSearch's 8 (the user's own output dirs can nest deeper than ad-hoc search
788
+ // scope) but still bounded. Symlinks are skipped outright via lstat to prevent
789
+ // a symlink cycle under analysis/ from re-running pollFiles indefinitely.
790
+ const SCAN_MAX_DEPTH = 12;
791
+
792
+ function scanDir(dir, result, depth) {
793
+ if (depth === undefined) depth = 0;
794
+ if (depth > SCAN_MAX_DEPTH) return;
795
+ try {
796
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
797
+ const fullPath = path.join(dir, entry.name);
798
+
799
+ // Skip symlinks (both file and dir) — prevents infinite recursion via
800
+ // symlink loops on the 3s poll. Dirent.isSymbolicLink() reflects the
801
+ // type the OS reported in readdir; lstat is the authoritative check in
802
+ // case the dirent type wasn't populated by the filesystem.
803
+ if (entry.isSymbolicLink()) continue;
804
+ try {
805
+ if (fs.lstatSync(fullPath).isSymbolicLink()) continue;
806
+ } catch {
807
+ continue;
808
+ }
809
+
810
+ if (entry.isDirectory()) {
811
+ scanDir(fullPath, result, depth + 1);
812
+ continue;
813
+ }
814
+
815
+ const ext = path.extname(entry.name).toLowerCase();
816
+ if (POLL_SKIP_EXT.has(ext)) {
817
+ if (!pollSkipWarned.has(fullPath)) {
818
+ pollSkipWarned.add(fullPath);
819
+ console.warn(`pollFiles: skipping ${fullPath} (extension ${ext} in skip list)`);
820
+ }
821
+ continue;
822
+ }
823
+
824
+ let size;
825
+ try {
826
+ size = fs.statSync(fullPath).size;
827
+ } catch {
828
+ continue;
829
+ }
830
+ if (size > POLL_MAX_BYTES) {
831
+ if (!pollSkipWarned.has(fullPath)) {
832
+ pollSkipWarned.add(fullPath);
833
+ console.warn(`pollFiles: skipping ${fullPath} (${size} bytes > ${POLL_MAX_BYTES})`);
834
+ }
835
+ continue;
836
+ }
837
+
838
+ const relPath = path.relative(PROJECT_DIR, fullPath);
839
+ const content = readFileSafe(fullPath);
840
+ if (content !== null) result[relPath] = content;
841
+ }
842
+ } catch {}
843
+ }
844
+
845
+ function pollFiles() {
846
+ const current = {};
847
+ for (const dir of OUTPUT_DIRS) {
848
+ const dirPath = path.join(PROJECT_DIR, dir);
849
+ if (fs.existsSync(dirPath)) scanDir(dirPath, current);
850
+ }
851
+
852
+ // Collect all sessionFiles across active sessions
853
+ const allSessionFiles = new Set();
854
+ for (const store of sessions.values()) {
855
+ for (const f of store.sessionFiles) allSessionFiles.add(f);
856
+ }
857
+
858
+ for (const [relPath, content] of Object.entries(current)) {
859
+ if (files[relPath] !== content) {
860
+ files[relPath] = content;
861
+ broadcast({ type: 'artifact-updated', path: relPath, content, sessionFile: allSessionFiles.has(relPath) });
862
+ }
863
+ }
864
+
865
+ setTimeout(pollFiles, 3000);
866
+ }
867
+
868
+ // ─── Panel file content parser ────────────────────────────────────────────────
869
+
870
+ function parsePanelFileContent(filePath, content) {
871
+ const ext = path.extname(filePath).toLowerCase();
872
+ if (ext === '.json') {
873
+ try { return JSON.parse(content); } catch { return content; }
874
+ }
875
+ if (ext === '.csv') {
876
+ return parseDelimited(content, ',');
877
+ }
878
+ if (ext === '.tsv') {
879
+ return parseDelimited(content, '\t');
880
+ }
881
+ return content;
882
+ }
883
+
884
+ // ─── Event handler (observer mode - relay) ──────────────────────────────────
885
+
886
+ function handleEvent(body) {
887
+ let payload;
888
+ try {
889
+ payload = JSON.parse(body);
890
+ } catch {
891
+ return { ack: false };
892
+ }
893
+
894
+ const { eventType, seq, sessionId: eventSessionId, ...data } = payload;
895
+
896
+ // Prefer the session id carried by the relay event itself (forwarded from
897
+ // the Claude Code hook's stdin payload — same value as the SessionStore
898
+ // key for UI-spawned sessions, since we pass --session-id to the CLI). If
899
+ // a matching store exists, use it directly. This is the fix for two
900
+ // concurrent sessions seeing each other's events misattributed to "most
901
+ // recently active session."
902
+ //
903
+ // Fall back to the previous "most recently active" heuristic when:
904
+ // - the event lacks a sessionId (legacy relay processes before this fix
905
+ // ship, or queued events written under the old shape)
906
+ // - the event references a session not tracked by this server (claude run
907
+ // outside the UI in the same project — hooks still fire)
908
+ let activeStore = null;
909
+ let activeSessionId = null;
910
+ if (eventSessionId) {
911
+ const store = sessions.get(eventSessionId);
912
+ if (store) {
913
+ activeStore = store;
914
+ activeSessionId = eventSessionId;
915
+ }
916
+ }
917
+ if (!activeStore) {
918
+ for (const [id, store] of sessions) {
919
+ if (store.chatSession && store.chatSession.isRunning) {
920
+ if (!activeStore || store.lastActivityAt > activeStore.lastActivityAt) {
921
+ activeStore = store;
922
+ activeSessionId = id;
923
+ }
924
+ }
925
+ }
926
+ }
927
+
928
+ switch (eventType) {
929
+ case 'user-message':
930
+ if (activeStore) activeStore.transcript.push({ role: 'user', content: data.content, agent: data.agent, source: 'observer' });
931
+ broadcast({ type: 'user-message', content: data.content, agent: data.agent, sessionId: activeSessionId });
932
+ break;
933
+ case 'agent-message':
934
+ if (activeStore) activeStore.transcript.push({ role: 'assistant', content: data.content, agent: data.agent, source: 'observer' });
935
+ broadcast({ type: 'agent-message', content: data.content, agent: data.agent, sessionId: activeSessionId });
936
+ break;
937
+ case 'agent-activated':
938
+ broadcast({ type: 'agent-activated', agent: data.agent, sessionId: activeSessionId });
939
+ break;
940
+ case 'agent-changed':
941
+ broadcast({ type: 'agent-changed', from: data.from, to: data.to, sessionId: activeSessionId });
942
+ break;
943
+ case 'agent-consulting':
944
+ broadcast({ type: 'agent-consulting', agent: data.agent, sessionId: activeSessionId });
945
+ break;
946
+ case 'event-log':
947
+ broadcast({ type: 'event-log', text: data.text, sessionId: activeSessionId });
948
+ break;
949
+ case 'file-touched': {
950
+ const relPath = path.relative(PROJECT_DIR, data.filePath);
951
+ if (activeStore) {
952
+ activeStore.sessionFiles.add(relPath);
953
+ if (activeStore.setProjectFromRelPath(relPath)) {
954
+ activeStore.save();
955
+ try {
956
+ sessionIndex.updateActivity(SESSIONS_DIR, activeSessionId, {
957
+ projectName: activeStore.projectName,
958
+ projectDir: activeStore.projectDir,
959
+ });
960
+ } catch (err) {
961
+ log(`session-index updateActivity (file-touched) failed: ${err.message}`);
962
+ }
963
+ broadcast({
964
+ type: 'session-context',
965
+ sessionId: activeSessionId,
966
+ projectName: activeStore.projectName,
967
+ projectDir: activeStore.projectDir,
968
+ });
969
+ }
970
+ }
971
+ broadcast({ type: 'file-touched', path: relPath, sessionId: activeSessionId });
972
+ break;
973
+ }
974
+ case 'session-end':
975
+ broadcast({ type: 'session-end', sessionId: activeSessionId });
976
+ if (activeStore) activeStore.sessionFiles = new Set();
977
+ break;
978
+
979
+ // ─── ui-panel: agent pushes an interactive panel to the browser ─────
980
+ case 'ui-panel': {
981
+ const { panel, panelId, title, data: panelData, source, type: panelType, agent: panelAgent } = data;
982
+ if (!panel || !panelId) break;
983
+
984
+ let resolvedData = panelData !== undefined ? panelData : null;
985
+
986
+ // If source file path provided, read it and watch for changes
987
+ if (source) {
988
+ const content = readFileSafe(source);
989
+ if (content !== null) {
990
+ resolvedData = parsePanelFileContent(source, content);
991
+ const info = {
992
+ filePath: source,
993
+ panel,
994
+ title: title || panel,
995
+ agent: panelAgent || null,
996
+ lastContent: content,
997
+ };
998
+ panelSources[panelId] = info;
999
+ startPanelWatcher(panelId, info);
1000
+ }
1001
+ }
1002
+
1003
+ broadcast({
1004
+ type: 'ui-panel',
1005
+ panel,
1006
+ panelId,
1007
+ title: title || panel,
1008
+ data: resolvedData,
1009
+ source: source || null,
1010
+ panelType: panelType || null,
1011
+ agent: panelAgent || null,
1012
+ });
1013
+ break;
1014
+ }
1015
+
1016
+ // ─── ui-panel-close: agent or server closes a panel ─────────────────
1017
+ case 'ui-panel-close': {
1018
+ const { panelId } = data;
1019
+ if (panelId) {
1020
+ if (panelSources[panelId]) delete panelSources[panelId];
1021
+ stopPanelWatcher(panelId);
1022
+ }
1023
+ broadcast({ type: 'ui-panel-close', panelId: panelId || null });
1024
+ break;
1025
+ }
1026
+ }
1027
+
1028
+ return { ack: true, seq: seq || null };
1029
+ }
1030
+
1031
+ // ─── Agent listing ──────────────────────────────────────────────────────────
1032
+
1033
+ function listAgents() {
1034
+ const agents = [];
1035
+ try {
1036
+ const entries = fs.readdirSync(AGENTS_DIR, { withFileTypes: true });
1037
+ for (const entry of entries) {
1038
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
1039
+ const name = entry.name.replace(/\.md$/, '');
1040
+ const content = readFileSafe(path.join(AGENTS_DIR, entry.name));
1041
+ if (!content) continue;
1042
+
1043
+ // Parse YAML frontmatter
1044
+ let description = '';
1045
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
1046
+ if (fmMatch) {
1047
+ const descMatch = fmMatch[1].match(/description:\s*["']?(.+?)["']?\s*$/m);
1048
+ if (descMatch) description = descMatch[1];
1049
+ }
1050
+
1051
+ agents.push({ name, description });
1052
+ }
1053
+ } catch {}
1054
+ // Enrich with metadata
1055
+ for (const a of agents) {
1056
+ const meta = AGENT_META[a.name] || {};
1057
+ a.description = a.description || meta.description || '';
1058
+ a.label = meta.label || a.name;
1059
+ a.color = meta.color || '#3860c0';
1060
+ a.category = meta.category || 'other';
1061
+ a.keywords = meta.keywords || [];
1062
+ }
1063
+ return agents;
1064
+ }
1065
+
1066
+ function listSkills() {
1067
+ const roots = [
1068
+ { dir: path.join(PROJECT_DIR, '.claude', 'skills'), scope: 'project' },
1069
+ { dir: path.join(os.homedir(), '.claude', 'skills'), scope: 'user' },
1070
+ ];
1071
+ const seen = new Set();
1072
+ const skills = [];
1073
+
1074
+ for (const { dir, scope } of roots) {
1075
+ let entries;
1076
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
1077
+
1078
+ for (const entry of entries) {
1079
+ if (!entry.isDirectory() || seen.has(entry.name)) continue;
1080
+ const skillPath = path.join(dir, entry.name, 'SKILL.md');
1081
+ if (!fs.existsSync(skillPath)) continue;
1082
+ const content = readFileSafe(skillPath);
1083
+ if (!content) continue;
1084
+
1085
+ let description = '';
1086
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
1087
+ if (fmMatch) {
1088
+ const descMatch = fmMatch[1].match(/description:\s*["']?(.+?)["']?\s*$/m);
1089
+ if (descMatch) description = descMatch[1];
1090
+ }
1091
+
1092
+ seen.add(entry.name);
1093
+ skills.push({ name: entry.name, description, scope });
1094
+ }
1095
+ }
1096
+ return skills;
1097
+ }
1098
+
1099
+ // ─── Chat session management ────────────────────────────────────────────────
1100
+
1101
+ function handleChatEvent(event) {
1102
+ const { type, sessionId } = event;
1103
+ const store = getSession(sessionId);
1104
+
1105
+ switch (type) {
1106
+ case 'chat-token':
1107
+ broadcast({ type: 'chat-token', text: event.text, index: event.index, sessionId });
1108
+ break;
1109
+
1110
+ case 'chat-tool-use':
1111
+ broadcast({ type: 'chat-tool-use', tool: event.tool, id: event.id, index: event.index, sessionId });
1112
+ break;
1113
+
1114
+ case 'chat-tool-input-delta':
1115
+ broadcast({ type: 'chat-tool-input-delta', partial_json: event.partial_json, index: event.index, sessionId });
1116
+ break;
1117
+
1118
+ case 'chat-block-stop':
1119
+ broadcast({ type: 'chat-block-stop', index: event.index, sessionId });
1120
+ break;
1121
+
1122
+ case 'chat-message': {
1123
+ const content = event.content;
1124
+ let textContent = '';
1125
+ if (Array.isArray(content)) {
1126
+ textContent = content
1127
+ .filter(b => b.type === 'text')
1128
+ .map(b => b.text)
1129
+ .join('\n');
1130
+ } else if (typeof content === 'string') {
1131
+ textContent = content;
1132
+ }
1133
+ const agent = store ? store.agent : 'unknown';
1134
+ if (store) {
1135
+ store.transcript.push({ role: 'assistant', content: textContent, agent, source: 'chat' });
1136
+ store.save();
1137
+ }
1138
+ broadcast({ type: 'chat-message', content: textContent, agent, sessionId });
1139
+ break;
1140
+ }
1141
+
1142
+ case 'chat-turn-end':
1143
+ broadcast({ type: 'chat-turn-end', sessionId, cost: event.cost, duration: event.duration });
1144
+ break;
1145
+
1146
+ case 'chat-init': {
1147
+ // When forking, the CLI assigns a new session ID — re-key the store
1148
+ if (event.oldSessionId && event.sessionId !== event.oldSessionId) {
1149
+ const store = sessions.get(event.oldSessionId);
1150
+ if (store) {
1151
+ sessions.delete(event.oldSessionId);
1152
+ sessions.set(event.sessionId, store);
1153
+ store.sessionId = event.sessionId;
1154
+ }
1155
+ }
1156
+ broadcast({ type: 'chat-init', sessionId });
1157
+ break;
1158
+ }
1159
+
1160
+ case 'chat-error':
1161
+ broadcast({ type: 'chat-error', error: event.error, sessionId });
1162
+ break;
1163
+
1164
+ case 'chat-usage': {
1165
+ if (store && event.usage) {
1166
+ store.contextUsage = event.usage;
1167
+ store.save();
1168
+ broadcast({
1169
+ type: 'chat-usage',
1170
+ sessionId,
1171
+ usage: event.usage,
1172
+ });
1173
+ }
1174
+ break;
1175
+ }
1176
+
1177
+ case 'chat-stderr':
1178
+ broadcast({ type: 'chat-stderr', text: event.text, sessionId });
1179
+ break;
1180
+ }
1181
+ }
1182
+
1183
+ function handleChatExit({ code, sessionId }) {
1184
+ const store = getSession(sessionId);
1185
+ if (store) {
1186
+ store.chatSession = null;
1187
+ // Remove session metadata file
1188
+ try { fs.unlinkSync(path.join(SESSIONS_DIR, `${sessionId}.json`)); } catch {}
1189
+ }
1190
+
1191
+ // Abort any pending permission requests tied to this session. Without this,
1192
+ // a relay process started by the now-dead chat session keeps polling for a
1193
+ // decision that will never come (the browser card disappeared with the
1194
+ // session). Marking these as denied lets the relay's next poll resolve
1195
+ // immediately and fall through to CC's native permission flow.
1196
+ for (const [id, req] of permissionRequests) {
1197
+ if (req.sessionId === sessionId && req.decision === null) {
1198
+ req.decision = 'deny';
1199
+ req.createdAt = Date.now(); // restart TTL so the entry isn't immediately collected
1200
+ broadcast({ type: 'permission-resolved', id, decision: 'deny', reason: 'session-ended' });
1201
+ }
1202
+ }
1203
+ // Mark ended in the index if not already (covers natural CLI exit, crashes,
1204
+ // and the path where the user closes the browser without hitting End Chat).
1205
+ try {
1206
+ const entry = sessionIndex.getEntry(SESSIONS_DIR, sessionId);
1207
+ if (entry && entry.status === 'active') {
1208
+ const snap = readGateSnapshot(SHARDS_DIR);
1209
+ sessionIndex.markEnded(SESSIONS_DIR, sessionId, {
1210
+ gateOpenAtEnd: snap.gateOpenAtEnd,
1211
+ reason: code === 0 ? 'process_exit' : 'process_crash',
1212
+ });
1213
+ if (typeof snap.phase === 'number') sessionIndex.updatePhase(SESSIONS_DIR, sessionId, snap.phase);
1214
+ }
1215
+ } catch (err) {
1216
+ log(`session-index markEnded (handleChatExit) failed: ${err.message}`);
1217
+ }
1218
+ broadcast({ type: 'chat-ended', sessionId, code: code || 0 });
1219
+ }
1220
+
1221
+ // ─── Slash command parsing ──────────────────────────────────────────────────
1222
+
1223
+ function parseSlashCommand(message) {
1224
+ const trimmed = message.trim();
1225
+ if (!trimmed.startsWith('/')) return null;
1226
+
1227
+ const cmd = trimmed.slice(1).split(/\s+/)[0].toLowerCase();
1228
+ if (!cmd) return null;
1229
+
1230
+ // Utility commands
1231
+ const utilities = ['stop', 'clear', 'help'];
1232
+ if (utilities.includes(cmd)) {
1233
+ return { type: 'utility', command: cmd };
1234
+ }
1235
+
1236
+ // Claude Code built-in commands handled by the UI
1237
+ const builtins = ['context', 'rewind', 'exit', 'model'];
1238
+ if (builtins.includes(cmd)) {
1239
+ const rest = trimmed.slice(1 + cmd.length).trim();
1240
+ return { type: 'builtin', command: cmd, args: rest || null };
1241
+ }
1242
+
1243
+ // Agent commands — check against installed agents
1244
+ const agents = listAgents();
1245
+
1246
+ // Explicit /agent <name> form — always unambiguous
1247
+ if (cmd === 'agent') {
1248
+ const name = trimmed.slice(1 + cmd.length).trim();
1249
+ if (agents.some(a => a.name === name)) {
1250
+ return { type: 'agent', agent: name };
1251
+ }
1252
+ return null;
1253
+ }
1254
+
1255
+ // Bare agent name — backward compatible shortcut
1256
+ if (agents.some(a => a.name === cmd)) {
1257
+ return { type: 'agent', agent: cmd };
1258
+ }
1259
+
1260
+ // Everything else — skills, project commands, CC builtins we don't
1261
+ // special-case — goes to the CLI verbatim.
1262
+ return { type: 'passthrough' };
1263
+ }
1264
+
1265
+ // ─── Chat session creation helper ───────────────────────────────────────────
1266
+
1267
+ function startNewChatSession(agent, options = {}) {
1268
+ const { resumeSessionId, permissionMode, callerSessionId, initialMessage, model } = options;
1269
+
1270
+ // Kill existing session for the caller if running
1271
+ if (callerSessionId) {
1272
+ const oldStore = getSession(callerSessionId);
1273
+ if (oldStore && oldStore.chatSession && oldStore.chatSession.isRunning) {
1274
+ oldStore.chatSession.stop();
1275
+ }
1276
+ }
1277
+
1278
+ const sessionId = randomUUID();
1279
+
1280
+ const store = new SessionStore({ sessionId, agent });
1281
+ sessions.set(sessionId, store);
1282
+
1283
+ try {
1284
+ sessionIndex.appendSession(SESSIONS_DIR, {
1285
+ sessionId,
1286
+ agent,
1287
+ resumedFrom: resumeSessionId || null,
1288
+ });
1289
+ } catch (err) {
1290
+ log(`session-index appendSession failed: ${err.message}`);
1291
+ }
1292
+
1293
+ const chatSess = new ChatSession({
1294
+ agent,
1295
+ sessionId,
1296
+ resumeSessionId,
1297
+ cwd: PROJECT_DIR,
1298
+ sessionsDir: SESSIONS_DIR,
1299
+ permissionMode: permissionMode || 'acceptEdits',
1300
+ model,
1301
+ onEvent: handleChatEvent,
1302
+ onExit: handleChatExit,
1303
+ });
1304
+
1305
+ store.chatSession = chatSess;
1306
+ chatSess.start();
1307
+
1308
+ // Opening message. For an agent session this is the activation prompt from
1309
+ // .claude/commands/<agent>.md; for a plain session it's the user's own first
1310
+ // message, or nothing at all if they haven't typed yet.
1311
+ let firstMessage = null;
1312
+
1313
+ if (agent) {
1314
+ const cmdFile = path.join(COMMANDS_DIR, `${agent}.md`);
1315
+ try {
1316
+ const raw = fs.readFileSync(cmdFile, 'utf8');
1317
+ firstMessage = raw.replace(/^---\n[\s\S]*?\n---\n*/, '').trim();
1318
+ } catch {
1319
+ firstMessage = `You are now activated as the ${agent} agent. Greet the user and display your activation menu.`;
1320
+ }
1321
+ if (initialMessage) {
1322
+ firstMessage += '\n\nThe user has already provided their request upfront. ' +
1323
+ '**Skip the greeting and activation menu entirely.** Go directly to Phase 0 triage.' +
1324
+ '\n\nUser request:\n\n' + initialMessage;
1325
+ }
1326
+ } else if (initialMessage) {
1327
+ firstMessage = initialMessage;
1328
+ }
1329
+
1330
+ if (firstMessage) {
1331
+ try {
1332
+ chatSess.send(firstMessage);
1333
+ } catch (err) {
1334
+ chatSess.stop();
1335
+ sessions.delete(sessionId);
1336
+ broadcast({ type: 'chat-error', error: `Session start failed: ${err.message}`, sessionId });
1337
+ return { sessionId, agent };
1338
+ }
1339
+ }
1340
+
1341
+ broadcast({ type: 'chat-started', agent, sessionId, autoActivated: !!agent });
1342
+ return { sessionId, agent };
1343
+ }
1344
+
1345
+ // ─── Request body parser ────────────────────────────────────────────────────
1346
+
1347
+ function readBody(req) {
1348
+ return new Promise((resolve) => {
1349
+ let body = '';
1350
+ req.on('data', (chunk) => { body += chunk; });
1351
+ req.on('end', () => resolve(body));
1352
+ });
1353
+ }
1354
+
1355
+ function jsonResponse(res, cors, statusCode, data) {
1356
+ res.writeHead(statusCode, { ...cors, 'Content-Type': 'application/json' });
1357
+ res.end(JSON.stringify(data));
1358
+ }
1359
+
1360
+ // ─── Server ──────────────────────────────────────────────────────────────────
1361
+
1362
+ // ─── P1: Auth middleware ──────────────────────────────────────────────────────
1363
+
1364
+ function checkAuth(req, parsedUrl) {
1365
+ // Check Authorization header first
1366
+ const authHeader = req.headers['authorization'];
1367
+ if (authHeader === `Bearer ${AUTH_TOKEN}`) return true;
1368
+ // Check query param (for SSE EventSource which can't set headers)
1369
+ if (parsedUrl && parsedUrl.searchParams.get('token') === AUTH_TOKEN) return true;
1370
+ return false;
1371
+ }
1372
+
1373
+ function rejectAuth(res, cors) {
1374
+ jsonResponse(res, cors, 401, { error: 'Unauthorized' });
1375
+ }
1376
+
1377
+ function createHandler() {
1378
+ return async (req, res) => {
1379
+ const cors = {
1380
+ 'Access-Control-Allow-Origin': '*',
1381
+ 'Access-Control-Allow-Methods': 'GET, POST',
1382
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
1383
+ };
1384
+
1385
+ // Log client IP if trust-proxy is enabled
1386
+ if (TRUST_PROXY) {
1387
+ const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
1388
+ log(`${req.method} ${req.url} from ${clientIp}`);
1389
+ }
1390
+
1391
+ const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
1392
+
1393
+ if (req.method === 'OPTIONS') {
1394
+ res.writeHead(200, cors);
1395
+ res.end();
1396
+ return;
1397
+ }
1398
+
1399
+ // ─── P1: Public endpoints (no auth required) ────────────────
1400
+
1401
+ // Serve HTML page — inject auth token as meta tag
1402
+ if (req.method === 'GET' && parsedUrl.pathname === '/') {
1403
+ try {
1404
+ let html = fs.readFileSync(INDEX_HTML, 'utf8');
1405
+ // Inject token into HTML as a meta tag before </head>
1406
+ html = html.replace('</head>', `<meta name="shards-token" content="${AUTH_TOKEN}">\n</head>`);
1407
+ // Electron mode: swap CDN URLs for local vendor paths and inject detection flag
1408
+ if (ELECTRON_MODE) {
1409
+ html = html.replace('</head>', `<script>window.__shardsElectronServer = true;</script>\n</head>`);
1410
+ if (VENDOR_DIR) {
1411
+ html = html.replace('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap', 'vendor/inter-font/inter.css');
1412
+ html = html.replace('https://unpkg.com/tabulator-tables@6.3.1/dist/css/tabulator_midnight.min.css', 'vendor/tabulator-tables/tabulator_midnight.min.css');
1413
+ html = html.replace('https://unpkg.com/tabulator-tables@6.3.1/dist/js/tabulator.min.js', 'vendor/tabulator-tables/tabulator.min.js');
1414
+ html = html.replace('https://cdn.plot.ly/plotly-2.35.2.min.js', 'vendor/plotly/plotly.min.js');
1415
+ html = html.replace('https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.min.js', 'vendor/mermaid/mermaid.min.js');
1416
+ // Inject xterm.js and fit addon for terminal support
1417
+ html = html.replace('</head>',
1418
+ '<link rel="stylesheet" href="vendor/xterm/xterm.css">\n' +
1419
+ '<script src="vendor/xterm/xterm.js"><\/script>\n' +
1420
+ '<script src="vendor/xterm/xterm-addon-fit.js"><\/script>\n' +
1421
+ '</head>');
1422
+ }
1423
+ }
1424
+ res.writeHead(200, { ...cors, 'Content-Type': 'text/html; charset=utf-8' });
1425
+ res.end(html);
1426
+ } catch {
1427
+ res.writeHead(500);
1428
+ res.end('index.html not found');
1429
+ }
1430
+ return;
1431
+ }
1432
+
1433
+ // Static file serving (JS/CSS/images) — no auth (public assets)
1434
+ if (req.method === 'GET') {
1435
+ const MIME = {
1436
+ '.js': 'application/javascript', '.css': 'text/css',
1437
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
1438
+ '.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp',
1439
+ '.ico': 'image/x-icon',
1440
+ '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf',
1441
+ };
1442
+ const ext = path.extname(parsedUrl.pathname).toLowerCase();
1443
+ if (MIME[ext]) {
1444
+ const decodedPath = decodeURIComponent(parsedUrl.pathname);
1445
+ let filePath = path.join(__dirname, decodedPath);
1446
+ let resolved = path.resolve(filePath);
1447
+ // In Electron mode, also serve from vendor directory
1448
+ if (ELECTRON_MODE && VENDOR_DIR && decodedPath.startsWith('/vendor/')) {
1449
+ filePath = path.join(VENDOR_DIR, decodedPath.slice('/vendor/'.length));
1450
+ resolved = path.resolve(filePath);
1451
+ if (!resolved.startsWith(path.resolve(VENDOR_DIR))) {
1452
+ res.writeHead(403);
1453
+ res.end('Forbidden');
1454
+ return;
1455
+ }
1456
+ } else if (!resolved.startsWith(__dirname)) {
1457
+ res.writeHead(403);
1458
+ res.end('Forbidden');
1459
+ return;
1460
+ }
1461
+ const isBinary = !['.js', '.css', '.svg'].includes(ext);
1462
+ try {
1463
+ const content = fs.readFileSync(resolved, isBinary ? null : 'utf8');
1464
+ res.writeHead(200, { ...cors, 'Content-Type': MIME[ext] });
1465
+ res.end(content);
1466
+ } catch {
1467
+ res.writeHead(404);
1468
+ res.end('Not found');
1469
+ }
1470
+ return;
1471
+ }
1472
+ }
1473
+
1474
+ // ─── Developer Guide (docs) ──────────────────────────────────────────────
1475
+ // Serves the Shards Developer Guide from .shards/ui/docs/ — no auth,
1476
+ // public static docs. __dirname here is the .shards/ui/ directory in the
1477
+ // target install.
1478
+ const DOCS_DIR = path.join(__dirname, 'docs');
1479
+
1480
+ if (req.method === 'GET' && parsedUrl.pathname === '/docs/manifest') {
1481
+ try {
1482
+ const raw = fs.readFileSync(path.join(DOCS_DIR, 'manifest.json'), 'utf8');
1483
+ res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
1484
+ res.end(raw);
1485
+ } catch {
1486
+ res.writeHead(404, { ...cors });
1487
+ res.end('Guide manifest not found');
1488
+ }
1489
+ return;
1490
+ }
1491
+
1492
+ if (req.method === 'GET' && parsedUrl.pathname === '/docs/page') {
1493
+ const file = parsedUrl.searchParams.get('file');
1494
+ if (!file) {
1495
+ res.writeHead(400, { ...cors });
1496
+ res.end('Missing file');
1497
+ return;
1498
+ }
1499
+ const filePath = path.join(DOCS_DIR, file);
1500
+ const resolved = path.resolve(filePath);
1501
+ if (!resolved.startsWith(path.resolve(DOCS_DIR) + path.sep)) {
1502
+ res.writeHead(403, { ...cors });
1503
+ res.end('Forbidden');
1504
+ return;
1505
+ }
1506
+ if (!resolved.endsWith('.md')) {
1507
+ res.writeHead(400, { ...cors });
1508
+ res.end('Only .md files are served');
1509
+ return;
1510
+ }
1511
+ try {
1512
+ const content = fs.readFileSync(resolved, 'utf8');
1513
+ res.writeHead(200, { ...cors, 'Content-Type': 'text/markdown; charset=utf-8' });
1514
+ res.end(content);
1515
+ } catch {
1516
+ res.writeHead(404, { ...cors });
1517
+ res.end('Not found');
1518
+ }
1519
+ return;
1520
+ }
1521
+
1522
+ // ─── Knowledge Ledger bulk-read ──────────────────────────────────────────
1523
+ if (req.method === 'GET' && parsedUrl.pathname === '/knowledge/entries') {
1524
+ if (!checkAuth(req, parsedUrl)) { rejectAuth(res, cors); return; }
1525
+ const KNOWLEDGE_CATEGORIES = ['entities', 'infrastructure', 'patterns', 'features'];
1526
+ const indexContent = readFileSafe(path.join(KNOWLEDGE_DIR, 'INDEX.md')) || '';
1527
+ const entries = [];
1528
+ for (const cat of KNOWLEDGE_CATEGORIES) {
1529
+ const catDir = path.join(KNOWLEDGE_DIR, cat);
1530
+ try {
1531
+ const files = fs.readdirSync(catDir, { withFileTypes: true });
1532
+ for (const f of files) {
1533
+ if (!f.isFile() || !f.name.endsWith('.md')) continue;
1534
+ const fullPath = path.join(catDir, f.name);
1535
+ const content = readFileSafe(fullPath);
1536
+ if (content !== null) {
1537
+ entries.push({
1538
+ path: path.relative(PROJECT_DIR, fullPath),
1539
+ content,
1540
+ });
1541
+ }
1542
+ }
1543
+ } catch { /* directory missing — skip */ }
1544
+ }
1545
+ jsonResponse(res, cors, 200, { indexContent, entries });
1546
+ return;
1547
+ }
1548
+
1549
+ /**
1550
+ * Resolve a user-supplied path and verify it stays within PROJECT_DIR.
1551
+ * Returns the resolved absolute path, or null if out of bounds.
1552
+ */
1553
+ function resolveSafe(userPath) {
1554
+ const resolved = path.resolve(userPath);
1555
+ if (resolved !== PROJECT_DIR && !resolved.startsWith(PROJECT_DIR + path.sep)) {
1556
+ return null;
1557
+ }
1558
+ return resolved;
1559
+ }
1560
+
1561
+ // Raw file serving (images, PDFs) — auth via query param
1562
+ if (req.method === 'GET' && parsedUrl.pathname === '/browse/file/raw') {
1563
+ if (!checkAuth(req, parsedUrl)) {
1564
+ rejectAuth(res, cors);
1565
+ return;
1566
+ }
1567
+ const filePath = parsedUrl.searchParams.get('path');
1568
+ if (!filePath) {
1569
+ res.writeHead(400);
1570
+ res.end('Missing path');
1571
+ return;
1572
+ }
1573
+ const resolved = resolveSafe(filePath);
1574
+ if (!resolved) {
1575
+ res.writeHead(403);
1576
+ res.end('Forbidden');
1577
+ return;
1578
+ }
1579
+ const RAW_MIME = {
1580
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
1581
+ '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
1582
+ '.bmp': 'image/bmp', '.ico': 'image/x-icon',
1583
+ '.pdf': 'application/pdf',
1584
+ };
1585
+ const rawExt = path.extname(resolved).toLowerCase();
1586
+ const mimeType = RAW_MIME[rawExt];
1587
+ if (!mimeType) {
1588
+ res.writeHead(415);
1589
+ res.end('Unsupported file type');
1590
+ return;
1591
+ }
1592
+ try {
1593
+ const stat = fs.statSync(resolved);
1594
+ if (stat.size > 50 * 1024 * 1024) {
1595
+ res.writeHead(413);
1596
+ res.end('File too large (>50MB)');
1597
+ return;
1598
+ }
1599
+ const data = fs.readFileSync(resolved);
1600
+ res.writeHead(200, {
1601
+ ...cors,
1602
+ 'Content-Type': mimeType,
1603
+ 'Content-Length': data.length,
1604
+ });
1605
+ res.end(data);
1606
+ } catch (err) {
1607
+ res.writeHead(404);
1608
+ res.end('File not found');
1609
+ }
1610
+ return;
1611
+ }
1612
+
1613
+ // Text file serving (for pinboard context)
1614
+ if (req.method === 'GET' && parsedUrl.pathname === '/browse/file/text') {
1615
+ if (!checkAuth(req, parsedUrl)) {
1616
+ rejectAuth(res, cors);
1617
+ return;
1618
+ }
1619
+ const filePath = parsedUrl.searchParams.get('path');
1620
+ if (!filePath) {
1621
+ res.writeHead(400, { ...cors, 'Content-Type': 'application/json' });
1622
+ res.end(JSON.stringify({ error: 'Missing path' }));
1623
+ return;
1624
+ }
1625
+ const resolved = resolveSafe(filePath);
1626
+ if (!resolved) {
1627
+ res.writeHead(403, { ...cors, 'Content-Type': 'application/json' });
1628
+ res.end(JSON.stringify({ error: 'Forbidden' }));
1629
+ return;
1630
+ }
1631
+ try {
1632
+ const stat = fs.statSync(resolved);
1633
+ if (!stat.isFile()) {
1634
+ res.writeHead(400, { ...cors, 'Content-Type': 'application/json' });
1635
+ res.end(JSON.stringify({ error: 'Not a file' }));
1636
+ return;
1637
+ }
1638
+ if (stat.size > 1024 * 1024) {
1639
+ res.writeHead(413, { ...cors, 'Content-Type': 'application/json' });
1640
+ res.end(JSON.stringify({ error: 'File too large (>1MB)' }));
1641
+ return;
1642
+ }
1643
+ const content = fs.readFileSync(resolved, 'utf-8');
1644
+ res.writeHead(200, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' });
1645
+ res.end(content);
1646
+ } catch (err) {
1647
+ res.writeHead(404, { ...cors, 'Content-Type': 'application/json' });
1648
+ res.end(JSON.stringify({ error: 'File not found' }));
1649
+ }
1650
+ return;
1651
+ }
1652
+
1653
+ // ─── P1: All remaining endpoints require auth ───────────────
1654
+
1655
+ if (!checkAuth(req, parsedUrl)) {
1656
+ rejectAuth(res, cors);
1657
+ return;
1658
+ }
1659
+
1660
+ // ─── SSE endpoint (auth via query param token) ──────────────
1661
+
1662
+ if (req.method === 'GET' && parsedUrl.pathname === '/events') {
1663
+ const filterSessionId = parsedUrl.searchParams.get('sessionId') || null;
1664
+ res.writeHead(200, {
1665
+ ...cors,
1666
+ 'Content-Type': 'text/event-stream',
1667
+ 'Cache-Control': 'no-cache',
1668
+ 'Connection': 'keep-alive',
1669
+ });
1670
+ res.write('data: {"type":"connected"}\n\n');
1671
+ clients.push({ res, sessionId: filterSessionId });
1672
+ req.on('close', () => {
1673
+ clients = clients.filter((c) => c.res !== res);
1674
+ });
1675
+ return;
1676
+ }
1677
+
1678
+ if (req.method === 'GET' && parsedUrl.pathname === '/transcript') {
1679
+ const sid = parsedUrl.searchParams.get('sessionId');
1680
+ if (sid) {
1681
+ const store = getSession(sid);
1682
+ jsonResponse(res, cors, 200, store ? store.transcript : []);
1683
+ } else {
1684
+ // Return all transcripts merged
1685
+ const all = [];
1686
+ for (const store of sessions.values()) {
1687
+ all.push(...store.transcript);
1688
+ }
1689
+ jsonResponse(res, cors, 200, all);
1690
+ }
1691
+ return;
1692
+ }
1693
+
1694
+ if (req.method === 'GET' && parsedUrl.pathname === '/files') {
1695
+ const allSessionFiles = new Set();
1696
+ for (const store of sessions.values()) {
1697
+ for (const f of store.sessionFiles) allSessionFiles.add(f);
1698
+ }
1699
+ jsonResponse(res, cors, 200, { files, sessionFiles: [...allSessionFiles] });
1700
+ return;
1701
+ }
1702
+
1703
+ // ─── P3: Sessions list endpoint ─────────────────────────────
1704
+
1705
+ if (req.method === 'GET' && parsedUrl.pathname === '/sessions') {
1706
+ const list = [];
1707
+ for (const [id, store] of sessions) {
1708
+ list.push({
1709
+ sessionId: id,
1710
+ agent: store.agent,
1711
+ title: store.title,
1712
+ active: !!(store.chatSession && store.chatSession.isRunning),
1713
+ createdAt: store.createdAt.toISOString(),
1714
+ lastActivityAt: store.lastActivityAt.toISOString(),
1715
+ messageCount: store.transcript.length,
1716
+ projectName: store.projectName,
1717
+ projectDir: store.projectDir,
1718
+ });
1719
+ }
1720
+ jsonResponse(res, cors, 200, list);
1721
+ return;
1722
+ }
1723
+
1724
+ // ─── Disk-backed session index (includes ended/abandoned) ───
1725
+ if (req.method === 'GET' && parsedUrl.pathname === '/sessions/index') {
1726
+ const project = parsedUrl.searchParams.get('project') || undefined;
1727
+ const agent = parsedUrl.searchParams.get('agent') || undefined;
1728
+ const status = parsedUrl.searchParams.get('status') || undefined;
1729
+ let entries;
1730
+ try {
1731
+ entries = sessionIndex.listSessions(SESSIONS_DIR, { project, agent, status });
1732
+ } catch (err) {
1733
+ jsonResponse(res, cors, 500, { error: err.message });
1734
+ return;
1735
+ }
1736
+ // Annotate each entry with `active: true` if there's a live in-memory
1737
+ // session for it. The disk record may be stale; the in-memory map is
1738
+ // authoritative for "is the process actually running right now."
1739
+ const annotated = entries.map((e) => {
1740
+ const live = sessions.get(e.sessionId);
1741
+ return {
1742
+ ...e,
1743
+ active: !!(live && live.chatSession && live.chatSession.isRunning),
1744
+ };
1745
+ });
1746
+ jsonResponse(res, cors, 200, { version: sessionIndex.INDEX_VERSION, sessions: annotated });
1747
+ return;
1748
+ }
1749
+
1750
+ // ─── Directory browser endpoints ───────────────────────────────────
1751
+
1752
+ if (req.method === 'GET' && parsedUrl.pathname.startsWith('/browse')) {
1753
+
1754
+ // ─── File search endpoint ──────────────────────────────────
1755
+ if (parsedUrl.pathname === '/browse/search') {
1756
+ const query = (parsedUrl.searchParams.get('q') || '').toLowerCase();
1757
+ if (!query) {
1758
+ jsonResponse(res, cors, 200, { results: [] });
1759
+ return;
1760
+ }
1761
+ const results = [];
1762
+ const skip = new Set(['.git', 'node_modules', '__pycache__', '.shards', '.venv', 'venv']);
1763
+ function walkSearch(dir, depth) {
1764
+ if (depth > 8 || results.length >= 50) return;
1765
+ try {
1766
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
1767
+ for (const entry of entries) {
1768
+ if (results.length >= 50) break;
1769
+ if (entry.name.startsWith('.') && skip.has(entry.name)) continue;
1770
+ if (skip.has(entry.name)) continue;
1771
+ const full = path.join(dir, entry.name);
1772
+ if (entry.isDirectory()) {
1773
+ walkSearch(full, depth + 1);
1774
+ } else {
1775
+ const rel = path.relative(PROJECT_DIR, full);
1776
+ if (rel.toLowerCase().indexOf(query) !== -1) {
1777
+ results.push(rel);
1778
+ }
1779
+ }
1780
+ }
1781
+ } catch {}
1782
+ }
1783
+ walkSearch(PROJECT_DIR, 0);
1784
+ jsonResponse(res, cors, 200, { results });
1785
+ return;
1786
+ }
1787
+
1788
+ if (parsedUrl.pathname === '/browse') {
1789
+ let dir = parsedUrl.searchParams.get('dir') || PROJECT_DIR;
1790
+ dir = resolveSafe(dir);
1791
+ if (!dir) {
1792
+ jsonResponse(res, cors, 403, { error: 'Forbidden' });
1793
+ return;
1794
+ }
1795
+
1796
+ try {
1797
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
1798
+ const items = [];
1799
+ for (const entry of entries) {
1800
+ if (entry.name.startsWith('.') && entry.name !== '..') continue;
1801
+ const fullPath = path.join(dir, entry.name);
1802
+ const isDir = entry.isDirectory();
1803
+ let size = 0;
1804
+ if (!isDir) {
1805
+ try { size = fs.statSync(fullPath).size; } catch {}
1806
+ }
1807
+ items.push({ name: entry.name, type: isDir ? 'dir' : 'file', size });
1808
+ }
1809
+ items.sort((a, b) => {
1810
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
1811
+ return a.name.localeCompare(b.name);
1812
+ });
1813
+
1814
+ const parent = path.dirname(dir);
1815
+ jsonResponse(res, cors, 200, {
1816
+ path: dir,
1817
+ parent: parent !== dir ? parent : null,
1818
+ entries: items,
1819
+ });
1820
+ } catch (err) {
1821
+ jsonResponse(res, cors, 400, { error: `Cannot read directory: ${err.message}` });
1822
+ }
1823
+ return;
1824
+ }
1825
+
1826
+ if (parsedUrl.pathname === '/browse/file') {
1827
+ const filePath = parsedUrl.searchParams.get('path');
1828
+ if (!filePath) {
1829
+ jsonResponse(res, cors, 400, { error: 'Missing path parameter' });
1830
+ return;
1831
+ }
1832
+ const resolved = resolveSafe(filePath);
1833
+ if (!resolved) {
1834
+ jsonResponse(res, cors, 403, { error: 'Forbidden' });
1835
+ return;
1836
+ }
1837
+ try {
1838
+ const stat = fs.statSync(resolved);
1839
+ const isNotebook = resolved.endsWith('.ipynb');
1840
+ const maxSize = isNotebook ? 10 * 1024 * 1024 : 2 * 1024 * 1024;
1841
+ if (stat.size > maxSize) {
1842
+ jsonResponse(res, cors, 400, { error: `File too large (>${isNotebook ? '10' : '2'}MB)` });
1843
+ return;
1844
+ }
1845
+ const content = fs.readFileSync(resolved, 'utf8');
1846
+ const relPath = path.relative(PROJECT_DIR, resolved);
1847
+ jsonResponse(res, cors, 200, { path: resolved, relPath, content });
1848
+ } catch (err) {
1849
+ jsonResponse(res, cors, 400, { error: `Cannot read file: ${err.message}` });
1850
+ }
1851
+ return;
1852
+ }
1853
+ }
1854
+
1855
+ // ─── Symbol / Code Intelligence endpoints ─────────────────────
1856
+
1857
+ if (req.method === 'GET' && parsedUrl.pathname === '/symbols/status') {
1858
+ jsonResponse(res, cors, 200, symbolIndex.getStatus());
1859
+ return;
1860
+ }
1861
+
1862
+ if (req.method === 'POST' && parsedUrl.pathname === '/symbols/reindex') {
1863
+ try {
1864
+ symbolIndex.buildIndex(PROJECT_DIR, log);
1865
+ jsonResponse(res, cors, 200, { ok: true, ...symbolIndex.getStatus() });
1866
+ } catch (err) {
1867
+ jsonResponse(res, cors, 500, { error: `Reindex failed: ${err.message}` });
1868
+ }
1869
+ return;
1870
+ }
1871
+
1872
+ if (req.method === 'GET' && parsedUrl.pathname === '/symbols/definition') {
1873
+ const name = parsedUrl.searchParams.get('name');
1874
+ const file = parsedUrl.searchParams.get('file') || '';
1875
+ if (!name) {
1876
+ jsonResponse(res, cors, 400, { error: 'Missing name parameter' });
1877
+ return;
1878
+ }
1879
+ const definitions = symbolIndex.getDefinitions(name, file);
1880
+ // Add preview line from disk
1881
+ for (const def of definitions) {
1882
+ try {
1883
+ const absPath = path.join(PROJECT_DIR, def.file);
1884
+ const lines = fs.readFileSync(absPath, 'utf8').split('\n');
1885
+ def.preview = lines[def.line - 1] || def.pattern || '';
1886
+ } catch {
1887
+ def.preview = def.pattern || '';
1888
+ }
1889
+ }
1890
+ jsonResponse(res, cors, 200, { definitions });
1891
+ return;
1892
+ }
1893
+
1894
+ if (req.method === 'GET' && parsedUrl.pathname === '/symbols/references') {
1895
+ const name = parsedUrl.searchParams.get('name');
1896
+ const file = parsedUrl.searchParams.get('file') || '';
1897
+ if (!name) {
1898
+ jsonResponse(res, cors, 400, { error: 'Missing name parameter' });
1899
+ return;
1900
+ }
1901
+ const references = symbolIndex.getReferences(name, PROJECT_DIR);
1902
+ jsonResponse(res, cors, 200, { references });
1903
+ return;
1904
+ }
1905
+
1906
+ if (req.method === 'GET' && parsedUrl.pathname === '/symbols/hover') {
1907
+ const name = parsedUrl.searchParams.get('name');
1908
+ const file = parsedUrl.searchParams.get('file') || '';
1909
+ const line = parseInt(parsedUrl.searchParams.get('line') || '0', 10);
1910
+ if (!name) {
1911
+ jsonResponse(res, cors, 400, { error: 'Missing name parameter' });
1912
+ return;
1913
+ }
1914
+ const info = symbolIndex.getHoverEnriched(name, file, line);
1915
+ if (info) {
1916
+ jsonResponse(res, cors, 200, info);
1917
+ } else {
1918
+ jsonResponse(res, cors, 200, { found: false });
1919
+ }
1920
+ return;
1921
+ }
1922
+
1923
+ if (req.method === 'GET' && parsedUrl.pathname === '/symbols/completions') {
1924
+ const prefix = parsedUrl.searchParams.get('prefix') || '';
1925
+ const file = parsedUrl.searchParams.get('file') || '';
1926
+ if (!prefix) {
1927
+ jsonResponse(res, cors, 200, { completions: [] });
1928
+ return;
1929
+ }
1930
+ const completions = symbolIndex.getCompletions(prefix, file);
1931
+ jsonResponse(res, cors, 200, { completions });
1932
+ return;
1933
+ }
1934
+
1935
+ // ─── File save endpoint ─────────────────────────────────────────
1936
+ if (req.method === 'POST' && parsedUrl.pathname === '/browse/file/save') {
1937
+ const body = await readBody(req);
1938
+ let params;
1939
+ try {
1940
+ params = JSON.parse(body);
1941
+ } catch {
1942
+ jsonResponse(res, cors, 400, { error: 'Invalid JSON' });
1943
+ return;
1944
+ }
1945
+
1946
+ const { path: filePath, content } = params;
1947
+ if (!filePath || typeof content !== 'string') {
1948
+ jsonResponse(res, cors, 400, { error: 'Missing path or content parameter' });
1949
+ return;
1950
+ }
1951
+
1952
+ const resolved = resolveSafe(filePath);
1953
+ if (!resolved) {
1954
+ jsonResponse(res, cors, 403, { error: 'Forbidden' });
1955
+ return;
1956
+ }
1957
+ try {
1958
+ fs.writeFileSync(resolved, content, 'utf8');
1959
+ // Update symbol index for saved file
1960
+ try {
1961
+ const relPath = path.relative(PROJECT_DIR, resolved);
1962
+ symbolIndex.updateFileIndex(PROJECT_DIR, relPath);
1963
+ } catch {}
1964
+ jsonResponse(res, cors, 200, { ok: true, path: resolved });
1965
+ } catch (err) {
1966
+ jsonResponse(res, cors, 500, { error: `Cannot write file: ${err.message}` });
1967
+ }
1968
+ return;
1969
+ }
1970
+
1971
+ // ─── CSV/TSV parse endpoint ────────────────────────────────────
1972
+ if (req.method === 'POST' && parsedUrl.pathname === '/browse/file/parse-csv') {
1973
+ const body = await readBody(req);
1974
+ let params;
1975
+ try {
1976
+ params = JSON.parse(body);
1977
+ } catch {
1978
+ jsonResponse(res, cors, 400, { error: 'Invalid JSON' });
1979
+ return;
1980
+ }
1981
+
1982
+ const { path: filePath, delimiter } = params;
1983
+ if (!filePath || (delimiter !== ',' && delimiter !== '\t')) {
1984
+ jsonResponse(res, cors, 400, { error: 'Missing path or invalid delimiter (use "," or "\\t")' });
1985
+ return;
1986
+ }
1987
+
1988
+ const resolved = resolveSafe(filePath);
1989
+ if (!resolved) {
1990
+ jsonResponse(res, cors, 403, { error: 'Forbidden' });
1991
+ return;
1992
+ }
1993
+ try {
1994
+ const stat = fs.statSync(resolved);
1995
+ if (stat.size > 2 * 1024 * 1024) {
1996
+ jsonResponse(res, cors, 400, { error: 'File too large (>2MB)' });
1997
+ return;
1998
+ }
1999
+ const content = fs.readFileSync(resolved, 'utf8');
2000
+ const result = parseDelimited(content, delimiter);
2001
+ jsonResponse(res, cors, 200, result);
2002
+ } catch (err) {
2003
+ jsonResponse(res, cors, 400, { error: `Parse failed: ${err.message}` });
2004
+ }
2005
+ return;
2006
+ }
2007
+
2008
+ // ─── Git endpoints ─────────────────────────────────────────────
2009
+
2010
+ // Resolve the active repo directory from an optional ?repo=<relPath> param.
2011
+ // Returns { repoDir, repoRel } where repoRel is the stripped prefix (e.g. "repo-a").
2012
+ function getRepoDir(pu) {
2013
+ const repo = pu.searchParams.get('repo');
2014
+ if (!repo || repo === '.' || repo === '') return { repoDir: PROJECT_DIR, repoRel: '' };
2015
+ // Strip any path traversal attempts
2016
+ const safe = path.normalize(repo).replace(/^(\.\.\/|\/|\.\.\\|\\)+/, '');
2017
+ if (!safe) return { repoDir: PROJECT_DIR, repoRel: '' };
2018
+ const resolved = path.resolve(PROJECT_DIR, safe);
2019
+ // Must stay inside PROJECT_DIR
2020
+ if (resolved !== PROJECT_DIR && !resolved.startsWith(PROJECT_DIR + path.sep)) {
2021
+ return { repoDir: PROJECT_DIR, repoRel: '' };
2022
+ }
2023
+ return { repoDir: resolved, repoRel: safe };
2024
+ }
2025
+
2026
+ // Strip a repo-relative prefix from a file path so git commands get the
2027
+ // right path relative to their cwd (the subrepo root).
2028
+ function stripRepoPrefix(filePath, repoRel) {
2029
+ if (!repoRel) return filePath;
2030
+ const prefix = repoRel.replace(/\\/g, '/') + '/';
2031
+ const fp = filePath.replace(/\\/g, '/');
2032
+ return fp.startsWith(prefix) ? fp.slice(prefix.length) : fp;
2033
+ }
2034
+
2035
+ // GET /git/repos — discover all git repos at PROJECT_DIR and one level deep
2036
+ if (req.method === 'GET' && parsedUrl.pathname === '/git/repos') {
2037
+ const repos = [];
2038
+ // Check root itself
2039
+ try {
2040
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: PROJECT_DIR, encoding: 'utf8', timeout: 5000 }).trim();
2041
+ repos.push({ name: path.basename(PROJECT_DIR), relPath: '.', branch, isRoot: true });
2042
+ } catch {}
2043
+ // Scan one level of subdirectories
2044
+ try {
2045
+ const entries = fs.readdirSync(PROJECT_DIR, { withFileTypes: true });
2046
+ for (const entry of entries) {
2047
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
2048
+ const subDir = path.join(PROJECT_DIR, entry.name);
2049
+ if (!fs.existsSync(path.join(subDir, '.git'))) continue;
2050
+ try {
2051
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: subDir, encoding: 'utf8', timeout: 5000 }).trim();
2052
+ repos.push({ name: entry.name, relPath: entry.name, branch, isRoot: false });
2053
+ } catch {}
2054
+ }
2055
+ } catch {}
2056
+ jsonResponse(res, cors, 200, { repos });
2057
+ return;
2058
+ }
2059
+
2060
+ if (req.method === 'GET' && parsedUrl.pathname === '/git/status') {
2061
+ const { repoDir, repoRel } = getRepoDir(parsedUrl);
2062
+ try {
2063
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: repoDir, encoding: 'utf8', timeout: 5000 }).trim();
2064
+ const statusRaw = execSync('git status --porcelain', { cwd: repoDir, encoding: 'utf8', timeout: 5000 });
2065
+ const changes = [];
2066
+ for (const line of statusRaw.split('\n')) {
2067
+ if (!line) continue;
2068
+ const xy = line.substring(0, 2);
2069
+ const filePath = line.substring(3).trim();
2070
+ // Skip renamed file arrow notation — use destination
2071
+ const actualPath = filePath.includes(' -> ') ? filePath.split(' -> ')[1] : filePath;
2072
+ let status = 'modified';
2073
+ if (xy[0] === '?' || xy[1] === '?') status = 'untracked';
2074
+ else if (xy[0] === 'A' || xy[1] === 'A') status = 'added';
2075
+ else if (xy[0] === 'D' || xy[1] === 'D') status = 'deleted';
2076
+ else if (xy[0] === 'R' || xy[1] === 'R') status = 'renamed';
2077
+ const staged = xy[0] !== ' ' && xy[0] !== '?';
2078
+ // Prefix path with repoRel so the browser can locate the file from PROJECT_DIR
2079
+ const fullPath = repoRel && repoRel !== '.' ? repoRel + '/' + actualPath : actualPath;
2080
+ changes.push({ path: fullPath, status, staged });
2081
+ }
2082
+ jsonResponse(res, cors, 200, { branch, changes, repoRel: repoRel || null });
2083
+ } catch (err) {
2084
+ // Not a git repo or git not available
2085
+ jsonResponse(res, cors, 200, { branch: null, changes: [], error: err.message });
2086
+ }
2087
+ return;
2088
+ }
2089
+
2090
+ if (req.method === 'GET' && parsedUrl.pathname === '/git/diff') {
2091
+ const filePath = parsedUrl.searchParams.get('path');
2092
+ if (!filePath) {
2093
+ jsonResponse(res, cors, 400, { error: 'Missing path parameter' });
2094
+ return;
2095
+ }
2096
+ const { repoDir, repoRel } = getRepoDir(parsedUrl);
2097
+ // File path relative to the repo root (strip subrepo prefix if present)
2098
+ const gitPath = stripRepoPrefix(filePath, repoRel);
2099
+ try {
2100
+ // Try staged diff first, then unstaged
2101
+ let diff = '';
2102
+ try {
2103
+ diff = execFileSync('git', ['diff', '--', gitPath], { cwd: repoDir, encoding: 'utf8', timeout: 10000 });
2104
+ } catch {}
2105
+ if (!diff) {
2106
+ try {
2107
+ diff = execFileSync('git', ['diff', '--cached', '--', gitPath], { cwd: repoDir, encoding: 'utf8', timeout: 10000 });
2108
+ } catch {}
2109
+ }
2110
+ // For untracked files, show full content as "added"
2111
+ if (!diff) {
2112
+ const resolved = path.resolve(repoDir, gitPath);
2113
+ try {
2114
+ const content = fs.readFileSync(resolved, 'utf8');
2115
+ diff = '--- /dev/null\n+++ b/' + gitPath + '\n@@ -0,0 +1,' + content.split('\n').length + ' @@\n' +
2116
+ content.split('\n').map(l => '+' + l).join('\n');
2117
+ } catch {}
2118
+ }
2119
+ // Get original (HEAD) content for side-by-side diff
2120
+ let original = '';
2121
+ try {
2122
+ original = execFileSync('git', ['show', `HEAD:${gitPath}`], { cwd: repoDir, encoding: 'utf8', timeout: 5000 });
2123
+ } catch {}
2124
+ // Get current working copy
2125
+ let modified = '';
2126
+ try {
2127
+ const resolved = path.resolve(repoDir, gitPath);
2128
+ modified = fs.readFileSync(resolved, 'utf8');
2129
+ } catch {}
2130
+ jsonResponse(res, cors, 200, { diff, original, modified, path: filePath });
2131
+ } catch (err) {
2132
+ jsonResponse(res, cors, 400, { error: err.message });
2133
+ }
2134
+ return;
2135
+ }
2136
+
2137
+ // ─── GitHub PR endpoints ──────────────────────────────────────────
2138
+
2139
+ if (req.method === 'GET' && parsedUrl.pathname === '/git/pr-info') {
2140
+ const { repoDir: prInfoDir } = getRepoDir(parsedUrl);
2141
+ try {
2142
+ const raw = execSync('gh pr view --json number,title,state,url,headRefName,baseRefName,author,reviewDecision', {
2143
+ cwd: prInfoDir, encoding: 'utf8', timeout: 10000,
2144
+ });
2145
+ const pr = JSON.parse(raw);
2146
+ jsonResponse(res, cors, 200, { pr: {
2147
+ number: pr.number,
2148
+ title: pr.title,
2149
+ state: pr.state,
2150
+ url: pr.url,
2151
+ branch: pr.headRefName,
2152
+ base: pr.baseRefName,
2153
+ author: (pr.author && pr.author.login) ? pr.author.login : String(pr.author || ''),
2154
+ reviewDecision: pr.reviewDecision || null,
2155
+ }});
2156
+ } catch (err) {
2157
+ const msg = err.message || '';
2158
+ if (msg.includes('ENOENT') || msg.includes('executable not found')) {
2159
+ jsonResponse(res, cors, 200, { pr: null, ghMissing: true });
2160
+ } else {
2161
+ jsonResponse(res, cors, 200, { pr: null, error: msg.split('\n')[0] });
2162
+ }
2163
+ }
2164
+ return;
2165
+ }
2166
+
2167
+ if (req.method === 'GET' && parsedUrl.pathname === '/git/pr-comments') {
2168
+ const prNumber = parsedUrl.searchParams.get('pr');
2169
+ if (!prNumber) { jsonResponse(res, cors, 400, { error: 'Missing pr parameter' }); return; }
2170
+ if (!/^\d+$/.test(prNumber)) { jsonResponse(res, cors, 400, { error: 'Invalid pr parameter' }); return; }
2171
+ const { repoDir: prCommentsDir } = getRepoDir(parsedUrl);
2172
+ try {
2173
+ // Resolve owner/repo from gh CLI
2174
+ let repo = null;
2175
+ try {
2176
+ repo = execSync("gh repo view --json owner,name --jq '.owner.login + \"/\" + .name'", {
2177
+ cwd: prCommentsDir, encoding: 'utf8', timeout: 10000,
2178
+ }).trim();
2179
+ } catch {}
2180
+
2181
+ if (!repo) {
2182
+ jsonResponse(res, cors, 200, { error: 'Could not determine repository', threads: [], generalComments: [], reviews: [] });
2183
+ return;
2184
+ }
2185
+
2186
+ // Inline review comments
2187
+ let reviewComments = [];
2188
+ try {
2189
+ const raw = execSync(`gh api repos/${repo}/pulls/${prNumber}/comments --paginate`, {
2190
+ cwd: PROJECT_DIR, encoding: 'utf8', timeout: 30000,
2191
+ });
2192
+ reviewComments = JSON.parse(raw);
2193
+ if (reviewComments.length > 500) reviewComments = reviewComments.slice(0, 500);
2194
+ } catch {}
2195
+
2196
+ // General PR conversation comments
2197
+ let generalComments = [];
2198
+ try {
2199
+ const raw = execSync(`gh api repos/${repo}/issues/${prNumber}/comments --paginate`, {
2200
+ cwd: PROJECT_DIR, encoding: 'utf8', timeout: 30000,
2201
+ });
2202
+ generalComments = JSON.parse(raw).map(function(c) {
2203
+ return {
2204
+ id: c.id,
2205
+ body: c.body,
2206
+ author: (c.user && c.user.login) ? c.user.login : '',
2207
+ createdAt: c.created_at,
2208
+ updatedAt: c.updated_at,
2209
+ url: c.html_url,
2210
+ };
2211
+ });
2212
+ } catch {}
2213
+
2214
+ // Reviews (APPROVED / CHANGES_REQUESTED / etc.)
2215
+ let reviews = [];
2216
+ try {
2217
+ const raw = execSync(`gh api repos/${repo}/pulls/${prNumber}/reviews`, {
2218
+ cwd: PROJECT_DIR, encoding: 'utf8', timeout: 15000,
2219
+ });
2220
+ reviews = JSON.parse(raw).map(function(r) {
2221
+ return {
2222
+ id: r.id,
2223
+ state: r.state,
2224
+ body: r.body || '',
2225
+ author: (r.user && r.user.login) ? r.user.login : '',
2226
+ submittedAt: r.submitted_at,
2227
+ };
2228
+ }).filter(function(r) { return r.state !== 'COMMENTED' || r.body; });
2229
+ } catch {}
2230
+
2231
+ // Thread inline comments: group by path + line + review_id
2232
+ const commentMap = {};
2233
+ for (const c of reviewComments) commentMap[c.id] = c;
2234
+
2235
+ const threadMap = {};
2236
+ const rootComments = reviewComments.filter(function(c) { return !c.in_reply_to_id; });
2237
+ for (const c of rootComments) {
2238
+ const tid = String(c.pull_request_review_id) + ':' + c.path + ':' + (c.line || c.original_line || 0);
2239
+ if (!threadMap[tid]) {
2240
+ threadMap[tid] = {
2241
+ threadId: tid,
2242
+ file: c.path,
2243
+ line: c.line || c.original_line || 0,
2244
+ startLine: c.start_line || c.line || c.original_line || 0,
2245
+ diffHunk: c.diff_hunk || '',
2246
+ resolved: false,
2247
+ comments: [],
2248
+ };
2249
+ }
2250
+ threadMap[tid].comments.push({
2251
+ id: c.id,
2252
+ author: (c.user && c.user.login) ? c.user.login : '',
2253
+ body: c.body,
2254
+ createdAt: c.created_at,
2255
+ url: c.html_url,
2256
+ isReply: false,
2257
+ });
2258
+ }
2259
+
2260
+ // Attach replies
2261
+ const replyComments = reviewComments.filter(function(c) { return !!c.in_reply_to_id; });
2262
+ for (const c of replyComments) {
2263
+ const parent = commentMap[c.in_reply_to_id];
2264
+ if (!parent) continue;
2265
+ const tid = String(parent.pull_request_review_id) + ':' + parent.path + ':' + (parent.line || parent.original_line || 0);
2266
+ if (threadMap[tid]) {
2267
+ threadMap[tid].comments.push({
2268
+ id: c.id,
2269
+ author: (c.user && c.user.login) ? c.user.login : '',
2270
+ body: c.body,
2271
+ createdAt: c.created_at,
2272
+ url: c.html_url,
2273
+ isReply: true,
2274
+ });
2275
+ }
2276
+ }
2277
+
2278
+ const threads = Object.values(threadMap).sort(function(a, b) {
2279
+ return a.file.localeCompare(b.file) || a.line - b.line;
2280
+ });
2281
+
2282
+ jsonResponse(res, cors, 200, { pr: parseInt(prNumber), threads, generalComments, reviews });
2283
+ } catch (err) {
2284
+ const msg = err.message || '';
2285
+ if (msg.includes('ENOENT') || msg.includes('executable not found')) {
2286
+ jsonResponse(res, cors, 200, { error: 'gh CLI not installed', ghMissing: true, threads: [], generalComments: [], reviews: [] });
2287
+ } else {
2288
+ jsonResponse(res, cors, 200, { error: msg.split('\n')[0], threads: [], generalComments: [], reviews: [] });
2289
+ }
2290
+ }
2291
+ return;
2292
+ }
2293
+
2294
+ // ─── Permission approval endpoints ─────────────────────────
2295
+
2296
+ // POST /pre-tool-use — relay posts permission request
2297
+ if (req.method === 'POST' && parsedUrl.pathname === '/pre-tool-use') {
2298
+ loadSettingsCache(); // Refresh from disk so CLI flag changes take effect immediately
2299
+ const body = await readBody(req);
2300
+ let params;
2301
+ try { params = JSON.parse(body); } catch {
2302
+ jsonResponse(res, cors, 400, { error: 'Invalid JSON' });
2303
+ return;
2304
+ }
2305
+
2306
+ const { tool, command, sessionId: claudeSessionId } = params;
2307
+
2308
+ // Fast path: deny first (CC evaluates deny before any allow/hook
2309
+ // decision), then settings allowlist, then CC's built-in read-only
2310
+ // classifier — so read-only commands the CLI auto-approves (ls, cat,
2311
+ // grep, read-only git forms, read-only tools) never reach a card.
2312
+ if (matchesPermission(command, settingsCache.deny)) {
2313
+ jsonResponse(res, cors, 200, { id: randomUUID(), status: 'denied' });
2314
+ return;
2315
+ }
2316
+ if (matchesPermission(command, settingsCache.allow) ||
2317
+ isCcReadOnlyAutoApprovable(tool, { command })) {
2318
+ jsonResponse(res, cors, 200, { id: randomUUID(), status: 'allowed' });
2319
+ return;
2320
+ }
2321
+
2322
+ // Pending — store and broadcast
2323
+ const id = randomUUID();
2324
+ permissionRequests.set(id, {
2325
+ id, tool, command, sessionId: claudeSessionId, decision: null, createdAt: Date.now(),
2326
+ });
2327
+
2328
+ // Find most-recently-active running session for routing
2329
+ let activeStore = null;
2330
+ let activeSessionId = null;
2331
+ for (const [sid, store] of sessions) {
2332
+ if (store.chatSession && store.chatSession.isRunning) {
2333
+ if (!activeStore || store.lastActivityAt > activeStore.lastActivityAt) {
2334
+ activeStore = store;
2335
+ activeSessionId = sid;
2336
+ }
2337
+ }
2338
+ }
2339
+
2340
+ broadcast({
2341
+ type: 'permission-request',
2342
+ id,
2343
+ tool: tool || 'Bash',
2344
+ command: command || '',
2345
+ sessionId: activeSessionId,
2346
+ });
2347
+
2348
+ jsonResponse(res, cors, 200, { id, status: 'pending' });
2349
+ return;
2350
+ }
2351
+
2352
+ // GET /chat/permission/:id — relay polls for decision
2353
+ if (req.method === 'GET' && parsedUrl.pathname.startsWith('/chat/permission/')) {
2354
+ const id = parsedUrl.pathname.split('/').pop();
2355
+ const entry = permissionRequests.get(id);
2356
+
2357
+ if (!entry) {
2358
+ jsonResponse(res, cors, 200, { status: 'not_found' });
2359
+ return;
2360
+ }
2361
+ if (entry.decision === null) {
2362
+ jsonResponse(res, cors, 200, { status: 'pending' });
2363
+ return;
2364
+ }
2365
+
2366
+ const decision = entry.decision;
2367
+ permissionRequests.delete(id);
2368
+
2369
+ broadcast({
2370
+ type: 'permission-resolved',
2371
+ id,
2372
+ decision,
2373
+ });
2374
+
2375
+ jsonResponse(res, cors, 200, { status: decision });
2376
+ return;
2377
+ }
2378
+
2379
+ // POST /chat/permission — browser submits decision
2380
+ if (req.method === 'POST' && parsedUrl.pathname === '/chat/permission') {
2381
+ const body = await readBody(req);
2382
+ let params;
2383
+ try { params = JSON.parse(body); } catch {
2384
+ jsonResponse(res, cors, 400, { error: 'Invalid JSON' });
2385
+ return;
2386
+ }
2387
+
2388
+ const { id, decision, persist, command } = params;
2389
+ const entry = permissionRequests.get(id);
2390
+ if (!entry) {
2391
+ jsonResponse(res, cors, 404, { error: 'Permission request not found' });
2392
+ return;
2393
+ }
2394
+
2395
+ entry.decision = decision;
2396
+
2397
+ if (persist && command) {
2398
+ persistPermission(command, decision);
2399
+ }
2400
+
2401
+ jsonResponse(res, cors, 200, { ok: true });
2402
+ return;
2403
+ }
2404
+
2405
+ // ─── Gate state endpoint ────────────────────────────────────────
2406
+
2407
+ // GET /gate-state — returns current gate state from .shards/gates/state.json
2408
+ if (req.method === 'GET' && parsedUrl.pathname === '/gate-state') {
2409
+ const gateStatePath = path.join(SHARDS_DIR, 'gates', 'state.json');
2410
+ try {
2411
+ const raw = fs.readFileSync(gateStatePath, 'utf8');
2412
+ // Collapse the v2 per-session map (or legacy single slot) to the flat
2413
+ // gate object the browser expects. Contract unchanged.
2414
+ jsonResponse(res, cors, 200, reduceGateState(JSON.parse(raw)));
2415
+ } catch {
2416
+ jsonResponse(res, cors, 200, { open: false, history: [] });
2417
+ }
2418
+ return;
2419
+ }
2420
+
2421
+ // GET /gate-state-stream — SSE channel pushing gate-state / gate-block /
2422
+ // auto-state events on file change. Browser uses this in place of the
2423
+ // legacy 2s poll (Bug H2 + H5 in the integration audit). On connect we
2424
+ // immediately send the current gate-state so the client doesn't need a
2425
+ // second /gate-state request to seed initial state.
2426
+ if (req.method === 'GET' && parsedUrl.pathname === '/gate-state-stream') {
2427
+ res.writeHead(200, {
2428
+ ...cors,
2429
+ 'Content-Type': 'text/event-stream',
2430
+ 'Cache-Control': 'no-cache',
2431
+ 'Connection': 'keep-alive',
2432
+ 'X-Accel-Buffering': 'no',
2433
+ });
2434
+ res.write('data: {"type":"connected"}\n\n');
2435
+ // Seed initial gate state for this client only — broadcast helpers
2436
+ // append to all subscribers, but a fresh subscriber needs the snapshot
2437
+ // before its first change event arrives.
2438
+ const initial = readGateStateFile() || { open: false, history: [] };
2439
+ try { res.write(`data: ${JSON.stringify({ type: 'gate-state', state: initial })}\n\n`); } catch {}
2440
+ gateClients.push({ res });
2441
+ req.on('close', () => {
2442
+ gateClients = gateClients.filter((c) => c.res !== res);
2443
+ });
2444
+ return;
2445
+ }
2446
+
2447
+ // GET /gate-mode — returns enforcement flags as the UI server sees them.
2448
+ // The Claude Code hook process runs in a different env; if the user only
2449
+ // exports SHARDS_GATE_ENFORCE=0 to CC without also exporting it to the UI
2450
+ // server, this endpoint will still report enforcement on. The browser
2451
+ // surfaces a banner reflecting whatever the server reports — accurate for
2452
+ // the common case where both processes are launched from the same shell.
2453
+ if (req.method === 'GET' && parsedUrl.pathname === '/gate-mode') {
2454
+ jsonResponse(res, cors, 200, {
2455
+ enforce: GATE_ENFORCE,
2456
+ checkpointEnforce: CHECKPOINT_ENFORCE,
2457
+ autoVerify: AUTO_VERIFY,
2458
+ });
2459
+ return;
2460
+ }
2461
+
2462
+ // ─── Permissions management endpoints ──────────────────────────
2463
+
2464
+ // GET /permissions — return current allow/deny lists + available presets
2465
+ if (req.method === 'GET' && parsedUrl.pathname === '/permissions') {
2466
+ loadSettingsCache();
2467
+ jsonResponse(res, cors, 200, {
2468
+ allow: settingsCache.allow,
2469
+ deny: settingsCache.deny,
2470
+ presets: {
2471
+ permissive: [
2472
+ ...READONLY_PRESET,
2473
+ 'Bash(python:*)', 'Bash(python3:*)', 'Bash(node:*)',
2474
+ 'Bash(npm:*)', 'Bash(pip:*)', 'Bash(pip3:*)',
2475
+ ],
2476
+ readonly: [...READONLY_PRESET],
2477
+ },
2478
+ });
2479
+ return;
2480
+ }
2481
+
2482
+ // POST /permissions — update allow list
2483
+ if (req.method === 'POST' && parsedUrl.pathname === '/permissions') {
2484
+ const body = await readBody(req);
2485
+ let params;
2486
+ try { params = JSON.parse(body); } catch {
2487
+ jsonResponse(res, cors, 400, { error: 'Invalid JSON' });
2488
+ return;
2489
+ }
2490
+
2491
+ const settingsPath = path.join(PROJECT_DIR, '.claude', 'settings.json');
2492
+ let settings = {};
2493
+ try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
2494
+
2495
+ if (!settings.permissions) settings.permissions = {};
2496
+ if (!settings.permissions.allow) settings.permissions.allow = [];
2497
+
2498
+ // params.add: array of patterns to add
2499
+ if (Array.isArray(params.add)) {
2500
+ for (const p of params.add) {
2501
+ if (typeof p === 'string' && p.trim() && !settings.permissions.allow.includes(p.trim())) {
2502
+ settings.permissions.allow.push(p.trim());
2503
+ }
2504
+ }
2505
+ }
2506
+
2507
+ // params.remove: array of patterns to remove
2508
+ if (Array.isArray(params.remove)) {
2509
+ const toRemove = new Set(params.remove.map(p => (typeof p === 'string' ? p.trim() : '')));
2510
+ settings.permissions.allow = settings.permissions.allow.filter(p => !toRemove.has(p));
2511
+ }
2512
+
2513
+ // params.set: replace the entire allow list
2514
+ if (Array.isArray(params.set)) {
2515
+ settings.permissions.allow = params.set.filter(p => typeof p === 'string' && p.trim());
2516
+ }
2517
+
2518
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
2519
+ loadSettingsCache();
2520
+
2521
+ jsonResponse(res, cors, 200, { ok: true, allow: settingsCache.allow });
2522
+ return;
2523
+ }
2524
+
2525
+ // ─── P2: Event endpoint with ack ────────────────────────────
2526
+ if (req.method === 'POST' && parsedUrl.pathname === '/event') {
2527
+ const body = await readBody(req);
2528
+ const result = handleEvent(body);
2529
+ jsonResponse(res, cors, 200, result);
2530
+ return;
2531
+ }
2532
+
2533
+ // ─── Chat endpoints ─────────────────────────────────────────────
2534
+
2535
+ if (req.method === 'GET' && parsedUrl.pathname === '/agents') {
2536
+ jsonResponse(res, cors, 200, listAgents());
2537
+ return;
2538
+ }
2539
+
2540
+ if (req.method === 'GET' && parsedUrl.pathname === '/api/skills') {
2541
+ jsonResponse(res, cors, 200, listSkills());
2542
+ return;
2543
+ }
2544
+
2545
+ if (req.method === 'GET' && parsedUrl.pathname === '/chat/status') {
2546
+ const activeSessions = [];
2547
+ for (const [id, store] of sessions) {
2548
+ if (store.chatSession && store.chatSession.isRunning) {
2549
+ activeSessions.push({
2550
+ sessionId: id,
2551
+ agent: store.agent,
2552
+ title: store.title,
2553
+ transcript: store.transcript,
2554
+ startedAt: store.createdAt.toISOString(),
2555
+ projectName: store.projectName,
2556
+ projectDir: store.projectDir,
2557
+ contextUsage: store.contextUsage,
2558
+ });
2559
+ }
2560
+ }
2561
+ jsonResponse(res, cors, 200, { sessions: activeSessions });
2562
+ return;
2563
+ }
2564
+
2565
+ if (req.method === 'POST' && parsedUrl.pathname === '/chat/start') {
2566
+ const body = await readBody(req);
2567
+ let params;
2568
+ try {
2569
+ params = JSON.parse(body);
2570
+ } catch {
2571
+ jsonResponse(res, cors, 400, { error: 'Invalid JSON' });
2572
+ return;
2573
+ }
2574
+
2575
+ const { agent, permissionMode, sessionId: callerSessionId, initialMessage,
2576
+ resumeSessionId, model } = params;
2577
+
2578
+ // agent is optional — null/omitted starts a plain session.
2579
+ if (agent) {
2580
+ const agentFile = path.join(AGENTS_DIR, `${agent}.md`);
2581
+ if (!fs.existsSync(agentFile)) {
2582
+ jsonResponse(res, cors, 404, { error: `Agent "${agent}" not found` });
2583
+ return;
2584
+ }
2585
+ }
2586
+
2587
+ // Validate resume target. We allow resuming only sessions that have
2588
+ // ended or been abandoned — resuming a session whose process is still
2589
+ // believed to be live would race with the existing CLI on --session-id.
2590
+ if (resumeSessionId) {
2591
+ const entry = sessionIndex.getEntry(SESSIONS_DIR, resumeSessionId);
2592
+ if (!entry) {
2593
+ jsonResponse(res, cors, 404, { error: `Resume target "${resumeSessionId}" not found in session index` });
2594
+ return;
2595
+ }
2596
+ if (entry.status === 'active') {
2597
+ const live = sessions.get(resumeSessionId);
2598
+ if (live && live.chatSession && live.chatSession.isRunning) {
2599
+ jsonResponse(res, cors, 409, { error: 'Resume target is still active. End it first.' });
2600
+ return;
2601
+ }
2602
+ }
2603
+ }
2604
+
2605
+ log(`/chat/start requested for agent="${agent || '(plain)'}"${resumeSessionId ? ` resume="${resumeSessionId}"` : ''}`);
2606
+ const result = startNewChatSession(agent || null, {
2607
+ permissionMode, callerSessionId, initialMessage, resumeSessionId, model,
2608
+ });
2609
+ jsonResponse(res, cors, 200, result);
2610
+ return;
2611
+ }
2612
+
2613
+ // ─── Permission mode switch ─────────────────────────────────────────────
2614
+ if (req.method === 'POST' && parsedUrl.pathname === '/chat/mode') {
2615
+ const body = await readBody(req);
2616
+ let params;
2617
+ try {
2618
+ params = JSON.parse(body);
2619
+ } catch {
2620
+ jsonResponse(res, cors, 400, { error: 'Invalid JSON' });
2621
+ return;
2622
+ }
2623
+
2624
+ const { sessionId: targetSessionId, mode } = params;
2625
+ const validModes = ['default', 'acceptEdits', 'plan'];
2626
+ if (!mode || !validModes.includes(mode)) {
2627
+ jsonResponse(res, cors, 400, { error: `Invalid mode. Use one of: ${validModes.join(', ')}` });
2628
+ return;
2629
+ }
2630
+
2631
+ const store = targetSessionId ? getSession(targetSessionId) : null;
2632
+ if (store && store.chatSession && store.chatSession.isRunning) {
2633
+ const agent = store.agent;
2634
+ broadcast({ type: 'chat-system-notice', text: 'Switching to **' + mode + '** mode…', sessionId: targetSessionId });
2635
+ const result = startNewChatSession(agent, { callerSessionId: targetSessionId, resumeSessionId: targetSessionId, permissionMode: mode });
2636
+ jsonResponse(res, cors, 200, { ok: true, switched: true, agent: result.agent, sessionId: result.sessionId });
2637
+ } else {
2638
+ // No active session — mode will be applied on next session start
2639
+ jsonResponse(res, cors, 200, { ok: true, deferred: true });
2640
+ }
2641
+ return;
2642
+ }
2643
+
2644
+ if (req.method === 'POST' && parsedUrl.pathname === '/chat/send') {
2645
+ const body = await readBody(req);
2646
+ let params;
2647
+ try {
2648
+ params = JSON.parse(body);
2649
+ } catch {
2650
+ jsonResponse(res, cors, 400, { error: 'Invalid JSON' });
2651
+ return;
2652
+ }
2653
+
2654
+ const { message, sessionId: targetSessionId, attachments: rawAttachments } = params;
2655
+ const attachments = Array.isArray(rawAttachments) ? rawAttachments : [];
2656
+ if (!message && attachments.length === 0) {
2657
+ jsonResponse(res, cors, 400, { error: 'Missing message parameter' });
2658
+ return;
2659
+ }
2660
+
2661
+ // ─── Attachment validation ──────────────────────────────
2662
+ // Mirrors the client-side checks in chat.js so we don't ship malformed
2663
+ // image blocks to the Claude CLI. Anthropic's Messages API rejects
2664
+ // unsupported MIME types and oversize payloads; better to fail fast here
2665
+ // with a clear error than to surface an opaque CLI error mid-turn.
2666
+ const ATTACH_MAX_COUNT = 5;
2667
+ const ATTACH_MAX_BYTES = 5 * 1024 * 1024;
2668
+ const ATTACH_ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
2669
+ if (attachments.length > ATTACH_MAX_COUNT) {
2670
+ jsonResponse(res, cors, 400, { error: `Too many attachments (max ${ATTACH_MAX_COUNT}).` });
2671
+ return;
2672
+ }
2673
+ for (const a of attachments) {
2674
+ if (!a || typeof a !== 'object') {
2675
+ jsonResponse(res, cors, 400, { error: 'Invalid attachment entry' });
2676
+ return;
2677
+ }
2678
+ if (!ATTACH_ALLOWED_MIME.has(a.mediaType)) {
2679
+ jsonResponse(res, cors, 400, { error: `Unsupported attachment mediaType: ${a.mediaType}` });
2680
+ return;
2681
+ }
2682
+ if (typeof a.dataBase64 !== 'string' || a.dataBase64.length === 0) {
2683
+ jsonResponse(res, cors, 400, { error: 'Attachment missing dataBase64' });
2684
+ return;
2685
+ }
2686
+ // Decoded size = ceil(base64_len * 3 / 4) - padding
2687
+ const padding = a.dataBase64.endsWith('==') ? 2 : a.dataBase64.endsWith('=') ? 1 : 0;
2688
+ const decodedBytes = Math.floor(a.dataBase64.length * 3 / 4) - padding;
2689
+ if (decodedBytes > ATTACH_MAX_BYTES) {
2690
+ jsonResponse(res, cors, 400, { error: 'Attachment exceeds 5MB limit' });
2691
+ return;
2692
+ }
2693
+ }
2694
+
2695
+ // ─── Slash command interception ─────────────────────────
2696
+ const slashCmd = parseSlashCommand(message);
2697
+
2698
+ if (slashCmd) {
2699
+ if (slashCmd.type === 'agent') {
2700
+ const toAgent = slashCmd.agent;
2701
+ const agentFile = path.join(AGENTS_DIR, `${toAgent}.md`);
2702
+ if (!fs.existsSync(agentFile)) {
2703
+ jsonResponse(res, cors, 404, { error: `Agent "${toAgent}" not found` });
2704
+ return;
2705
+ }
2706
+
2707
+ // Find caller's current session to get fromAgent
2708
+ const callerStore = targetSessionId ? getSession(targetSessionId) : null;
2709
+ const fromAgent = callerStore ? callerStore.agent : null;
2710
+
2711
+ broadcast({ type: 'chat-agent-switching', from: fromAgent, to: toAgent, sessionId: targetSessionId });
2712
+ const result = startNewChatSession(toAgent, { callerSessionId: targetSessionId });
2713
+ jsonResponse(res, cors, 200, { ok: true, switched: true, agent: result.agent, sessionId: result.sessionId });
2714
+ return;
2715
+ }
2716
+
2717
+ if (slashCmd.command === 'stop') {
2718
+ const store = targetSessionId ? getSession(targetSessionId) : null;
2719
+ if (store && store.chatSession && store.chatSession.isRunning) {
2720
+ store.chatSession.stop();
2721
+ }
2722
+ jsonResponse(res, cors, 200, { ok: true, stopped: true });
2723
+ return;
2724
+ }
2725
+
2726
+ if (slashCmd.command === 'help') {
2727
+ const agents = listAgents();
2728
+ let helpText = '**Available Commands**\n\n';
2729
+ helpText += '**Agents:**\n';
2730
+ for (const a of agents) {
2731
+ helpText += ` \`/${a.name}\` — ${a.description || 'Switch to ' + a.name}\n`;
2732
+ }
2733
+ helpText += '\n**Utility:**\n';
2734
+ helpText += ' `/compact` — Summarize conversation to free up context\n';
2735
+ helpText += ' `/stop` — Stop the current session\n';
2736
+ helpText += ' `/clear` — Clear the messages panel\n';
2737
+ helpText += ' `/help` — Show this help message\n';
2738
+ helpText += '\n**Claude Code:**\n';
2739
+ helpText += ' `/init` — Initialize a CLAUDE.md file\n';
2740
+ helpText += ' `/exit` — End the current session\n';
2741
+ helpText += ' `/context` — Show token usage (terminal only)\n';
2742
+ helpText += ' `/rewind` — Restore to previous point (terminal only)\n';
2743
+ helpText += ' `/config` — Open settings panel\n';
2744
+ helpText += ' `/model <name>` — Change the active LLM model\n';
2745
+ helpText += ' `/effort <level>` — Set compute intensity (low/medium/high)\n';
2746
+ helpText += ' `/mode` — Cycle permission mode (Shift+Tab)\n';
2747
+ broadcast({ type: 'chat-system-notice', text: helpText, sessionId: targetSessionId });
2748
+ jsonResponse(res, cors, 200, { ok: true, help: true });
2749
+ return;
2750
+ }
2751
+
2752
+ if (slashCmd.command === 'clear') {
2753
+ broadcast({ type: 'chat-clear-messages', sessionId: targetSessionId });
2754
+ jsonResponse(res, cors, 200, { ok: true, cleared: true });
2755
+ return;
2756
+ }
2757
+ }
2758
+
2759
+ // ─── Claude Code built-in command handling ─────────────
2760
+ if (slashCmd && slashCmd.type === 'builtin') {
2761
+ if (slashCmd.command === 'exit') {
2762
+ const store = targetSessionId ? getSession(targetSessionId) : null;
2763
+ if (store && store.chatSession && store.chatSession.isRunning) {
2764
+ store.chatSession.stop();
2765
+ }
2766
+ jsonResponse(res, cors, 200, { ok: true, stopped: true });
2767
+ return;
2768
+ }
2769
+
2770
+ if (slashCmd.command === 'model') {
2771
+ if (!slashCmd.args) {
2772
+ broadcast({ type: 'chat-system-notice', text: 'Usage: `/model <model-name>` — e.g., `/model claude-sonnet-4-6`', sessionId: targetSessionId });
2773
+ jsonResponse(res, cors, 200, { ok: true });
2774
+ return;
2775
+ }
2776
+ const store = targetSessionId ? getSession(targetSessionId) : null;
2777
+ if (store && store.chatSession && store.chatSession.isRunning) {
2778
+ const agent = store.agent;
2779
+ broadcast({ type: 'chat-system-notice', text: 'Switching model to `' + slashCmd.args + '`…', sessionId: targetSessionId });
2780
+ const result = startNewChatSession(agent, { callerSessionId: targetSessionId, resumeSessionId: targetSessionId, model: slashCmd.args });
2781
+ jsonResponse(res, cors, 200, { ok: true, switched: true, agent: result.agent, sessionId: result.sessionId });
2782
+ } else {
2783
+ broadcast({ type: 'chat-system-notice', text: 'No active session. Start a session first.', sessionId: targetSessionId });
2784
+ jsonResponse(res, cors, 200, { ok: true });
2785
+ }
2786
+ return;
2787
+ }
2788
+ }
2789
+
2790
+ // ─── Regular message send ───────────────────────────────
2791
+ // Find the target session
2792
+ let store = targetSessionId ? getSession(targetSessionId) : null;
2793
+ if (!store) {
2794
+ // Fall back to most recently active session
2795
+ for (const s of sessions.values()) {
2796
+ if (s.chatSession && s.chatSession.isRunning) {
2797
+ if (!store || s.lastActivityAt > store.lastActivityAt) store = s;
2798
+ }
2799
+ }
2800
+ }
2801
+ if (!store || !store.chatSession || !store.chatSession.isRunning) {
2802
+ jsonResponse(res, cors, 400, { error: 'No active chat session' });
2803
+ return;
2804
+ }
2805
+
2806
+ try {
2807
+ store.chatSession.send(message, attachments);
2808
+ const agent = store.agent;
2809
+ // Transcript-form attachments mirror the wire format. We keep
2810
+ // dataBase64 so the chat history can re-render thumbnails on reload;
2811
+ // the alternative (writing files to .shards/sessions/<id>/attachments/)
2812
+ // is worth doing only if transcript sizes start to bite.
2813
+ const transcriptAttachments = attachments.map((a) => ({
2814
+ mediaType: a.mediaType,
2815
+ dataBase64: a.dataBase64,
2816
+ name: a.name,
2817
+ sizeBytes: a.sizeBytes,
2818
+ }));
2819
+ const transcriptEntry = { role: 'user', content: message, agent, source: 'chat' };
2820
+ if (transcriptAttachments.length > 0) transcriptEntry.attachments = transcriptAttachments;
2821
+ store.transcript.push(transcriptEntry);
2822
+ store.save();
2823
+ try {
2824
+ sessionIndex.updateActivity(SESSIONS_DIR, store.sessionId, {
2825
+ lastUserPrompt: message,
2826
+ messageCount: store.transcript.length,
2827
+ projectName: store.projectName,
2828
+ projectDir: store.projectDir,
2829
+ });
2830
+ } catch (err) {
2831
+ log(`session-index updateActivity failed: ${err.message}`);
2832
+ }
2833
+ const broadcastMsg = { type: 'chat-user-message', content: message, agent, sessionId: store.sessionId };
2834
+ if (transcriptAttachments.length > 0) broadcastMsg.attachments = transcriptAttachments;
2835
+ broadcast(broadcastMsg);
2836
+ jsonResponse(res, cors, 200, { ok: true });
2837
+ } catch (err) {
2838
+ jsonResponse(res, cors, 500, { error: err.message });
2839
+ }
2840
+ return;
2841
+ }
2842
+
2843
+ if (req.method === 'POST' && parsedUrl.pathname === '/chat/rename') {
2844
+ const body = await readBody(req);
2845
+ let params;
2846
+ try {
2847
+ params = JSON.parse(body);
2848
+ } catch {
2849
+ jsonResponse(res, cors, 400, { error: 'Invalid JSON' });
2850
+ return;
2851
+ }
2852
+
2853
+ const { sessionId: renameSessionId, title } = params;
2854
+ if (!renameSessionId) {
2855
+ jsonResponse(res, cors, 400, { error: 'Missing sessionId' });
2856
+ return;
2857
+ }
2858
+
2859
+ const renameStore = getSession(renameSessionId);
2860
+ if (renameStore) {
2861
+ renameStore.title = title || null;
2862
+ renameStore.save();
2863
+ }
2864
+
2865
+ jsonResponse(res, cors, 200, { ok: true });
2866
+ return;
2867
+ }
2868
+
2869
+ if (req.method === 'POST' && parsedUrl.pathname === '/chat/end') {
2870
+ const body = await readBody(req);
2871
+ let params = {};
2872
+ try { params = JSON.parse(body); } catch {}
2873
+ const { sessionId: endSessionId } = params;
2874
+ if (!endSessionId) {
2875
+ jsonResponse(res, cors, 400, { error: 'Missing sessionId' });
2876
+ return;
2877
+ }
2878
+
2879
+ const store = getSession(endSessionId);
2880
+ const snap = readGateSnapshot(SHARDS_DIR);
2881
+
2882
+ // Stop the CLI if still running. We intentionally do not mutate
2883
+ // .shards/gates/state.json — gates are project-scoped state and may
2884
+ // still be legitimately open for a future resume of this same project.
2885
+ if (store && store.chatSession && store.chatSession.isRunning) {
2886
+ try { store.chatSession.stop(); } catch (err) { log(`/chat/end stop failed: ${err.message}`); }
2887
+ }
2888
+
2889
+ let entry = null;
2890
+ try {
2891
+ entry = sessionIndex.markEnded(SESSIONS_DIR, endSessionId, {
2892
+ gateOpenAtEnd: snap.gateOpenAtEnd,
2893
+ reason: 'user_ended',
2894
+ });
2895
+ if (typeof snap.phase === 'number') {
2896
+ sessionIndex.updatePhase(SESSIONS_DIR, endSessionId, snap.phase);
2897
+ }
2898
+ } catch (err) {
2899
+ log(`session-index markEnded failed: ${err.message}`);
2900
+ }
2901
+
2902
+ broadcast({ type: 'chat-ended', sessionId: endSessionId, code: 0, ended: true });
2903
+ jsonResponse(res, cors, 200, { ok: true, entry });
2904
+ return;
2905
+ }
2906
+
2907
+ if (req.method === 'POST' && parsedUrl.pathname === '/chat/stop') {
2908
+ const body = await readBody(req);
2909
+ let params = {};
2910
+ try { params = JSON.parse(body); } catch {}
2911
+
2912
+ const targetSessionId = params.sessionId;
2913
+ const store = targetSessionId ? getSession(targetSessionId) : null;
2914
+
2915
+ if (store && store.chatSession && store.chatSession.isRunning) {
2916
+ store.chatSession.stop();
2917
+ jsonResponse(res, cors, 200, { ok: true });
2918
+ } else {
2919
+ // Stop the most recently active session
2920
+ let active = null;
2921
+ for (const s of sessions.values()) {
2922
+ if (s.chatSession && s.chatSession.isRunning) {
2923
+ if (!active || s.lastActivityAt > active.lastActivityAt) active = s;
2924
+ }
2925
+ }
2926
+ if (active) {
2927
+ active.chatSession.stop();
2928
+ jsonResponse(res, cors, 200, { ok: true });
2929
+ } else {
2930
+ jsonResponse(res, cors, 200, { ok: true, note: 'No active session' });
2931
+ }
2932
+ }
2933
+ return;
2934
+ }
2935
+
2936
+ res.writeHead(404);
2937
+ res.end('Not found');
2938
+ };
2939
+ }
2940
+
2941
+ function startServer(portIndex, resolve, reject) {
2942
+ if (portIndex >= PORTS.length) {
2943
+ const msg = 'Shards UI: all ports in use (7842-7845)';
2944
+ if (reject) { reject(new Error(msg)); return; }
2945
+ console.error(msg);
2946
+ process.exit(1);
2947
+ }
2948
+
2949
+ const port = PORTS[portIndex];
2950
+ const handler = createHandler();
2951
+
2952
+ // P5: Create HTTPS server if TLS cert/key are provided
2953
+ let server;
2954
+ let protocol = 'http';
2955
+ if (TLS_CERT && TLS_KEY) {
2956
+ try {
2957
+ const tlsOptions = {
2958
+ cert: fs.readFileSync(TLS_CERT),
2959
+ key: fs.readFileSync(TLS_KEY),
2960
+ };
2961
+ server = https.createServer(tlsOptions, handler);
2962
+ protocol = 'https';
2963
+ } catch (err) {
2964
+ const msg = `Shards UI: Failed to load TLS cert/key: ${err.message}`;
2965
+ if (reject) { reject(new Error(msg)); return; }
2966
+ console.error(msg);
2967
+ process.exit(1);
2968
+ }
2969
+ } else {
2970
+ server = http.createServer(handler);
2971
+ }
2972
+
2973
+ // P5: Warn if binding to 0.0.0.0 without TLS
2974
+ if (BIND_ADDR === '0.0.0.0' && protocol === 'http') {
2975
+ console.warn('WARNING: Binding to 0.0.0.0 without TLS. All traffic is unencrypted. Use SHARDS_TLS_CERT and SHARDS_TLS_KEY for secure transport, or use a reverse proxy.');
2976
+ }
2977
+
2978
+ server.on('error', (e) => {
2979
+ if (e.code === 'EADDRINUSE') {
2980
+ startServer(portIndex + 1, resolve, reject);
2981
+ } else {
2982
+ const msg = 'Shards UI server error: ' + e.message;
2983
+ if (reject) { reject(new Error(msg)); return; }
2984
+ console.error(msg);
2985
+ process.exit(1);
2986
+ }
2987
+ });
2988
+
2989
+ server.listen(port, BIND_ADDR, () => {
2990
+ fs.mkdirSync(SHARDS_DIR, { recursive: true });
2991
+ fs.mkdirSync(SESSIONS_DIR, { recursive: true });
2992
+
2993
+ // P1: Write port file as JSON with auth token
2994
+ try {
2995
+ fs.writeFileSync(PORT_FILE, JSON.stringify({ port, token: AUTH_TOKEN }));
2996
+ fs.writeFileSync(PID_FILE, String(process.pid));
2997
+ } catch {}
2998
+
2999
+ console.log(`Shards UI server running on ${protocol}://${BIND_ADDR === '0.0.0.0' ? 'localhost' : BIND_ADDR}:${port}`);
3000
+
3001
+ // P4: Reconnect to orphaned sessions or clean up
3002
+ reconnectOrCleanup(SESSIONS_DIR, sessions, SessionStore, handleChatEvent, handleChatExit, PROJECT_DIR);
3003
+
3004
+ // Sweep stale active sessions in INDEX.json. Anything reconnected above
3005
+ // is back in the in-memory map; anything that wasn't and is past the
3006
+ // abandonment window gets reclassified so the picker can show it as
3007
+ // such instead of a phantom "active" row.
3008
+ try {
3009
+ const swept = sessionIndex.sweepAbandoned(SESSIONS_DIR);
3010
+ if (swept > 0) log(`session-index: reclassified ${swept} stale active sessions as abandoned`);
3011
+ } catch (err) {
3012
+ log(`session-index sweep failed: ${err.message}`);
3013
+ }
3014
+
3015
+ // Initial file scan then poll
3016
+ pollFiles();
3017
+
3018
+ // Start gate / auto / violations watchers (replaces browser-side 2s poll)
3019
+ startGateWatchers();
3020
+
3021
+ // Build symbol index asynchronously (reference cache is built lazily on hover)
3022
+ setTimeout(() => {
3023
+ try {
3024
+ symbolIndex.buildIndex(PROJECT_DIR, log);
3025
+ symbolIndex.startWatcher(PROJECT_DIR, log);
3026
+ log(`Symbol index ready: ${symbolIndex.getStatus().symbolCount} symbols from ${symbolIndex.getStatus().fileCount} files`);
3027
+ } catch (err) {
3028
+ log(`Symbol index build failed: ${err.message}`);
3029
+ }
3030
+ }, 100);
3031
+
3032
+ // Resolve the promise for createServer callers
3033
+ if (resolve) {
3034
+ resolve({
3035
+ port,
3036
+ authToken: AUTH_TOKEN,
3037
+ httpServer: server,
3038
+ shutdown: () => {
3039
+ server.close();
3040
+ cleanup();
3041
+ }
3042
+ });
3043
+ }
3044
+ });
3045
+ }
3046
+
3047
+ // Clean up on exit — don't kill detached chat processes, they survive restarts
3048
+ function cleanup() {
3049
+ symbolIndex.stopWatcher();
3050
+ stopGateWatchers();
3051
+ try { fs.unlinkSync(PORT_FILE); } catch {}
3052
+ try { fs.unlinkSync(PID_FILE); } catch {}
3053
+ }
3054
+ process.on('exit', cleanup);
3055
+ process.on('SIGTERM', () => { cleanup(); process.exit(0); });
3056
+ process.on('SIGINT', () => { cleanup(); process.exit(0); });
3057
+
3058
+ // ─── Factory for Electron / programmatic use ────────────────────────────────
3059
+
3060
+ function createServer(projectDir, options = {}) {
3061
+ initPaths(projectDir, options);
3062
+ return new Promise((resolve, reject) => {
3063
+ startServer(0, resolve, reject);
3064
+ });
3065
+ }
3066
+
3067
+ // Standalone mode — preserves existing CLI behavior
3068
+ if (require.main === module) {
3069
+ startServer(0);
3070
+ }
3071
+
3072
+ module.exports = { createServer };