@seanmars/tospec 0.23.0 → 0.26.0-beta

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 (358) hide show
  1. package/CHANGELOG.md +281 -0
  2. package/README.md +27 -9
  3. package/assets/claude-mod/tospec-dashboard/.claude-plugin/plugin.json +7 -0
  4. package/assets/claude-mod/tospec-dashboard/hooks/hooks.json +1 -0
  5. package/assets/claude-mod/tospec-dashboard/hooks/register.ts +695 -0
  6. package/assets/claude-mod/tospec-dashboard/tests/register.test.ts +448 -0
  7. package/assets/claude-mod/tospec-dashboard/types/index.d.ts +8 -0
  8. package/assets/dashboard/app.js +218 -1180
  9. package/assets/dashboard/index.html +31 -22
  10. package/assets/dashboard/lib/charts.js +355 -0
  11. package/assets/dashboard/lib/derive.js +375 -0
  12. package/assets/dashboard/lib/dom.js +121 -0
  13. package/assets/dashboard/lib/routes.js +52 -0
  14. package/assets/dashboard/lib/store.js +326 -0
  15. package/assets/dashboard/style.css +3103 -664
  16. package/assets/dashboard/views/alerts.js +170 -0
  17. package/assets/dashboard/views/change.js +234 -0
  18. package/assets/dashboard/views/docs.js +214 -0
  19. package/assets/dashboard/views/home.js +357 -0
  20. package/assets/dashboard/views/project.js +168 -0
  21. package/assets/dashboard/views/session.js +270 -0
  22. package/assets/dashboard/views/sidebar.js +131 -0
  23. package/dist/cli/index.d.ts.map +1 -1
  24. package/dist/cli/index.js +116 -102
  25. package/dist/cli/index.js.map +1 -1
  26. package/dist/commands/config.d.ts.map +1 -1
  27. package/dist/commands/config.js +213 -100
  28. package/dist/commands/config.js.map +1 -1
  29. package/dist/commands/dashboard-relay.d.ts +11 -0
  30. package/dist/commands/dashboard-relay.d.ts.map +1 -0
  31. package/dist/commands/dashboard-relay.js +88 -0
  32. package/dist/commands/dashboard-relay.js.map +1 -0
  33. package/dist/commands/dashboard.d.ts +69 -100
  34. package/dist/commands/dashboard.d.ts.map +1 -1
  35. package/dist/commands/dashboard.js +420 -598
  36. package/dist/commands/dashboard.js.map +1 -1
  37. package/dist/commands/decision.d.ts +38 -1
  38. package/dist/commands/decision.d.ts.map +1 -1
  39. package/dist/commands/decision.js +303 -27
  40. package/dist/commands/decision.js.map +1 -1
  41. package/dist/commands/metrics.d.ts +3 -4
  42. package/dist/commands/metrics.d.ts.map +1 -1
  43. package/dist/commands/metrics.js +3 -5
  44. package/dist/commands/metrics.js.map +1 -1
  45. package/dist/commands/shared-output.d.ts +24 -0
  46. package/dist/commands/shared-output.d.ts.map +1 -1
  47. package/dist/commands/shared-output.js +21 -2
  48. package/dist/commands/shared-output.js.map +1 -1
  49. package/dist/commands/show.d.ts.map +1 -1
  50. package/dist/commands/show.js +12 -9
  51. package/dist/commands/show.js.map +1 -1
  52. package/dist/commands/validate.d.ts +19 -0
  53. package/dist/commands/validate.d.ts.map +1 -1
  54. package/dist/commands/validate.js +243 -64
  55. package/dist/commands/validate.js.map +1 -1
  56. package/dist/commands/workflow/instructions.d.ts +7 -1
  57. package/dist/commands/workflow/instructions.d.ts.map +1 -1
  58. package/dist/commands/workflow/instructions.js +299 -81
  59. package/dist/commands/workflow/instructions.js.map +1 -1
  60. package/dist/commands/workflow/new-change.d.ts.map +1 -1
  61. package/dist/commands/workflow/new-change.js +26 -17
  62. package/dist/commands/workflow/new-change.js.map +1 -1
  63. package/dist/commands/workflow/prompt-escape.d.ts +25 -0
  64. package/dist/commands/workflow/prompt-escape.d.ts.map +1 -0
  65. package/dist/commands/workflow/prompt-escape.js +62 -0
  66. package/dist/commands/workflow/prompt-escape.js.map +1 -0
  67. package/dist/commands/workflow/schemas.d.ts.map +1 -1
  68. package/dist/commands/workflow/schemas.js +34 -16
  69. package/dist/commands/workflow/schemas.js.map +1 -1
  70. package/dist/commands/workflow/shared.d.ts +46 -10
  71. package/dist/commands/workflow/shared.d.ts.map +1 -1
  72. package/dist/commands/workflow/shared.js +66 -30
  73. package/dist/commands/workflow/shared.js.map +1 -1
  74. package/dist/commands/workflow/status.d.ts +5 -2
  75. package/dist/commands/workflow/status.d.ts.map +1 -1
  76. package/dist/commands/workflow/status.js +62 -28
  77. package/dist/commands/workflow/status.js.map +1 -1
  78. package/dist/core/archive.d.ts +38 -0
  79. package/dist/core/archive.d.ts.map +1 -1
  80. package/dist/core/archive.js +492 -75
  81. package/dist/core/archive.js.map +1 -1
  82. package/dist/core/artifact-graph/index.d.ts +2 -2
  83. package/dist/core/artifact-graph/index.d.ts.map +1 -1
  84. package/dist/core/artifact-graph/index.js +2 -2
  85. package/dist/core/artifact-graph/index.js.map +1 -1
  86. package/dist/core/artifact-graph/instruction-loader.d.ts +8 -0
  87. package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
  88. package/dist/core/artifact-graph/instruction-loader.js +58 -36
  89. package/dist/core/artifact-graph/instruction-loader.js.map +1 -1
  90. package/dist/core/artifact-graph/resolver.d.ts +39 -12
  91. package/dist/core/artifact-graph/resolver.d.ts.map +1 -1
  92. package/dist/core/artifact-graph/resolver.js +82 -36
  93. package/dist/core/artifact-graph/resolver.js.map +1 -1
  94. package/dist/core/artifact-graph/schema.d.ts +24 -0
  95. package/dist/core/artifact-graph/schema.d.ts.map +1 -1
  96. package/dist/core/artifact-graph/schema.js +32 -1
  97. package/dist/core/artifact-graph/schema.js.map +1 -1
  98. package/dist/core/artifact-graph/stub-detection.d.ts +10 -2
  99. package/dist/core/artifact-graph/stub-detection.d.ts.map +1 -1
  100. package/dist/core/artifact-graph/stub-detection.js +18 -11
  101. package/dist/core/artifact-graph/stub-detection.js.map +1 -1
  102. package/dist/core/artifact-graph/types.d.ts +2 -2
  103. package/dist/core/artifact-graph/types.d.ts.map +1 -1
  104. package/dist/core/artifact-graph/types.js +10 -1
  105. package/dist/core/artifact-graph/types.js.map +1 -1
  106. package/dist/core/change-metadata/schema.d.ts.map +1 -1
  107. package/dist/core/change-metadata/schema.js +18 -4
  108. package/dist/core/change-metadata/schema.js.map +1 -1
  109. package/dist/core/change-presenter.d.ts.map +1 -1
  110. package/dist/core/change-presenter.js +13 -8
  111. package/dist/core/change-presenter.js.map +1 -1
  112. package/dist/core/change-status-policy.d.ts.map +1 -1
  113. package/dist/core/change-status-policy.js +3 -1
  114. package/dist/core/change-status-policy.js.map +1 -1
  115. package/dist/core/codex-metrics.d.ts +1 -1
  116. package/dist/core/codex-metrics.js +1 -1
  117. package/dist/core/config-schema.d.ts +8 -0
  118. package/dist/core/config-schema.d.ts.map +1 -1
  119. package/dist/core/config-schema.js +10 -0
  120. package/dist/core/config-schema.js.map +1 -1
  121. package/dist/core/dashboard-activity.d.ts +4 -4
  122. package/dist/core/dashboard-activity.js +4 -4
  123. package/dist/core/dashboard-data.d.ts +11 -5
  124. package/dist/core/dashboard-data.d.ts.map +1 -1
  125. package/dist/core/dashboard-data.js +54 -28
  126. package/dist/core/dashboard-data.js.map +1 -1
  127. package/dist/core/dashboard-hub.d.ts +258 -0
  128. package/dist/core/dashboard-hub.d.ts.map +1 -0
  129. package/dist/core/dashboard-hub.js +655 -0
  130. package/dist/core/dashboard-hub.js.map +1 -0
  131. package/dist/core/dashboard-routes.d.ts +57 -0
  132. package/dist/core/dashboard-routes.d.ts.map +1 -0
  133. package/dist/core/dashboard-routes.js +398 -0
  134. package/dist/core/dashboard-routes.js.map +1 -0
  135. package/dist/core/global-config.d.ts +71 -2
  136. package/dist/core/global-config.d.ts.map +1 -1
  137. package/dist/core/global-config.js +149 -4
  138. package/dist/core/global-config.js.map +1 -1
  139. package/dist/core/id.d.ts +9 -0
  140. package/dist/core/id.d.ts.map +1 -1
  141. package/dist/core/id.js +37 -0
  142. package/dist/core/id.js.map +1 -1
  143. package/dist/core/init.d.ts +9 -2
  144. package/dist/core/init.d.ts.map +1 -1
  145. package/dist/core/init.js +119 -33
  146. package/dist/core/init.js.map +1 -1
  147. package/dist/core/list.d.ts.map +1 -1
  148. package/dist/core/list.js +69 -45
  149. package/dist/core/list.js.map +1 -1
  150. package/dist/core/local-server.d.ts +10 -3
  151. package/dist/core/local-server.d.ts.map +1 -1
  152. package/dist/core/local-server.js +25 -5
  153. package/dist/core/local-server.js.map +1 -1
  154. package/dist/core/markdown-render.d.ts.map +1 -1
  155. package/dist/core/markdown-render.js +7 -2
  156. package/dist/core/markdown-render.js.map +1 -1
  157. package/dist/core/migrate.d.ts +24 -2
  158. package/dist/core/migrate.d.ts.map +1 -1
  159. package/dist/core/migrate.js +335 -59
  160. package/dist/core/migrate.js.map +1 -1
  161. package/dist/core/parsers/change-parser.d.ts +20 -1
  162. package/dist/core/parsers/change-parser.d.ts.map +1 -1
  163. package/dist/core/parsers/change-parser.js +76 -73
  164. package/dist/core/parsers/change-parser.js.map +1 -1
  165. package/dist/core/parsers/markdown-parser.d.ts +24 -0
  166. package/dist/core/parsers/markdown-parser.d.ts.map +1 -1
  167. package/dist/core/parsers/markdown-parser.js +65 -10
  168. package/dist/core/parsers/markdown-parser.js.map +1 -1
  169. package/dist/core/parsers/requirement-blocks.d.ts +127 -6
  170. package/dist/core/parsers/requirement-blocks.d.ts.map +1 -1
  171. package/dist/core/parsers/requirement-blocks.js +349 -56
  172. package/dist/core/parsers/requirement-blocks.js.map +1 -1
  173. package/dist/core/parsers/requirement-text.d.ts +25 -1
  174. package/dist/core/parsers/requirement-text.d.ts.map +1 -1
  175. package/dist/core/parsers/requirement-text.js +62 -6
  176. package/dist/core/parsers/requirement-text.js.map +1 -1
  177. package/dist/core/parsers/spec-structure.d.ts.map +1 -1
  178. package/dist/core/parsers/spec-structure.js +36 -24
  179. package/dist/core/parsers/spec-structure.js.map +1 -1
  180. package/dist/core/project-config.d.ts +9 -2
  181. package/dist/core/project-config.d.ts.map +1 -1
  182. package/dist/core/project-config.js +30 -7
  183. package/dist/core/project-config.js.map +1 -1
  184. package/dist/core/project-layout.d.ts +26 -0
  185. package/dist/core/project-layout.d.ts.map +1 -1
  186. package/dist/core/project-layout.js +51 -1
  187. package/dist/core/project-layout.js.map +1 -1
  188. package/dist/core/schema-names.d.ts +17 -0
  189. package/dist/core/schema-names.d.ts.map +1 -1
  190. package/dist/core/schema-names.js +24 -0
  191. package/dist/core/schema-names.js.map +1 -1
  192. package/dist/core/shared/dashboard-mod.d.ts +24 -0
  193. package/dist/core/shared/dashboard-mod.d.ts.map +1 -0
  194. package/dist/core/shared/dashboard-mod.js +44 -0
  195. package/dist/core/shared/dashboard-mod.js.map +1 -0
  196. package/dist/core/shared/index.d.ts +2 -1
  197. package/dist/core/shared/index.d.ts.map +1 -1
  198. package/dist/core/shared/index.js +2 -1
  199. package/dist/core/shared/index.js.map +1 -1
  200. package/dist/core/shared/rules-generation.d.ts +12 -0
  201. package/dist/core/shared/rules-generation.d.ts.map +1 -1
  202. package/dist/core/shared/rules-generation.js +26 -2
  203. package/dist/core/shared/rules-generation.js.map +1 -1
  204. package/dist/core/shared/skill-generation.d.ts +7 -0
  205. package/dist/core/shared/skill-generation.d.ts.map +1 -1
  206. package/dist/core/shared/skill-generation.js +19 -0
  207. package/dist/core/shared/skill-generation.js.map +1 -1
  208. package/dist/core/shared/tool-detection.d.ts +10 -1
  209. package/dist/core/shared/tool-detection.d.ts.map +1 -1
  210. package/dist/core/shared/tool-detection.js +37 -6
  211. package/dist/core/shared/tool-detection.js.map +1 -1
  212. package/dist/core/spec-presenter.d.ts.map +1 -1
  213. package/dist/core/spec-presenter.js +23 -1
  214. package/dist/core/spec-presenter.js.map +1 -1
  215. package/dist/core/specs-apply.d.ts +98 -1
  216. package/dist/core/specs-apply.d.ts.map +1 -1
  217. package/dist/core/specs-apply.js +426 -63
  218. package/dist/core/specs-apply.js.map +1 -1
  219. package/dist/core/templates/fragments/change-diff.d.ts +37 -0
  220. package/dist/core/templates/fragments/change-diff.d.ts.map +1 -0
  221. package/dist/core/templates/fragments/change-diff.js +37 -0
  222. package/dist/core/templates/fragments/change-diff.js.map +1 -0
  223. package/dist/core/templates/fragments/sync-fail.d.ts +8 -0
  224. package/dist/core/templates/fragments/sync-fail.d.ts.map +1 -0
  225. package/dist/core/templates/fragments/sync-fail.js +8 -0
  226. package/dist/core/templates/fragments/sync-fail.js.map +1 -0
  227. package/dist/core/templates/fragments/validate.d.ts +14 -1
  228. package/dist/core/templates/fragments/validate.d.ts.map +1 -1
  229. package/dist/core/templates/fragments/validate.js +14 -1
  230. package/dist/core/templates/fragments/validate.js.map +1 -1
  231. package/dist/core/templates/fragments/verify.d.ts +2 -2
  232. package/dist/core/templates/fragments/verify.d.ts.map +1 -1
  233. package/dist/core/templates/fragments/verify.js +8 -5
  234. package/dist/core/templates/fragments/verify.js.map +1 -1
  235. package/dist/core/templates/workflows/apply.d.ts +1 -1
  236. package/dist/core/templates/workflows/apply.d.ts.map +1 -1
  237. package/dist/core/templates/workflows/apply.js +29 -10
  238. package/dist/core/templates/workflows/apply.js.map +1 -1
  239. package/dist/core/templates/workflows/archive.d.ts.map +1 -1
  240. package/dist/core/templates/workflows/archive.js +27 -9
  241. package/dist/core/templates/workflows/archive.js.map +1 -1
  242. package/dist/core/templates/workflows/decision.d.ts.map +1 -1
  243. package/dist/core/templates/workflows/decision.js +10 -4
  244. package/dist/core/templates/workflows/decision.js.map +1 -1
  245. package/dist/core/templates/workflows/explore.d.ts +2 -2
  246. package/dist/core/templates/workflows/explore.d.ts.map +1 -1
  247. package/dist/core/templates/workflows/explore.js +7 -3
  248. package/dist/core/templates/workflows/explore.js.map +1 -1
  249. package/dist/core/templates/workflows/grill.d.ts +2 -2
  250. package/dist/core/templates/workflows/grill.d.ts.map +1 -1
  251. package/dist/core/templates/workflows/grill.js +5 -4
  252. package/dist/core/templates/workflows/grill.js.map +1 -1
  253. package/dist/core/templates/workflows/issue.d.ts.map +1 -1
  254. package/dist/core/templates/workflows/issue.js +9 -3
  255. package/dist/core/templates/workflows/issue.js.map +1 -1
  256. package/dist/core/templates/workflows/propose.d.ts.map +1 -1
  257. package/dist/core/templates/workflows/propose.js +4 -1
  258. package/dist/core/templates/workflows/propose.js.map +1 -1
  259. package/dist/core/templates/workflows/sync.d.ts.map +1 -1
  260. package/dist/core/templates/workflows/sync.js +25 -11
  261. package/dist/core/templates/workflows/sync.js.map +1 -1
  262. package/dist/core/templates/workflows/update.d.ts.map +1 -1
  263. package/dist/core/templates/workflows/update.js +17 -10
  264. package/dist/core/templates/workflows/update.js.map +1 -1
  265. package/dist/core/update.d.ts +5 -0
  266. package/dist/core/update.d.ts.map +1 -1
  267. package/dist/core/update.js +69 -29
  268. package/dist/core/update.js.map +1 -1
  269. package/dist/core/validation/constants.d.ts +7 -1
  270. package/dist/core/validation/constants.d.ts.map +1 -1
  271. package/dist/core/validation/constants.js +16 -2
  272. package/dist/core/validation/constants.js.map +1 -1
  273. package/dist/core/validation/purpose-placeholder.d.ts.map +1 -1
  274. package/dist/core/validation/purpose-placeholder.js +35 -11
  275. package/dist/core/validation/purpose-placeholder.js.map +1 -1
  276. package/dist/core/validation/section-validator.d.ts +7 -0
  277. package/dist/core/validation/section-validator.d.ts.map +1 -1
  278. package/dist/core/validation/section-validator.js +22 -2
  279. package/dist/core/validation/section-validator.js.map +1 -1
  280. package/dist/core/validation/sister-changes.d.ts +93 -0
  281. package/dist/core/validation/sister-changes.d.ts.map +1 -0
  282. package/dist/core/validation/sister-changes.js +180 -0
  283. package/dist/core/validation/sister-changes.js.map +1 -0
  284. package/dist/core/validation/task-markers.d.ts +9 -0
  285. package/dist/core/validation/task-markers.d.ts.map +1 -0
  286. package/dist/core/validation/task-markers.js +71 -0
  287. package/dist/core/validation/task-markers.js.map +1 -0
  288. package/dist/core/validation/types.d.ts +58 -0
  289. package/dist/core/validation/types.d.ts.map +1 -1
  290. package/dist/core/validation/types.js +22 -0
  291. package/dist/core/validation/types.js.map +1 -1
  292. package/dist/core/validation/validator.d.ts +79 -2
  293. package/dist/core/validation/validator.d.ts.map +1 -1
  294. package/dist/core/validation/validator.js +565 -84
  295. package/dist/core/validation/validator.js.map +1 -1
  296. package/dist/utils/change-metadata.d.ts +62 -5
  297. package/dist/utils/change-metadata.d.ts.map +1 -1
  298. package/dist/utils/change-metadata.js +193 -31
  299. package/dist/utils/change-metadata.js.map +1 -1
  300. package/dist/utils/change-utils.d.ts +21 -0
  301. package/dist/utils/change-utils.d.ts.map +1 -1
  302. package/dist/utils/change-utils.js +168 -53
  303. package/dist/utils/change-utils.js.map +1 -1
  304. package/dist/utils/command-line.d.ts +11 -0
  305. package/dist/utils/command-line.d.ts.map +1 -0
  306. package/dist/utils/command-line.js +43 -0
  307. package/dist/utils/command-line.js.map +1 -0
  308. package/dist/utils/display-width.d.ts +10 -0
  309. package/dist/utils/display-width.d.ts.map +1 -0
  310. package/dist/utils/display-width.js +15 -0
  311. package/dist/utils/display-width.js.map +1 -0
  312. package/dist/utils/file-system.d.ts +16 -0
  313. package/dist/utils/file-system.d.ts.map +1 -1
  314. package/dist/utils/file-system.js +49 -2
  315. package/dist/utils/file-system.js.map +1 -1
  316. package/dist/utils/frontmatter.d.ts.map +1 -1
  317. package/dist/utils/frontmatter.js +11 -5
  318. package/dist/utils/frontmatter.js.map +1 -1
  319. package/dist/utils/interactive.d.ts.map +1 -1
  320. package/dist/utils/interactive.js +3 -1
  321. package/dist/utils/interactive.js.map +1 -1
  322. package/dist/utils/item-discovery.d.ts +3 -2
  323. package/dist/utils/item-discovery.d.ts.map +1 -1
  324. package/dist/utils/item-discovery.js +34 -16
  325. package/dist/utils/item-discovery.js.map +1 -1
  326. package/dist/utils/link.d.ts +0 -1
  327. package/dist/utils/link.d.ts.map +1 -1
  328. package/dist/utils/link.js +0 -1
  329. package/dist/utils/link.js.map +1 -1
  330. package/dist/utils/match.d.ts.map +1 -1
  331. package/dist/utils/match.js +10 -1
  332. package/dist/utils/match.js.map +1 -1
  333. package/dist/utils/option-flags.d.ts +9 -0
  334. package/dist/utils/option-flags.d.ts.map +1 -0
  335. package/dist/utils/option-flags.js +12 -0
  336. package/dist/utils/option-flags.js.map +1 -0
  337. package/dist/utils/sentence.d.ts +9 -0
  338. package/dist/utils/sentence.d.ts.map +1 -0
  339. package/dist/utils/sentence.js +12 -0
  340. package/dist/utils/sentence.js.map +1 -0
  341. package/dist/utils/spec-files.d.ts +84 -1
  342. package/dist/utils/spec-files.d.ts.map +1 -1
  343. package/dist/utils/spec-files.js +192 -3
  344. package/dist/utils/spec-files.js.map +1 -1
  345. package/dist/utils/task-progress.d.ts.map +1 -1
  346. package/dist/utils/task-progress.js +32 -9
  347. package/dist/utils/task-progress.js.map +1 -1
  348. package/dist/utils/timestamp.d.ts +27 -0
  349. package/dist/utils/timestamp.d.ts.map +1 -1
  350. package/dist/utils/timestamp.js +37 -0
  351. package/dist/utils/timestamp.js.map +1 -1
  352. package/package.json +6 -2
  353. package/schemas/decision/templates/decision.md +2 -2
  354. package/schemas/issue/schema.yaml +12 -6
  355. package/schemas/issue/templates/task.md +1 -1
  356. package/schemas/sdd/schema.yaml +21 -10
  357. package/schemas/sdd/templates/proposal.md +1 -1
  358. package/assets/dashboard/chart.umd.js +0 -14
@@ -1,172 +1,48 @@
1
1
  /**
2
- * `tospec dashboard` — a `node:http` server over the aggregators in
3
- * `core/dashboard-data.ts`. Reads are GET-only; the sole write is
4
- * `POST /api/task`. `/api/render` and `/api/task` are trust boundaries: the
5
- * path must resolve under the tospec root (D5).
2
+ * `tospec dashboard start|stop|status` — one local hub for every tospec
3
+ * project. It reads no project files: Claude Code sessions push snapshots and
4
+ * activity through the `tospec-dashboard` mod (see `core/dashboard-hub.ts`),
5
+ * and the browser reads those. A task checkbox is relayed to a live session,
6
+ * which applies it with the checks in `core/dashboard-routes.ts`. Beyond that
7
+ * the page can only answer a question a session asked (an AskUserQuestion, a
8
+ * permission), and only from a hub bound to loopback; it never types a prompt.
6
9
  */
7
10
  import * as http from 'node:http';
8
- import { promises as fs, readdirSync, readFileSync, rmSync, unlinkSync, watch, } from 'node:fs';
11
+ import { promises as fs, readdirSync, rmSync } from 'node:fs';
9
12
  import * as path from 'node:path';
10
- import * as os from 'node:os';
11
- import * as crypto from 'node:crypto';
12
13
  import { spawn } from 'node:child_process';
13
- import { MarkdownParser } from '../core/parsers/markdown-parser.js';
14
- import { renderMarkdownSafe } from '../core/markdown-render.js';
15
- import { collectOverview, collectChangeDetail, collectArchive, collectDecisions, readSyncConclusion, } from '../core/dashboard-data.js';
16
- import { collectActivity } from '../core/dashboard-activity.js';
14
+ import { DashboardHub, HubRequestError, } from '../core/dashboard-hub.js';
15
+ import { DASHBOARD_PROTOCOL } from '../core/dashboard-routes.js';
17
16
  import { getGlobalConfigDir } from '../core/global-config.js';
18
- import { acceptsAnyHostHeader, assertBindableHost, connectableUrl, isAllowedHostHeader, listenFrom, openBrowser, packageAssetsDir, sendJson, serveAsset, } from '../core/local-server.js';
19
- import { resolveTaskFiles, setTaskDone } from '../utils/task-progress.js';
20
- const DEFAULT_PORT = 5620;
17
+ import { LOOPBACK_HOSTS, acceptsAnyHostHeader, assertBindableHost, connectableUrl, isAllowedHostHeader, isJsonContentType, listenOnce, normalizeHost, openBrowser, packageAssetsDir, sendJson, serveAsset, } from '../core/local-server.js';
18
+ import { maxDisplayWidth, padEndDisplay } from '../utils/display-width.js';
19
+ export const DEFAULT_PORT = 5620;
21
20
  const DEFAULT_HOST = '127.0.0.1';
22
21
  // Readiness signal the detached child writes to stderr once bound; emitted only
23
- // when TOSPEC_DASHBOARD_PIDFILE marks it as a `--detach` child.
22
+ // when TOSPEC_DASHBOARD_DETACHED marks it as the child `dashboard start` spawned.
24
23
  const LISTENING_PREFIX = 'LISTENING ';
25
- /** Drops one leading `Error: ` the child added, so wrapping doesn't stack prefixes. */
26
- function stripErrorPrefix(text) {
27
- return text.replace(/^Error:\s*/, '');
28
- }
24
+ const DETACHED_ENV = 'TOSPEC_DASHBOARD_DETACHED';
29
25
  // The child signals right after binding, so slower than a cold start plus a
30
26
  // bind means it is stuck, not busy.
31
27
  const READINESS_TIMEOUT_MS = 10_000;
28
+ // `status` asks a hub on this machine; one that takes longer is not answering.
29
+ const STATUS_TIMEOUT_MS = 3_000;
30
+ // How often online/offline transitions are checked and broadcast.
31
+ const SWEEP_INTERVAL_MS = 5_000;
32
+ // A browser POST carries one task tick or one answer (up to four questions of
33
+ // CJK text); a mod push carries a whole project.
34
+ const BROWSER_BODY_LIMIT = 64 * 1024;
35
+ const SNAPSHOT_BODY_LIMIT = 64 * 1024 * 1024;
36
+ const REPLY_BODY_LIMIT = 16 * 1024 * 1024;
37
+ const ACTIVITY_BODY_LIMIT = 1024 * 1024;
32
38
  /** This command's shipped frontend; `serveAsset` is confined to it. */
33
39
  const ASSETS_DIR = packageAssetsDir('dashboard');
34
- /**
35
- * Decodes one URL path segment and confines it to a single directory name, or
36
- * answers the request and returns null.
37
- *
38
- * WHATWG URL normalizes a literal `..` out of the pathname but does not
39
- * percent-decode, so `%2e%2e%2f` survives until `decodeURIComponent` turns it
40
- * back into `../../` — a traversal. Malformed escapes (`%zz`) are the client's
41
- * mistake, so 400 rather than a 500 from the router.
42
- */
43
- function decodePathSegment(raw, label, res) {
44
- let decoded;
45
- try {
46
- decoded = decodeURIComponent(raw);
47
- }
48
- catch {
49
- sendJson(res, 400, { error: `malformed ${label}` });
50
- return null;
51
- }
52
- if (!decoded || decoded.includes('/') || decoded.includes('\\') || decoded.includes('..')) {
53
- sendJson(res, 403, { error: 'forbidden' });
54
- return null;
55
- }
56
- return decoded;
57
- }
58
- async function handleChangeDetail(projectRoot, rawName, res) {
59
- const name = decodePathSegment(rawName, 'change name', res);
60
- if (name === null)
61
- return;
62
- try {
63
- sendJson(res, 200, await collectChangeDetail(projectRoot, name));
64
- }
65
- catch (err) {
66
- const message = err instanceof Error ? err.message : String(err);
67
- sendJson(res, /not found/i.test(message) ? 404 : 500, { error: message });
68
- }
69
- }
70
- async function handleSpec(projectRoot, rawId, res) {
71
- const id = decodePathSegment(rawId, 'spec id', res);
72
- if (id === null)
73
- return;
74
- const specPath = path.join(projectRoot, 'tospec', 'specs', id, 'spec.md');
75
- let content;
76
- try {
77
- content = await fs.readFile(specPath, 'utf-8');
78
- }
79
- catch {
80
- return sendJson(res, 404, { error: 'not found' });
81
- }
82
- const spec = new MarkdownParser(content).parseSpec(id);
83
- sendJson(res, 200, { id, requirements: spec.requirements });
84
- }
85
- /**
86
- * Git activity + health signals (ADR 001). `change` feeds a `git log` path
87
- * filter, so reject anything that could escape `tospec/changes/` before it
88
- * reaches git. `days` is capped at 3650 to bound the zero-filled series.
89
- */
90
- async function handleActivity(projectRoot, changeParam, daysParam, res) {
91
- const change = changeParam ?? undefined;
92
- if (change && (change.includes('/') || change.includes('\\') || change.includes('..'))) {
93
- return sendJson(res, 403, { error: 'forbidden' });
94
- }
95
- // Absent or garbage stays undefined → collectActivity's default.
96
- let window;
97
- if (daysParam === 'all') {
98
- window = 'all';
99
- }
100
- else {
101
- const parsed = Number(daysParam);
102
- if (Number.isInteger(parsed) && parsed >= 1)
103
- window = Math.min(parsed, 3650);
104
- }
105
- sendJson(res, 200, await collectActivity(projectRoot, change, window));
106
- }
107
- /**
108
- * Trust boundary (D5): resolves a client-supplied path to an absolute `.md` file
109
- * under `<root>/<prefix>`, or null when it escapes.
110
- */
111
- function resolveTospecFile(projectRoot, file, prefix) {
112
- const resolved = path.resolve(projectRoot, file.replace(/\\/g, '/'));
113
- if (!resolved.startsWith(path.resolve(projectRoot) + path.sep))
114
- return null;
115
- const posixRel = path.relative(projectRoot, resolved).replace(/\\/g, '/');
116
- if (!posixRel.startsWith(prefix) || !posixRel.endsWith('.md'))
117
- return null;
118
- return resolved;
119
- }
120
- const ARCHIVE_SEGMENT = 'archive';
121
- /**
122
- * True for a path inside `tospec/changes/archive/`. Not folded into
123
- * `resolveTospecFile`: `/api/render` shares that helper and legitimately renders
124
- * archived documents, so the rejection belongs on the write path.
125
- */
126
- function isArchivedChangePath(projectRoot, resolvedAbsPath) {
127
- const posixRel = path.relative(projectRoot, resolvedAbsPath).replace(/\\/g, '/');
128
- const parts = posixRel.split('/');
129
- return parts[0] === 'tospec' && parts[1] === 'changes' && parts[2] === ARCHIVE_SEGMENT;
130
- }
131
- /**
132
- * Maps a resolved task-file path (`tospec/changes/<name>/...`) back to its
133
- * change name, or null when it doesn't match. An archived change is one level
134
- * deeper, so `parts[2]` is the literal `archive`, never a change name.
135
- */
136
- function changeNameFromTaskFile(projectRoot, resolvedAbsPath) {
137
- const posixRel = path.relative(projectRoot, resolvedAbsPath).replace(/\\/g, '/');
138
- const parts = posixRel.split('/');
139
- if (parts.length < 4 || parts[0] !== 'tospec' || parts[1] !== 'changes')
140
- return null;
141
- if (parts[2] === ARCHIVE_SEGMENT)
142
- return null;
143
- return parts[2] || null;
144
- }
145
- /**
146
- * Renders through `renderMarkdownSafe`, not `marked.parse`: the client assigns
147
- * this with `innerHTML`, so anything the renderer passes through runs inside
148
- * the CSRF boundary `handleTaskUpdate` relies on.
149
- */
150
- async function handleRender(projectRoot, fileParam, res) {
151
- if (!fileParam) {
152
- return sendJson(res, 400, { error: 'missing file parameter' });
153
- }
154
- const resolved = resolveTospecFile(projectRoot, fileParam, 'tospec/');
155
- if (!resolved) {
156
- return sendJson(res, 403, { error: 'forbidden' });
157
- }
158
- let content;
159
- try {
160
- content = await fs.readFile(resolved, 'utf-8');
161
- }
162
- catch {
163
- return sendJson(res, 404, { error: 'not found' });
164
- }
165
- const html = await renderMarkdownSafe(content);
166
- sendJson(res, 200, { html });
40
+ /** Drops one leading `Error: ` the child added, so wrapping doesn't stack prefixes. */
41
+ function stripErrorPrefix(text) {
42
+ return text.replace(/^Error:\s*/, '');
167
43
  }
168
44
  /** Reads a JSON request body, capped so a stray client can't exhaust memory. */
169
- async function readJsonBody(req, limit = 8192) {
45
+ async function readJsonBody(req, limit) {
170
46
  // Bytes first, decoded once: `string += chunk` decodes each chunk alone, so a
171
47
  // multi-byte character split across two chunks (a CJK filename) came out as
172
48
  // U+FFFD and the request addressed a file that does not exist.
@@ -176,7 +52,7 @@ async function readJsonBody(req, limit = 8192) {
176
52
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
177
53
  size += buffer.length;
178
54
  if (size > limit)
179
- throw new Error('body too large');
55
+ throw new HubRequestError('body too large', 413);
180
56
  chunks.push(buffer);
181
57
  }
182
58
  const raw = Buffer.concat(chunks).toString('utf-8');
@@ -184,103 +60,9 @@ async function readJsonBody(req, limit = 8192) {
184
60
  return JSON.parse(raw);
185
61
  }
186
62
  catch {
187
- throw new Error('invalid json');
63
+ throw new HubRequestError('invalid json', 400);
188
64
  }
189
65
  }
190
- /**
191
- * Ticks a task checkbox: `{ file, line, done }`, `file` root-relative and `line`
192
- * 1-based. Writes are confined to `<root>/tospec/changes/`, so the dashboard can
193
- * never edit specs.
194
- *
195
- * Requiring `content-type: application/json` is load-bearing: it pushes any
196
- * cross-origin POST into a CORS preflight this server does not answer.
197
- */
198
- async function handleTaskUpdate(projectRoot, req, res) {
199
- if (!/^application\/json\b/.test(req.headers['content-type'] ?? '')) {
200
- return sendJson(res, 415, { error: 'expected application/json' });
201
- }
202
- let body;
203
- try {
204
- body = await readJsonBody(req);
205
- }
206
- catch (err) {
207
- return sendJson(res, 400, { error: err instanceof Error ? err.message : 'bad request' });
208
- }
209
- const { file, line, done } = (body ?? {});
210
- if (typeof file !== 'string' || !Number.isInteger(line) || line < 1) {
211
- return sendJson(res, 400, { error: 'expected { file: string, line: int >= 1, done: bool }' });
212
- }
213
- if (typeof done !== 'boolean') {
214
- return sendJson(res, 400, { error: 'expected { file: string, line: int >= 1, done: bool }' });
215
- }
216
- const resolved = resolveTospecFile(projectRoot, file, 'tospec/changes/');
217
- if (!resolved) {
218
- return sendJson(res, 403, { error: 'forbidden' });
219
- }
220
- // Before the sync gate: that gate asks whether a change is *becoming*
221
- // immutable; an archived one already is.
222
- if (isArchivedChangePath(projectRoot, resolved)) {
223
- return sendJson(res, 403, { error: 'change is archived; tasks are immutable' });
224
- }
225
- // The checks above are lexical. A task file that is itself a symlink out of
226
- // the project passes both, and the write would land outside the root, so the
227
- // fence is re-checked on the real path. Before the tracked-file check below:
228
- // that check canonicalizes its own side, so the symlink used to fail there
229
- // as "not the tracked file" — refused, but for the wrong reason. A file that
230
- // is gone by now is the 404 the write would have reported.
231
- let realFile;
232
- try {
233
- const realRoot = await fs.realpath(projectRoot);
234
- realFile = await fs.realpath(resolved);
235
- if (!realFile.startsWith(realRoot + path.sep)) {
236
- return sendJson(res, 403, { error: 'forbidden' });
237
- }
238
- }
239
- catch (err) {
240
- if (err.code === 'ENOENT')
241
- return sendJson(res, 404, { error: 'not found' });
242
- throw err;
243
- }
244
- // A file directly under tospec/changes/ belongs to no change, so neither the
245
- // sync gate nor the tracked-file check below can apply to it. Skipping both
246
- // left any markdown there writable by a raw POST.
247
- const changeName = changeNameFromTaskFile(projectRoot, resolved);
248
- if (!changeName) {
249
- return sendJson(res, 403, { error: 'forbidden' });
250
- }
251
- // Freeze task edits once a change is sync certified. The frontend disable is
252
- // only UX; this gate is what protects certified content from a raw POST. A
253
- // read error reads as not certified.
254
- const changeDir = path.join(projectRoot, 'tospec', 'changes', changeName);
255
- if ((await readSyncConclusion(changeDir)) === 'PASS') {
256
- return sendJson(res, 409, { error: 'change is archiving; tasks are frozen' });
257
- }
258
- // Confined to the schema's tracked task file, via the resolver the read
259
- // side uses. The path check above only proves the file sits inside a change
260
- // directory, so any other markdown there with a checkbox on the addressed
261
- // line was writable too, invisibly. The resolver canonicalizes the files it
262
- // finds and leaves its `tasks.md` fallback as joined, so both spellings of
263
- // the request path are compared.
264
- const tracked = resolveTaskFiles(changeDir, projectRoot).map((file) => path.resolve(file));
265
- if (!tracked.includes(realFile) && !tracked.includes(path.resolve(resolved))) {
266
- return sendJson(res, 409, {
267
- error: `${path.basename(resolved)} is not this change's tracked task file`,
268
- });
269
- }
270
- try {
271
- await setTaskDone(resolved, line, done);
272
- }
273
- catch (err) {
274
- const message = err instanceof Error ? err.message : String(err);
275
- // Stale client view or a deleted file — neither is a server fault, so no 500.
276
- if (/not a task/.test(message))
277
- return sendJson(res, 409, { error: message });
278
- if (/ENOENT/.test(message))
279
- return sendJson(res, 404, { error: 'not found' });
280
- throw err;
281
- }
282
- sendJson(res, 200, { file, line, done });
283
- }
284
66
  function handleEvents(req, res, clients) {
285
67
  res.writeHead(200, {
286
68
  'content-type': 'text/event-stream',
@@ -291,18 +73,22 @@ function handleEvents(req, res, clients) {
291
73
  clients.add(res);
292
74
  req.on('close', () => clients.delete(res));
293
75
  }
294
- function broadcastChange(clients) {
295
- for (const client of clients) {
296
- client.write('event: change\ndata: {}\n\n');
297
- }
76
+ function broadcast(clients, event) {
77
+ const { type, ...data } = event;
78
+ const line = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
79
+ for (const client of clients)
80
+ client.write(line);
298
81
  }
299
82
  /**
300
83
  * Second layer behind `renderMarkdownSafe`, applied to every response.
301
84
  *
302
85
  * `script-src 'self'` is the load-bearing directive: it refuses the inline
303
- * handlers and `javascript:` URLs a markdown file would need to reach
304
- * `POST /api/task` from inside this origin. `style-src` keeps `'unsafe-inline'`
305
- * for Chart.js — a style injection cannot call the write endpoint.
86
+ * handlers and `javascript:` URLs a markdown file would need to reach the task
87
+ * relay from inside this origin. `style-src` still carries `'unsafe-inline'`,
88
+ * added for Chart.js, which the dashboard no longer loads (its charts are SVG
89
+ * styled from the stylesheet, and the app sets styles through the CSSOM).
90
+ * Dropping it is a separate hardening step; a style injection cannot call the
91
+ * write endpoint either way.
306
92
  */
307
93
  function applyContentSecurityPolicy(res) {
308
94
  res.setHeader('content-security-policy', [
@@ -317,7 +103,104 @@ function applyContentSecurityPolicy(res) {
317
103
  "frame-ancestors 'none'",
318
104
  ].join('; '));
319
105
  }
320
- async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAnyHost) {
106
+ /** `/api/p/<key>/<rest>` → the project key and the per-project path `/api/<rest>?…`. */
107
+ function matchProjectPath(url) {
108
+ const match = /^\/api\/p\/([0-9a-f]{12})(\/.*)$/.exec(url.pathname);
109
+ if (!match)
110
+ return null;
111
+ return { key: match[1], inner: `/api${match[2]}${url.search}` };
112
+ }
113
+ /**
114
+ * `/api/p/<key>/sessions/<id>/<action>`: the hub's own answers about one
115
+ * session, matched before the per-project relay, which would otherwise carry
116
+ * them to the project as a file read.
117
+ */
118
+ function matchSessionPath(url) {
119
+ const match = /^\/api\/p\/([0-9a-f]{12})\/sessions\/([^/]+)\/(log|answer)$/.exec(url.pathname);
120
+ if (!match)
121
+ return null;
122
+ try {
123
+ return { key: match[1], session: decodeURIComponent(match[2]), action: match[3] };
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
129
+ function fieldsOf(body) {
130
+ return typeof body === 'object' && body !== null ? body : {};
131
+ }
132
+ /**
133
+ * Every POST must be JSON. That is the CSRF fence for the whole write surface:
134
+ * a cross-origin page can only send `application/json` through a CORS preflight
135
+ * this server never answers, so no web page can push a snapshot, take a queued
136
+ * command, tick a task, or answer for a session.
137
+ */
138
+ async function handlePost(hub, req, res, url) {
139
+ if (!isJsonContentType(req.headers['content-type'])) {
140
+ return sendJson(res, 415, { error: 'expected application/json' });
141
+ }
142
+ const pathname = url.pathname;
143
+ if (pathname === '/api/mod/snapshot') {
144
+ const body = fieldsOf(await readJsonBody(req, SNAPSHOT_BODY_LIMIT));
145
+ return sendJson(res, 200, { project: hub.acceptSnapshot(body.session, body.snapshot) });
146
+ }
147
+ if (pathname === '/api/mod/poll') {
148
+ const body = fieldsOf(await readJsonBody(req, BROWSER_BODY_LIMIT));
149
+ const hangUp = new AbortController();
150
+ res.on('close', () => hangUp.abort());
151
+ const commands = await hub.poll(body.project, body.session, hangUp.signal);
152
+ if (commands === null)
153
+ return sendJson(res, 404, { error: 'unknown project' });
154
+ return sendJson(res, 200, { commands });
155
+ }
156
+ if (pathname === '/api/mod/reply') {
157
+ const body = fieldsOf(await readJsonBody(req, REPLY_BODY_LIMIT));
158
+ const settled = hub.reply(body.id, { status: body.status, body: body.body });
159
+ return sendJson(res, settled ? 200 : 404, settled ? { ok: true } : { error: 'no such request' });
160
+ }
161
+ if (pathname === '/api/mod/leave') {
162
+ const body = fieldsOf(await readJsonBody(req, BROWSER_BODY_LIMIT));
163
+ hub.leave(body.project, body.session);
164
+ return sendJson(res, 200, { ok: true });
165
+ }
166
+ if (pathname === '/api/mod/activity') {
167
+ const body = fieldsOf(await readJsonBody(req, ACTIVITY_BODY_LIMIT));
168
+ const known = hub.reportActivity(body.project, body.session, body);
169
+ return sendJson(res, known ? 200 : 404, known ? { ok: true } : { error: 'unknown project' });
170
+ }
171
+ if (pathname === '/api/mod/ask') {
172
+ const body = fieldsOf(await readJsonBody(req, ACTIVITY_BODY_LIMIT));
173
+ return sendJson(res, 200, hub.openAsk(body.project, body.session, body.ask));
174
+ }
175
+ if (pathname === '/api/mod/ask/wait') {
176
+ const body = fieldsOf(await readJsonBody(req, BROWSER_BODY_LIMIT));
177
+ const hangUp = new AbortController();
178
+ res.on('close', () => hangUp.abort());
179
+ return sendJson(res, 200, await hub.waitAsk(body.id, hangUp.signal));
180
+ }
181
+ if (pathname === '/api/mod/ask/close') {
182
+ const body = fieldsOf(await readJsonBody(req, BROWSER_BODY_LIMIT));
183
+ hub.closeAsk(body.id);
184
+ return sendJson(res, 200, { ok: true });
185
+ }
186
+ const session = matchSessionPath(url);
187
+ if (session && session.action === 'answer') {
188
+ const body = fieldsOf(await readJsonBody(req, BROWSER_BODY_LIMIT));
189
+ const answer = hub.answerAsk(session.key, session.session, body.id, body.answer);
190
+ return sendJson(res, answer.status, answer.body);
191
+ }
192
+ const scoped = matchProjectPath(url);
193
+ if (scoped && scoped.inner === '/api/task') {
194
+ const body = await readJsonBody(req, BROWSER_BODY_LIMIT);
195
+ const answer = await hub.write(scoped.key, { method: 'POST', path: '/api/task', body });
196
+ return sendJson(res, answer.status, answer.body);
197
+ }
198
+ if (scoped && scoped.inner === '/api/refresh') {
199
+ return sendJson(res, 202, { queued: hub.requestRefresh(scoped.key) });
200
+ }
201
+ sendJson(res, 404, { error: 'not found' });
202
+ }
203
+ async function handleRequest(hub, req, res, clients, boundHost, acceptAnyHost) {
321
204
  try {
322
205
  // Host allowlist runs before any route (DNS-rebinding guard).
323
206
  if (!isAllowedHostHeader(req.headers.host, boundHost, acceptAnyHost)) {
@@ -326,14 +209,32 @@ async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAn
326
209
  applyContentSecurityPolicy(res);
327
210
  const url = new URL(req.url ?? '/', 'http://localhost');
328
211
  const pathname = url.pathname;
329
- if (req.method === 'POST' && pathname === '/api/task') {
330
- return await handleTaskUpdate(projectRoot, req, res);
212
+ if (req.method === 'POST') {
213
+ return await handlePost(hub, req, res, url);
331
214
  }
332
215
  if (req.method !== 'GET') {
333
216
  return sendJson(res, 405, { error: 'method not allowed' });
334
217
  }
335
218
  if (pathname === '/api/events')
336
219
  return handleEvents(req, res, clients);
220
+ // The mod's cheap reachability probe, so a session with no hub to talk to
221
+ // does not run the tospec CLI just to find that out. The pid lets `stop`
222
+ // confirm a registry marker still names this hub (`answersAsHub`).
223
+ if (pathname === '/api/health') {
224
+ return sendJson(res, 200, { protocol: DASHBOARD_PROTOCOL, pid: process.pid });
225
+ }
226
+ // `interactive` tells the page whether to offer the answer controls and
227
+ // the task checkboxes.
228
+ if (pathname === '/api/projects') {
229
+ return sendJson(res, 200, { projects: hub.listProjects(), interactive: hub.interactive });
230
+ }
231
+ const session = matchSessionPath(url);
232
+ if (session) {
233
+ if (session.action !== 'log')
234
+ return sendJson(res, 405, { error: 'method not allowed' });
235
+ const log = hub.sessionLog(session.key, session.session);
236
+ return log ? sendJson(res, 200, log) : sendJson(res, 404, { error: 'That session is not connected to this dashboard.' });
237
+ }
337
238
  // `/index.html` alongside `/`: nothing links to it, but typing it 404'd.
338
239
  if (pathname === '/' || pathname === '/index.html') {
339
240
  return await serveAsset(res, ASSETS_DIR, 'index.html');
@@ -341,89 +242,77 @@ async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAn
341
242
  if (pathname.startsWith('/assets/')) {
342
243
  return await serveAsset(res, ASSETS_DIR, pathname.slice('/assets/'.length));
343
244
  }
344
- if (pathname === '/api/overview') {
345
- return sendJson(res, 200, await collectOverview(projectRoot));
346
- }
347
- if (pathname === '/api/archive') {
348
- return sendJson(res, 200, await collectArchive(projectRoot));
349
- }
350
- if (pathname === '/api/decisions') {
351
- return sendJson(res, 200, await collectDecisions(projectRoot));
352
- }
353
- if (pathname === '/api/activity') {
354
- return await handleActivity(projectRoot, url.searchParams.get('change'), url.searchParams.get('days'), res);
355
- }
356
- if (pathname.startsWith('/api/changes/')) {
357
- return await handleChangeDetail(projectRoot, pathname.slice('/api/changes/'.length), res);
358
- }
359
- if (pathname.startsWith('/api/specs/')) {
360
- return await handleSpec(projectRoot, pathname.slice('/api/specs/'.length), res);
361
- }
362
- if (pathname === '/api/render') {
363
- return await handleRender(projectRoot, url.searchParams.get('file'), res);
245
+ const scoped = matchProjectPath(url);
246
+ if (scoped) {
247
+ const answer = await hub.read(scoped.key, scoped.inner);
248
+ return sendJson(res, answer.status, answer.body);
364
249
  }
365
250
  sendJson(res, 404, { error: 'not found' });
366
251
  }
367
252
  catch (err) {
253
+ if (err instanceof HubRequestError)
254
+ return sendJson(res, err.status, { error: err.message });
368
255
  // A single handler failure must never crash the server.
369
256
  sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
370
257
  }
371
258
  }
372
259
  /**
373
- * Starts the server, advancing past `EADDRINUSE` until a port is free (D6) so
374
- * dashboards for different projects coexist. Side-effect free (no logging,
375
- * signal handlers or registry) so tests can drive it directly.
260
+ * Starts the hub on exactly the requested port. Unlike the per-project servers
261
+ * this replaced, it never advances past `EADDRINUSE`: mods find the hub at a
262
+ * fixed address, so a hub that moved would be one no session could reach.
263
+ * Side-effect free (no logging, signal handlers or registry) so tests can drive
264
+ * it directly.
376
265
  */
377
- export async function startDashboardServer(projectRoot, opts = {}) {
266
+ export async function startDashboardServer(opts = {}) {
378
267
  const host = opts.host ?? DEFAULT_HOST;
379
268
  assertBindableHost(host, 'dashboard', opts.allowRemote, 'Pass --allow-remote to accept that risk.');
380
269
  const acceptAnyHost = acceptsAnyHostHeader(host, opts.allowRemote);
381
270
  const clients = new Set();
271
+ const hub = new DashboardHub({
272
+ // Driving a session from the page runs tools in it, and a task tick writes
273
+ // project files, so only a hub that nobody else can reach may do either;
274
+ // `--allow-remote` leaves it read-only.
275
+ interactive: LOOPBACK_HOSTS.has(normalizeHost(host)),
276
+ ...opts.hub,
277
+ });
278
+ const unsubscribe = hub.subscribe((event) => broadcast(clients, event));
382
279
  const server = http.createServer((req, res) => {
383
- void handleRequest(projectRoot, req, res, clients, host, acceptAnyHost);
280
+ void handleRequest(hub, req, res, clients, host, acceptAnyHost);
384
281
  });
385
- const actualPort = await listenFrom(server, opts.port ?? DEFAULT_PORT, host);
386
- const watcher = watchTospec(projectRoot, () => broadcastChange(clients));
387
- server.once('close', () => watcher?.close());
388
- return { server, url: connectableUrl(host, actualPort), port: actualPort };
389
- }
390
- /**
391
- * Watches `<root>/tospec` recursively and calls `onChange` (debounced), or null
392
- * if the directory can't be watched. `fs.watch` uses OS change notifications,
393
- * so it never locks the watched files — agents and users stay free to edit or
394
- * delete them (D2). Recursive watch is native on win32/macOS, Node 20+ on Linux.
395
- */
396
- function watchTospec(projectRoot, onChange) {
397
- const dir = path.join(projectRoot, 'tospec');
398
- let timer = null;
282
+ const requestedPort = opts.port ?? DEFAULT_PORT;
399
283
  try {
400
- const watcher = watch(dir, { recursive: true }, () => {
401
- if (timer)
402
- clearTimeout(timer);
403
- timer = setTimeout(onChange, 200);
404
- });
405
- watcher.on('close', () => timer && clearTimeout(timer));
406
- watcher.on('error', () => { }); // transient watch errors must not crash the server
407
- return watcher;
284
+ await listenOnce(server, requestedPort, host);
408
285
  }
409
- catch {
410
- return null;
286
+ catch (err) {
287
+ if (err.code === 'EADDRINUSE') {
288
+ throw new Error(`Port ${requestedPort} is already in use. Another dashboard may be running: see tospec dashboard status.`);
289
+ }
290
+ throw err;
411
291
  }
292
+ const address = server.address();
293
+ const port = typeof address === 'object' && address ? address.port : requestedPort;
294
+ const sweep = setInterval(() => hub.sweep(), SWEEP_INTERVAL_MS);
295
+ sweep.unref();
296
+ const close = async () => {
297
+ clearInterval(sweep);
298
+ unsubscribe();
299
+ hub.close();
300
+ // Held polls and SSE streams would otherwise keep close() from draining.
301
+ server.closeAllConnections?.();
302
+ await new Promise((resolve) => server.close(() => resolve()));
303
+ };
304
+ return { server, url: connectableUrl(host, port), port, hub, close };
412
305
  }
413
- /**
414
- * PID-file location, keyed by project root so `--detach` and `--stop` derive the
415
- * same path. In the OS temp dir, not the repo, so it never gets committed.
416
- */
417
- export function pidFilePath(projectRoot) {
418
- const hash = crypto.createHash('sha1').update(path.resolve(projectRoot)).digest('hex').slice(0, 12);
419
- return path.join(os.tmpdir(), `tospec-dashboard-${hash}.json`);
306
+ function registryDir() {
307
+ // Derived from the one config resolver, so relocating the config directory
308
+ // moves the registry with it.
309
+ return path.join(getGlobalConfigDir(), 'dashboards');
420
310
  }
421
311
  /** True if a process with this PID exists (signal 0 probes without killing). */
422
312
  function isProcessAlive(pid) {
423
- // Every record's pid arrives here, so the sign check guards the module: on
424
- // POSIX `kill(0, sig)` signals our own process group and `kill(-n, sig)` a
425
- // named one, so a corrupt record in the shared temp dir would make `--stop`
426
- // terminate the user's own shell.
313
+ // On POSIX `kill(0, sig)` signals our own process group and `kill(-n, sig)` a
314
+ // named one, so a corrupt marker would make `stop` terminate the user's own
315
+ // shell.
427
316
  if (!Number.isInteger(pid) || pid <= 0)
428
317
  return false;
429
318
  try {
@@ -435,67 +324,6 @@ function isProcessAlive(pid) {
435
324
  return err.code === 'EPERM';
436
325
  }
437
326
  }
438
- /**
439
- * This project's dashboard PID record, or null if absent or corrupt. `pid` is
440
- * the only required field — a truncated record still names a process to reap.
441
- */
442
- async function readPidRecord(pidFile) {
443
- try {
444
- const rec = JSON.parse(await fs.readFile(pidFile, 'utf-8'));
445
- return typeof rec?.pid === 'number' ? rec : null;
446
- }
447
- catch {
448
- return null;
449
- }
450
- }
451
- /**
452
- * Records this dashboard so `--stop` can find it. Reads tolerate the missing
453
- * optional fields of pre-0.13 records; writes do not, since the root is what
454
- * attributes a scanned record to its project.
455
- */
456
- async function writePidRecord(pidFile, rec) {
457
- await fs.writeFile(pidFile, JSON.stringify(rec), 'utf-8');
458
- }
459
- /**
460
- * Removes a pid record only if it still names `pid`: the path is keyed by
461
- * project, not process, so two sibling dashboards share this file and either
462
- * clearer would otherwise delete the other's record.
463
- *
464
- * Limitation: one record per project, so only the later writer is tracked by
465
- * pid file; both stay reachable through their registry markers.
466
- *
467
- * Sync so it completes before the `process.exit` its signal-handler caller
468
- * races — hence not reusing the async `readPidRecord`.
469
- */
470
- export function clearPidRecordOwnedBy(pidFile, pid) {
471
- try {
472
- const rec = JSON.parse(readFileSync(pidFile, 'utf-8'));
473
- if (rec?.pid === pid)
474
- unlinkSync(pidFile);
475
- }
476
- catch {
477
- // Absent: nothing to do. Corrupt: left alone — it names no process, and a
478
- // half-written record may belong to one that is starting up.
479
- }
480
- }
481
- /** Every project's pid file, not just this one's — they all live in the temp dir. */
482
- async function pidFilePaths() {
483
- const dir = os.tmpdir();
484
- try {
485
- return (await fs.readdir(dir))
486
- // Exactly the shape pidFilePath() generates — never wider than we write.
487
- .filter((name) => /^tospec-dashboard-[0-9a-f]{12}\.json$/.test(name))
488
- .map((name) => path.join(dir, name));
489
- }
490
- catch {
491
- return [];
492
- }
493
- }
494
- function registryDir() {
495
- // Derived from the one config resolver, so relocating the config directory
496
- // moves the registry with it.
497
- return path.join(getGlobalConfigDir(), 'dashboards');
498
- }
499
327
  /**
500
328
  * Lists live dashboards sorted by port, reaping any marker whose process has
501
329
  * died. A missing or unreadable dir means no dashboards are running.
@@ -515,6 +343,7 @@ export async function listDashboards() {
515
343
  if (!m)
516
344
  continue;
517
345
  const pid = Number(m[1]);
346
+ const port = Number(m[2]);
518
347
  const file = path.join(dir, name);
519
348
  if (!isProcessAlive(pid)) {
520
349
  // Best-effort: failing to unlink one stale marker must not take down a
@@ -522,112 +351,71 @@ export async function listDashboards() {
522
351
  await fs.rm(file, { force: true }).catch(() => { });
523
352
  continue;
524
353
  }
525
- let projectRoot = '';
354
+ let url = '';
526
355
  try {
527
- projectRoot = (await fs.readFile(file, 'utf-8')).trim();
356
+ url = (await fs.readFile(file, 'utf-8')).trim();
528
357
  }
529
358
  catch {
530
- /* marker without content (pre-upgrade) → leave path blank */
359
+ /* unreadable marker → fall back to loopback below */
531
360
  }
532
- running.push({ pid, port: Number(m[2]), projectRoot });
361
+ // A marker from before the hub held a project root, not a URL.
362
+ if (!/^https?:\/\//.test(url))
363
+ url = connectableUrl(DEFAULT_HOST, port);
364
+ running.push({ pid, port, url });
533
365
  }
534
366
  return running.sort((a, b) => a.port - b.port);
535
367
  }
536
368
  /**
537
- * One line describing a running dashboard, for `--list` and `--stop`'s picker.
538
- * The URL assumes loopback — `RunningDashboard` doesn't record the bound host.
539
- */
540
- export function formatRunningDashboard(d, sep = '\t') {
541
- const url = `http://${DEFAULT_HOST}:${d.port}`;
542
- return `pid ${d.pid}${sep}${url}${sep}${d.projectRoot || '(unknown project)'}`;
543
- }
544
- /**
545
- * `--stop` found dashboards but cannot ask which to stop (no terminal). Carries
546
- * its own diagnostic so the shared failure sink renders it.
369
+ * True only when the marker's URL answers `/api/health` with the marker's own
370
+ * pid. A live pid alone proves nothing: Windows hard-kills on `stop`, and a
371
+ * reboot or Task Manager skips cleanup, so a marker can outlive its hub until
372
+ * the OS hands the pid to an unrelated process — which `stop` then killed.
373
+ * Matching the pid, not just "something answered", also rejects a different
374
+ * hub that later bound the same port.
547
375
  */
548
- export class DashboardStopError extends Error {
549
- diagnostic;
550
- constructor(message, fix) {
551
- super(message);
552
- this.name = 'DashboardStopError';
553
- this.diagnostic = { severity: 'error', code: 'dashboard_stop_needs_selection', message, fix };
376
+ export async function answersAsHub(d, timeoutMs = STATUS_TIMEOUT_MS) {
377
+ try {
378
+ const res = await fetch(`${d.url}/api/health`, { signal: AbortSignal.timeout(timeoutMs) });
379
+ if (!res.ok)
380
+ return false;
381
+ const body = (await res.json());
382
+ return body.pid === d.pid;
554
383
  }
555
- }
556
- /**
557
- * The detached child neither reported itself listening nor exited. It may still
558
- * be coming up, so the message reports what was not observed rather than
559
- * claiming failure.
560
- */
561
- export class DashboardReadinessTimeoutError extends Error {
562
- diagnostic;
563
- constructor(timeoutMs) {
564
- const message = `The background dashboard process did not report that it is listening within ${Math.round(timeoutMs / 1000)}s. ` +
565
- 'It was started and has not exited, so it may still be coming up.';
566
- super(message);
567
- this.name = 'DashboardReadinessTimeoutError';
568
- this.diagnostic = {
569
- severity: 'error',
570
- code: 'dashboard_detach_readiness_timeout',
571
- message,
572
- fix: 'Check whether it came up with: tospec dashboard --list (then tospec dashboard --stop to stop it).',
573
- };
384
+ catch {
385
+ return false;
574
386
  }
575
387
  }
576
388
  /**
577
- * Every dashboard `--stop` can reach: the registry unioned with every project's
578
- * pid file, keyed by pid, reaping dead records along the way.
579
- *
580
- * The registry alone is not enough — `listDashboards()` reaps any marker whose
581
- * pid looks dead, so a live server can lose its marker and become unstoppable
582
- * while its pid file still names it.
583
- *
584
- * `ownRootHint` names records written before pid files carried their own root;
585
- * it never restricts the result.
389
+ * Drops markers whose pid no longer runs the hub they name, returning the rest.
390
+ * Stale here means "does not answer as that hub", so a hub wedged past the
391
+ * timeout is dropped too: then `start` fails on the port it still holds, which
392
+ * is recoverable, whereas killing a reused pid is not.
586
393
  */
587
- export async function collectStoppableDashboards(ownRootHint) {
588
- const byPid = new Map();
589
- for (const d of await listDashboards())
590
- byPid.set(d.pid, d);
591
- const ownPidFile = ownRootHint ? pidFilePath(ownRootHint) : null;
592
- for (const file of await pidFilePaths()) {
593
- const rec = await readPidRecord(file);
594
- if (!rec)
595
- continue;
596
- // Reap like the marker reaper does: a crashed dashboard's record would
597
- // otherwise linger until the OS recycled its pid, then read as alive again —
598
- // a phantom `--stop` would kill an unrelated process for. Unlink is
599
- // best-effort: the POSIX temp dir is shared with other users.
600
- if (!isProcessAlive(rec.pid)) {
601
- await fs.rm(file, { force: true }).catch(() => { });
602
- continue;
394
+ async function reapUnansweringDashboards(running) {
395
+ const verdicts = await Promise.all(running.map((d) => answersAsHub(d)));
396
+ const live = [];
397
+ const reaped = [];
398
+ for (const [i, d] of running.entries()) {
399
+ if (verdicts[i]) {
400
+ live.push(d);
603
401
  }
604
- // Live, but we cannot name a port to report or reach it by.
605
- if (typeof rec.port !== 'number')
606
- continue;
607
- const root = rec.projectRoot ?? (file === ownPidFile && ownRootHint ? path.resolve(ownRootHint) : '');
608
- const known = byPid.get(rec.pid);
609
- if (!known) {
610
- byPid.set(rec.pid, { pid: rec.pid, port: rec.port, projectRoot: root });
611
- continue;
402
+ else {
403
+ reaped.push(d);
404
+ await unregisterDashboard(d.pid);
612
405
  }
613
- // A content-less registry marker never matches its project; the pid file may
614
- // still know the root.
615
- if (!known.projectRoot && root)
616
- byPid.set(rec.pid, { ...known, projectRoot: root });
617
406
  }
618
- return [...byPid.values()].sort((a, b) => a.port - b.port);
407
+ return { live, reaped };
619
408
  }
620
- /**
621
- * Ports held by live dashboards, reaping dead markers as a side effect. Runs at
622
- * every startup so a crashed dashboard's port frees up.
623
- */
624
- async function reapAndCollectPorts() {
625
- return new Set((await listDashboards()).map((d) => d.port));
409
+ function formatReapedMarker(d) {
410
+ return `Removed a stale dashboard marker: pid ${d.pid} does not answer as the hub at ${d.url}.`;
411
+ }
412
+ /** One line describing a running dashboard, for `start`'s refusal. */
413
+ export function formatRunningDashboard(d) {
414
+ return `pid ${d.pid} ${d.url}`;
626
415
  }
627
- /** Creates the `{pid}_{port}` marker for this dashboard, holding its project root. */
628
- async function registerDashboard(pid, port, projectRoot) {
416
+ async function registerDashboard(pid, port, url) {
629
417
  await fs.mkdir(registryDir(), { recursive: true });
630
- await fs.writeFile(path.join(registryDir(), `${pid}_${port}`), projectRoot, 'utf-8');
418
+ await fs.writeFile(path.join(registryDir(), `${pid}_${port}`), url, 'utf-8');
631
419
  }
632
420
  /** Removes any marker for this pid (port-agnostic, so callers needn't know it). */
633
421
  async function unregisterDashboard(pid) {
@@ -666,42 +454,48 @@ function unregisterDashboardSync(pid) {
666
454
  }
667
455
  }
668
456
  /**
669
- * Launches the dashboard as a detached background process: re-runs this CLI
670
- * without `--detach` (the child is the actual server), then waits for the
671
- * child's `LISTENING` report, so the URL printed is one the child actually
672
- * bound — including after an EADDRINUSE fallback moved it off the predicted
673
- * port. Order: spawn → wait for the report → write the PID record → print.
674
- *
675
- * Refuses if any live dashboard already serves this project, asking the shared
676
- * candidate set because either record can be the only one left (a foreground
677
- * dashboard writes no pid file; a background one can lose its registry marker).
678
- *
679
- * This process and the child both write the PID record, with identical fields.
680
- * The child's is all that remains if this process dies mid-handshake; this one
681
- * makes a record exist by the time the call returns, since the child writes only
682
- * after signalling.
457
+ * The detached child neither reported itself listening nor exited. It may still
458
+ * be coming up, so the message reports what was not observed rather than
459
+ * claiming failure.
460
+ */
461
+ export class DashboardReadinessTimeoutError extends Error {
462
+ diagnostic;
463
+ constructor(timeoutMs) {
464
+ const message = `The background dashboard process did not report that it is listening within ${Math.round(timeoutMs / 1000)}s. ` +
465
+ 'It was started and has not exited, so it may still be coming up.';
466
+ super(message);
467
+ this.name = 'DashboardReadinessTimeoutError';
468
+ this.diagnostic = {
469
+ severity: 'error',
470
+ code: 'dashboard_detach_readiness_timeout',
471
+ message,
472
+ fix: 'Check whether it came up with: tospec dashboard status (then tospec dashboard stop to stop it).',
473
+ };
474
+ }
475
+ }
476
+ /**
477
+ * `tospec dashboard start`: launches the hub as a detached background process
478
+ * by re-running this CLI as the hidden `dashboard serve` (the child is the
479
+ * actual server), then waits for the child's `LISTENING` report, so the URL
480
+ * printed is one the child actually bound.
683
481
  *
684
- * This is the only guarded start path: nothing stops a second foreground
685
- * `tospec dashboard`, so "one dashboard per project" is not machine-wide.
482
+ * Refuses while any hub is running: one hub serves every project, and a second
483
+ * one could only bind another port that no mod looks at. Limitation: no
484
+ * cross-process lock, so two simultaneous `start` could both pass the guard;
485
+ * the loser then fails on the port.
686
486
  */
687
- export async function detachDashboard(projectRoot, cliEntry, opts) {
688
- const pidFile = pidFilePath(projectRoot);
689
- const running = shouldRefuseDetach(await collectStoppableDashboards(projectRoot), projectRoot);
487
+ export async function detachDashboard(cliEntry, opts) {
488
+ const { live, reaped } = await reapUnansweringDashboards(await listDashboards());
489
+ for (const d of reaped)
490
+ console.log(formatReapedMarker(d));
491
+ const running = live[0];
690
492
  if (running) {
691
- // Same fields and separator as `--list` and `--stop`'s picker.
692
- console.log('Dashboard already running for this project.');
693
- console.log(` ${formatRunningDashboard(running, ' ')}`);
694
- console.log('Stop it with: tospec dashboard --stop');
493
+ console.log('Dashboard already running.');
494
+ console.log(` ${formatRunningDashboard(running)}`);
495
+ console.log('Stop it with: tospec dashboard stop');
695
496
  return;
696
497
  }
697
- // Predict the port to hand the child, skipping ones live dashboards hold. The
698
- // child re-checks and binds, so a race for this port just shifts it upward;
699
- // what gets reported is the URL the child confirms, never this prediction.
700
- const used = await reapAndCollectPorts();
701
- let port = opts.port;
702
- while (used.has(port))
703
- port++;
704
- const args = [cliEntry, 'dashboard', opts.targetPath, '--port', String(port), '--host', opts.host];
498
+ const args = [cliEntry, 'dashboard', 'serve', '--port', String(opts.port), '--host', opts.host];
705
499
  if (opts.open)
706
500
  args.push('--open');
707
501
  if (opts.allowRemote)
@@ -713,15 +507,13 @@ export async function detachDashboard(projectRoot, cliEntry, opts) {
713
507
  stdio: ['ignore', 'ignore', 'pipe'],
714
508
  // On Windows, detached: true otherwise pops a new console window.
715
509
  windowsHide: true,
716
- env: { ...process.env, TOSPEC_DASHBOARD_PIDFILE: pidFile },
510
+ env: { ...process.env, [DETACHED_ENV]: '1' },
717
511
  });
718
- // No pid means the spawn itself failed; reporting success would leave a record
719
- // `--stop` could never use.
512
+ // No pid means the spawn itself failed; reporting success would point at a
513
+ // process `--stop` could never reach.
720
514
  if (child.pid === undefined) {
721
515
  throw new Error('Could not start the background dashboard process.');
722
516
  }
723
- // Everything that can go wrong after a successful spawn happens inside the
724
- // child and is only observable through this handshake.
725
517
  let confirmedUrl;
726
518
  try {
727
519
  confirmedUrl = await waitForListening(child);
@@ -731,31 +523,14 @@ export async function detachDashboard(projectRoot, cliEntry, opts) {
731
523
  // un-unref'd handle would keep this process's event loop alive.
732
524
  child.unref();
733
525
  }
734
- // Derived from the URL the child reported, so this record and the child's own
735
- // agree whichever lands last. Limitation: no cross-process lock, so two
736
- // simultaneous --detach could both pass the guard.
737
- await writePidRecord(pidFile, {
738
- pid: child.pid,
739
- port: portFromUrl(confirmedUrl) ?? port,
740
- url: confirmedUrl,
741
- projectRoot: path.resolve(projectRoot),
742
- });
743
526
  console.log(`Dashboard starting in background at ${confirmedUrl}`);
744
- console.log('Stop it with: tospec dashboard --stop');
745
- }
746
- /** `http://host:5621` -> `5621`, or null if the URL carries no parseable port. */
747
- function portFromUrl(url) {
748
- const match = /:(\d+)$/.exec(url);
749
- return match ? Number(match[1]) : null;
527
+ console.log('Stop it with: tospec dashboard stop');
750
528
  }
751
529
  /**
752
530
  * Races the child's outcome: resolves with the confirmed URL on
753
531
  * `LISTENING <url>`, rejects with its captured stderr if it exits first, and
754
532
  * rejects on timeout if it does neither. Closes the stderr pipe once settled so
755
533
  * our end cannot keep this process's event loop alive.
756
- *
757
- * The timeout is a real third outcome: a child that binds then wedges emits
758
- * neither event, and `--detach` would hang with no output.
759
534
  */
760
535
  function waitForListening(child, timeoutMs = READINESS_TIMEOUT_MS) {
761
536
  return new Promise((resolve, reject) => {
@@ -804,44 +579,13 @@ function waitForListening(child, timeoutMs = READINESS_TIMEOUT_MS) {
804
579
  }, timeoutMs);
805
580
  });
806
581
  }
807
- /** Case-correct path equality (the Windows filesystem is case-insensitive). */
808
- export function samePath(a, b) {
809
- const na = path.resolve(a);
810
- const nb = path.resolve(b);
811
- return process.platform === 'win32' ? na.toLowerCase() === nb.toLowerCase() : na === nb;
812
- }
813
- /**
814
- * The dashboards `--stop` may terminate without asking. Empty is the caller's
815
- * cue to offer the pick-from-all list. A candidate with no recorded project
816
- * never matches: `path.resolve('')` is the cwd, so a blank root would claim it.
817
- */
818
- export function selectStopTargets(candidates, projectRoot) {
819
- if (!projectRoot)
820
- return [];
821
- return candidates.filter((d) => d.projectRoot && samePath(d.projectRoot, projectRoot));
822
- }
823
- /**
824
- * The dashboard already serving this project, or null to go ahead and start one.
825
- * One dashboard per project, foreground or background — see
826
- * `tospec/decisions/20260727_232950-one-dashboard-per-project.md`.
827
- *
828
- * Deliberately the same question `--stop` asks, over the same candidate set, so
829
- * the two can never disagree about what is running.
830
- */
831
- export function shouldRefuseDetach(candidates, projectRoot) {
832
- // First match only: one is enough to refuse and point at.
833
- return selectStopTargets(candidates, projectRoot)[0] ?? null;
834
- }
835
582
  /**
836
- * Terminates a running dashboard and clears its registry marker, plus the
837
- * per-project pid file if that still names this pid. A dead pid counts as
838
- * already stopped.
839
- *
840
- * Returns null on success, or the reason the signal was refused — the POSIX temp
841
- * dir is shared, so a candidate can belong to another user.
583
+ * Terminates a running dashboard and clears its registry marker. A dead pid
584
+ * counts as already stopped. Returns null on success, or the reason the signal
585
+ * was refused.
842
586
  *
843
587
  * Limitation: SIGTERM; Windows maps it to TerminateProcess (hard kill), so the
844
- * target's own cleanup won't run — we clear its files here.
588
+ * target's own cleanup won't run — we clear its marker here.
845
589
  */
846
590
  export async function stopRunningDashboard(d) {
847
591
  try {
@@ -849,71 +593,149 @@ export async function stopRunningDashboard(d) {
849
593
  }
850
594
  catch (err) {
851
595
  // ESRCH means it exited between listing and now: already stopped, so still
852
- // clear its records. Anything else (EPERM) is a real refusal.
596
+ // clear its marker. Anything else (EPERM) is a real refusal.
853
597
  const { code, message } = err;
854
598
  if (code !== 'ESRCH')
855
599
  return code ?? message;
856
600
  }
857
601
  await unregisterDashboard(d.pid);
858
- // Only this dashboard's record: the path is per-project, so stopping one of
859
- // two siblings must not clear the other's.
860
- if (d.projectRoot)
861
- clearPidRecordOwnedBy(pidFilePath(d.projectRoot), d.pid);
862
602
  return null;
863
603
  }
864
604
  /**
865
- * CLI entry: allocates a free port, starts the server, prints the URL, and stays
866
- * up until terminated. Port allocation reaps dead registry markers and skips
867
- * ports live dashboards hold; the bind then advances past any the registry
868
- * didn't know about.
605
+ * `tospec dashboard stop`: stops every hub, not a pick. One serves all
606
+ * projects, so there is no "this project's dashboard" left to choose.
607
+ */
608
+ export async function dashboardStopCommand() {
609
+ const { live, reaped } = await reapUnansweringDashboards(await listDashboards());
610
+ for (const d of reaped)
611
+ console.log(formatReapedMarker(d));
612
+ if (live.length === 0) {
613
+ console.log('No dashboards are running.');
614
+ return;
615
+ }
616
+ for (const d of live) {
617
+ const refusal = await stopRunningDashboard(d);
618
+ if (refusal === null) {
619
+ console.log(`Stopped dashboard (pid ${d.pid}) at ${d.url}.`);
620
+ }
621
+ else {
622
+ console.error(`Error: Could not stop dashboard (pid ${d.pid}) at ${d.url}: ${refusal}.`);
623
+ process.exitCode = 1;
624
+ }
625
+ }
626
+ }
627
+ /**
628
+ * Asks a running hub which projects it serves. Sessions live only in the hub's
629
+ * memory, so the registry marker can say a hub is running but not whom it
630
+ * serves.
631
+ */
632
+ export async function fetchHubProjects(d, timeoutMs = STATUS_TIMEOUT_MS) {
633
+ const res = await fetch(`${d.url}/api/projects`, { signal: AbortSignal.timeout(timeoutMs) });
634
+ if (!res.ok)
635
+ throw new Error(`it answered HTTP ${res.status}`);
636
+ const body = (await res.json());
637
+ if (!Array.isArray(body.projects))
638
+ throw new Error('its answer had no project list');
639
+ return body.projects;
640
+ }
641
+ function plural(n, noun) {
642
+ return `${n} ${noun}${n === 1 ? '' : 's'}`;
643
+ }
644
+ /**
645
+ * The `status` block for one hub. Projects with no live session stay listed as
646
+ * offline: the hub still serves their last snapshot.
869
647
  */
870
- export async function runDashboard(projectRoot, opts = {}) {
871
- const used = await reapAndCollectPorts();
872
- let start = opts.port ?? DEFAULT_PORT;
873
- while (used.has(start))
874
- start++;
875
- const { server, url, port } = await startDashboardServer(projectRoot, { ...opts, port: start });
648
+ export function formatDashboardStatus(d, projects) {
649
+ const sessionCount = projects.reduce((sum, p) => sum + p.sessions.length, 0);
650
+ const lines = [`Dashboard running at ${d.url} (pid ${d.pid}).`, `Sessions: ${sessionCount}`];
651
+ if (projects.length === 0) {
652
+ lines.push('Projects: none');
653
+ return lines;
654
+ }
655
+ lines.push(`Projects: ${projects.length}`);
656
+ const counts = projects.map((p) => (p.sessions.length === 0 ? 'offline' : plural(p.sessions.length, 'session')));
657
+ const nameWidth = maxDisplayWidth(projects.map((p) => p.name));
658
+ const countWidth = Math.max(...counts.map((c) => c.length));
659
+ projects.forEach((p, i) => {
660
+ // Root last: two projects can share a directory name, and the root is what
661
+ // tells them apart.
662
+ lines.push(` ${padEndDisplay(p.name, nameWidth)} ${counts[i].padEnd(countWidth)} ${p.root}`);
663
+ });
664
+ return lines;
665
+ }
666
+ /** `tospec dashboard status`: whether a hub runs, and the sessions it serves. */
667
+ export async function dashboardStatusCommand() {
668
+ const { live, reaped } = await reapUnansweringDashboards(await listDashboards());
669
+ for (const d of reaped)
670
+ console.log(formatReapedMarker(d));
671
+ if (live.length === 0) {
672
+ console.log('Dashboard is not running.');
673
+ console.log('Start it with: tospec dashboard start');
674
+ return;
675
+ }
676
+ for (const [i, d] of live.entries()) {
677
+ if (i > 0)
678
+ console.log('');
679
+ let projects;
680
+ try {
681
+ projects = await fetchHubProjects(d);
682
+ }
683
+ catch (err) {
684
+ // It answered /api/health as this hub a moment ago, so it is a hub, just
685
+ // not serving its project list. Saying "running" would claim the one
686
+ // thing this could not confirm.
687
+ const reason = err instanceof Error ? err.message : String(err);
688
+ console.error(`Error: Dashboard process ${d.pid} is registered at ${d.url} but did not answer: ${reason}.`);
689
+ console.error('Fix: Retry in a moment; if it still does not answer, run tospec dashboard stop, then tospec dashboard start.');
690
+ process.exitCode = 1;
691
+ continue;
692
+ }
693
+ for (const line of formatDashboardStatus(d, projects))
694
+ console.log(line);
695
+ }
696
+ }
697
+ /**
698
+ * `tospec dashboard serve` (hidden), the process `start` spawns: starts the
699
+ * hub, records it, prints the URL, and stays up until terminated.
700
+ */
701
+ export async function runDashboard(opts = {}) {
702
+ const started = await startDashboardServer(opts);
703
+ const { url, port } = started;
704
+ // Recorded before the readiness line, so a `status` right after `start`
705
+ // returns already sees it.
706
+ try {
707
+ await registerDashboard(process.pid, port, url);
708
+ }
709
+ catch (err) {
710
+ await started.close();
711
+ throw err;
712
+ }
876
713
  console.log(`Dashboard running at ${url} (Ctrl+C to stop)`);
714
+ console.log('Claude Code sessions in tospec projects register through the tospec-dashboard mod.');
877
715
  // Detached child only: `detachDashboard` waits on stderr for exactly this line
878
- // before it reports anything. A foreground run has no parent watching.
879
- if (process.env.TOSPEC_DASHBOARD_PIDFILE) {
716
+ // before it reports anything. A `serve` run by hand has no parent watching.
717
+ if (process.env[DETACHED_ENV]) {
880
718
  // The parent closes its end of this pipe once it reads the line below and
881
- // then exits, so every later stderr write (a config warning on
882
- // /api/activity, a failed --open) hits EPIPE. Unhandled, that error killed
883
- // the background dashboard a few writes in; handled, the writes are dropped.
719
+ // then exits, so every later stderr write hits EPIPE. Unhandled, that error
720
+ // killed the background dashboard a few writes in; handled, the writes are
721
+ // dropped.
884
722
  process.stderr.on('error', () => { });
885
723
  console.error(`${LISTENING_PREFIX}${url}`);
886
724
  }
887
- await registerDashboard(process.pid, port, path.resolve(projectRoot));
888
- // Every dashboard records itself, foreground included: `--detach` can only
889
- // refuse for one that left a record, and a registry marker alone can be
890
- // reaped out from under a live server. The child is handed its parent's path
891
- // so the two cannot disagree about where the file lives.
892
- const pidFile = process.env.TOSPEC_DASHBOARD_PIDFILE ?? pidFilePath(projectRoot);
893
- await writePidRecord(pidFile, {
894
- pid: process.pid,
895
- port,
896
- url,
897
- projectRoot: path.resolve(projectRoot),
898
- });
899
725
  if (opts.open) {
900
726
  openBrowser(url);
901
727
  }
902
- // Normal termination removes this dashboard's own records. Windows hard-kills
903
- // on `--stop`, so there `stopRunningDashboard` clears them instead.
728
+ // Normal termination removes this dashboard's marker. Windows hard-kills on
729
+ // `stop`, so there `stopRunningDashboard` clears it instead.
904
730
  const cleanup = () => {
905
- // SSE keep-alive sockets would otherwise block close() from ever draining.
906
- server.closeAllConnections?.();
907
- // Sync, like clearPidRecordOwnedBy: fired and forgotten, the async
908
- // readdir + rm could lose the race with process.exit, and a marker
909
- // outliving its process reads as alive again once the OS reuses the pid —
910
- // which `--stop` would then kill.
731
+ // Sync: fired and forgotten, the async readdir + rm could lose the race
732
+ // with process.exit, and a marker outliving its process reads as alive
733
+ // again once the OS reuses the pid — which `stop` would then kill.
911
734
  unregisterDashboardSync(process.pid);
912
- clearPidRecordOwnedBy(pidFile, process.pid);
913
- server.close(() => process.exit(0));
735
+ void started.close().then(() => process.exit(0));
914
736
  };
915
737
  process.on('SIGINT', cleanup);
916
738
  process.on('SIGTERM', cleanup);
917
- return server;
739
+ return started;
918
740
  }
919
741
  //# sourceMappingURL=dashboard.js.map