@cluesmith/codev 2.0.0-rc.6 → 2.0.0-rc.60

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 (374) hide show
  1. package/bin/af.js +2 -2
  2. package/bin/consult.js +1 -1
  3. package/bin/porch.js +6 -35
  4. package/dashboard/dist/assets/index-CXloFYpB.css +32 -0
  5. package/dashboard/dist/assets/index-Ca2fjOJf.js +131 -0
  6. package/dashboard/dist/assets/index-Ca2fjOJf.js.map +1 -0
  7. package/dashboard/dist/index.html +14 -0
  8. package/dist/agent-farm/cli.d.ts.map +1 -1
  9. package/dist/agent-farm/cli.js +94 -65
  10. package/dist/agent-farm/cli.js.map +1 -1
  11. package/dist/agent-farm/commands/architect.d.ts.map +1 -1
  12. package/dist/agent-farm/commands/architect.js +13 -6
  13. package/dist/agent-farm/commands/architect.js.map +1 -1
  14. package/dist/agent-farm/commands/attach.d.ts +13 -0
  15. package/dist/agent-farm/commands/attach.d.ts.map +1 -0
  16. package/dist/agent-farm/commands/attach.js +202 -0
  17. package/dist/agent-farm/commands/attach.js.map +1 -0
  18. package/dist/agent-farm/commands/cleanup.d.ts.map +1 -1
  19. package/dist/agent-farm/commands/cleanup.js +30 -3
  20. package/dist/agent-farm/commands/cleanup.js.map +1 -1
  21. package/dist/agent-farm/commands/consult.js +1 -1
  22. package/dist/agent-farm/commands/consult.js.map +1 -1
  23. package/dist/agent-farm/commands/index.d.ts +2 -2
  24. package/dist/agent-farm/commands/index.d.ts.map +1 -1
  25. package/dist/agent-farm/commands/index.js +2 -2
  26. package/dist/agent-farm/commands/index.js.map +1 -1
  27. package/dist/agent-farm/commands/open.d.ts +4 -2
  28. package/dist/agent-farm/commands/open.d.ts.map +1 -1
  29. package/dist/agent-farm/commands/open.js +37 -70
  30. package/dist/agent-farm/commands/open.js.map +1 -1
  31. package/dist/agent-farm/commands/send.d.ts.map +1 -1
  32. package/dist/agent-farm/commands/send.js +55 -17
  33. package/dist/agent-farm/commands/send.js.map +1 -1
  34. package/dist/agent-farm/commands/{util.d.ts → shell.d.ts} +5 -5
  35. package/dist/agent-farm/commands/shell.d.ts.map +1 -0
  36. package/dist/agent-farm/commands/{util.js → shell.js} +23 -36
  37. package/dist/agent-farm/commands/shell.js.map +1 -0
  38. package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
  39. package/dist/agent-farm/commands/spawn.js +503 -226
  40. package/dist/agent-farm/commands/spawn.js.map +1 -1
  41. package/dist/agent-farm/commands/start.d.ts +3 -0
  42. package/dist/agent-farm/commands/start.d.ts.map +1 -1
  43. package/dist/agent-farm/commands/start.js +55 -265
  44. package/dist/agent-farm/commands/start.js.map +1 -1
  45. package/dist/agent-farm/commands/status.d.ts +2 -0
  46. package/dist/agent-farm/commands/status.d.ts.map +1 -1
  47. package/dist/agent-farm/commands/status.js +61 -3
  48. package/dist/agent-farm/commands/status.js.map +1 -1
  49. package/dist/agent-farm/commands/stop.d.ts +6 -0
  50. package/dist/agent-farm/commands/stop.d.ts.map +1 -1
  51. package/dist/agent-farm/commands/stop.js +116 -12
  52. package/dist/agent-farm/commands/stop.js.map +1 -1
  53. package/dist/agent-farm/commands/tower.d.ts +9 -0
  54. package/dist/agent-farm/commands/tower.d.ts.map +1 -1
  55. package/dist/agent-farm/commands/tower.js +59 -19
  56. package/dist/agent-farm/commands/tower.js.map +1 -1
  57. package/dist/agent-farm/db/index.d.ts.map +1 -1
  58. package/dist/agent-farm/db/index.js +124 -0
  59. package/dist/agent-farm/db/index.js.map +1 -1
  60. package/dist/agent-farm/db/schema.d.ts +2 -2
  61. package/dist/agent-farm/db/schema.d.ts.map +1 -1
  62. package/dist/agent-farm/db/schema.js +26 -5
  63. package/dist/agent-farm/db/schema.js.map +1 -1
  64. package/dist/agent-farm/db/types.d.ts +3 -0
  65. package/dist/agent-farm/db/types.d.ts.map +1 -1
  66. package/dist/agent-farm/db/types.js +3 -0
  67. package/dist/agent-farm/db/types.js.map +1 -1
  68. package/dist/agent-farm/hq-connector.d.ts +2 -6
  69. package/dist/agent-farm/hq-connector.d.ts.map +1 -1
  70. package/dist/agent-farm/hq-connector.js +2 -17
  71. package/dist/agent-farm/hq-connector.js.map +1 -1
  72. package/dist/agent-farm/lib/tower-client.d.ts +157 -0
  73. package/dist/agent-farm/lib/tower-client.d.ts.map +1 -0
  74. package/dist/agent-farm/lib/tower-client.js +223 -0
  75. package/dist/agent-farm/lib/tower-client.js.map +1 -0
  76. package/dist/agent-farm/servers/tower-server.js +2137 -112
  77. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  78. package/dist/agent-farm/state.d.ts +4 -10
  79. package/dist/agent-farm/state.d.ts.map +1 -1
  80. package/dist/agent-farm/state.js +30 -31
  81. package/dist/agent-farm/state.js.map +1 -1
  82. package/dist/agent-farm/types.d.ts +48 -1
  83. package/dist/agent-farm/types.d.ts.map +1 -1
  84. package/dist/agent-farm/utils/config.d.ts.map +1 -1
  85. package/dist/agent-farm/utils/config.js +13 -14
  86. package/dist/agent-farm/utils/config.js.map +1 -1
  87. package/dist/agent-farm/utils/deps.d.ts.map +1 -1
  88. package/dist/agent-farm/utils/deps.js +0 -16
  89. package/dist/agent-farm/utils/deps.js.map +1 -1
  90. package/dist/agent-farm/utils/notifications.d.ts +30 -0
  91. package/dist/agent-farm/utils/notifications.d.ts.map +1 -0
  92. package/dist/agent-farm/utils/notifications.js +121 -0
  93. package/dist/agent-farm/utils/notifications.js.map +1 -0
  94. package/dist/agent-farm/utils/port-registry.d.ts +0 -1
  95. package/dist/agent-farm/utils/port-registry.d.ts.map +1 -1
  96. package/dist/agent-farm/utils/port-registry.js +1 -1
  97. package/dist/agent-farm/utils/port-registry.js.map +1 -1
  98. package/dist/agent-farm/utils/server-utils.d.ts +4 -4
  99. package/dist/agent-farm/utils/server-utils.d.ts.map +1 -1
  100. package/dist/agent-farm/utils/server-utils.js +4 -15
  101. package/dist/agent-farm/utils/server-utils.js.map +1 -1
  102. package/dist/agent-farm/utils/shell.d.ts +9 -22
  103. package/dist/agent-farm/utils/shell.d.ts.map +1 -1
  104. package/dist/agent-farm/utils/shell.js +34 -34
  105. package/dist/agent-farm/utils/shell.js.map +1 -1
  106. package/dist/agent-farm/utils/terminal-ports.d.ts +1 -1
  107. package/dist/agent-farm/utils/terminal-ports.js +1 -1
  108. package/dist/cli.d.ts.map +1 -1
  109. package/dist/cli.js +7 -54
  110. package/dist/cli.js.map +1 -1
  111. package/dist/commands/adopt.d.ts.map +1 -1
  112. package/dist/commands/adopt.js +49 -4
  113. package/dist/commands/adopt.js.map +1 -1
  114. package/dist/commands/consult/index.d.ts +1 -0
  115. package/dist/commands/consult/index.d.ts.map +1 -1
  116. package/dist/commands/consult/index.js +85 -6
  117. package/dist/commands/consult/index.js.map +1 -1
  118. package/dist/commands/doctor.d.ts.map +1 -1
  119. package/dist/commands/doctor.js +0 -15
  120. package/dist/commands/doctor.js.map +1 -1
  121. package/dist/commands/init.d.ts.map +1 -1
  122. package/dist/commands/init.js +41 -2
  123. package/dist/commands/init.js.map +1 -1
  124. package/dist/commands/porch/build-counter.d.ts +5 -0
  125. package/dist/commands/porch/build-counter.d.ts.map +1 -0
  126. package/dist/commands/porch/build-counter.js +5 -0
  127. package/dist/commands/porch/build-counter.js.map +1 -0
  128. package/dist/commands/porch/checks.d.ts +17 -29
  129. package/dist/commands/porch/checks.d.ts.map +1 -1
  130. package/dist/commands/porch/checks.js +96 -144
  131. package/dist/commands/porch/checks.js.map +1 -1
  132. package/dist/commands/porch/index.d.ts +21 -43
  133. package/dist/commands/porch/index.d.ts.map +1 -1
  134. package/dist/commands/porch/index.js +418 -1123
  135. package/dist/commands/porch/index.js.map +1 -1
  136. package/dist/commands/porch/next.d.ts +22 -0
  137. package/dist/commands/porch/next.d.ts.map +1 -0
  138. package/dist/commands/porch/next.js +479 -0
  139. package/dist/commands/porch/next.js.map +1 -0
  140. package/dist/commands/porch/plan.d.ts +70 -0
  141. package/dist/commands/porch/plan.d.ts.map +1 -0
  142. package/dist/commands/porch/plan.js +190 -0
  143. package/dist/commands/porch/plan.js.map +1 -0
  144. package/dist/commands/porch/prompts.d.ts +19 -0
  145. package/dist/commands/porch/prompts.d.ts.map +1 -0
  146. package/dist/commands/porch/prompts.js +255 -0
  147. package/dist/commands/porch/prompts.js.map +1 -0
  148. package/dist/commands/porch/protocol.d.ts +59 -0
  149. package/dist/commands/porch/protocol.d.ts.map +1 -0
  150. package/dist/commands/porch/protocol.js +294 -0
  151. package/dist/commands/porch/protocol.js.map +1 -0
  152. package/dist/commands/porch/state.d.ts +23 -112
  153. package/dist/commands/porch/state.d.ts.map +1 -1
  154. package/dist/commands/porch/state.js +81 -699
  155. package/dist/commands/porch/state.js.map +1 -1
  156. package/dist/commands/porch/types.d.ts +99 -164
  157. package/dist/commands/porch/types.d.ts.map +1 -1
  158. package/dist/commands/porch/types.js +2 -1
  159. package/dist/commands/porch/types.js.map +1 -1
  160. package/dist/commands/porch/verdict.d.ts +31 -0
  161. package/dist/commands/porch/verdict.d.ts.map +1 -0
  162. package/dist/commands/porch/verdict.js +59 -0
  163. package/dist/commands/porch/verdict.js.map +1 -0
  164. package/dist/commands/update.d.ts.map +1 -1
  165. package/dist/commands/update.js +31 -0
  166. package/dist/commands/update.js.map +1 -1
  167. package/dist/lib/scaffold.d.ts +37 -0
  168. package/dist/lib/scaffold.d.ts.map +1 -1
  169. package/dist/lib/scaffold.js +114 -0
  170. package/dist/lib/scaffold.js.map +1 -1
  171. package/dist/terminal/index.d.ts +8 -0
  172. package/dist/terminal/index.d.ts.map +1 -0
  173. package/dist/terminal/index.js +5 -0
  174. package/dist/terminal/index.js.map +1 -0
  175. package/dist/terminal/pty-manager.d.ts +60 -0
  176. package/dist/terminal/pty-manager.d.ts.map +1 -0
  177. package/dist/terminal/pty-manager.js +334 -0
  178. package/dist/terminal/pty-manager.js.map +1 -0
  179. package/dist/terminal/pty-session.d.ts +79 -0
  180. package/dist/terminal/pty-session.d.ts.map +1 -0
  181. package/dist/terminal/pty-session.js +215 -0
  182. package/dist/terminal/pty-session.js.map +1 -0
  183. package/dist/terminal/ring-buffer.d.ts +27 -0
  184. package/dist/terminal/ring-buffer.d.ts.map +1 -0
  185. package/dist/terminal/ring-buffer.js +74 -0
  186. package/dist/terminal/ring-buffer.js.map +1 -0
  187. package/dist/terminal/ws-protocol.d.ts +27 -0
  188. package/dist/terminal/ws-protocol.d.ts.map +1 -0
  189. package/dist/terminal/ws-protocol.js +44 -0
  190. package/dist/terminal/ws-protocol.js.map +1 -0
  191. package/package.json +18 -5
  192. package/skeleton/.claude/skills/af/SKILL.md +74 -0
  193. package/skeleton/.claude/skills/codev/SKILL.md +41 -0
  194. package/skeleton/.claude/skills/consult/SKILL.md +81 -0
  195. package/skeleton/.claude/skills/generate-image/SKILL.md +56 -0
  196. package/skeleton/DEPENDENCIES.md +3 -29
  197. package/skeleton/builders.md +1 -1
  198. package/skeleton/porch/prompts/defend.md +1 -1
  199. package/skeleton/porch/prompts/evaluate.md +2 -2
  200. package/skeleton/porch/prompts/implement.md +1 -1
  201. package/skeleton/porch/prompts/plan.md +1 -1
  202. package/skeleton/porch/prompts/review.md +4 -4
  203. package/skeleton/porch/prompts/specify.md +1 -1
  204. package/skeleton/porch/prompts/understand.md +2 -2
  205. package/skeleton/protocol-schema.json +282 -0
  206. package/skeleton/protocols/bugfix/builder-prompt.md +54 -0
  207. package/skeleton/protocols/bugfix/prompts/fix.md +77 -0
  208. package/skeleton/protocols/bugfix/prompts/investigate.md +77 -0
  209. package/skeleton/protocols/bugfix/prompts/pr.md +61 -0
  210. package/skeleton/protocols/bugfix/protocol.json +19 -2
  211. package/skeleton/protocols/experiment/builder-prompt.md +52 -0
  212. package/skeleton/protocols/experiment/protocol.json +101 -0
  213. package/skeleton/protocols/experiment/protocol.md +3 -3
  214. package/skeleton/protocols/experiment/templates/notes.md +1 -1
  215. package/skeleton/protocols/maintain/builder-prompt.md +46 -0
  216. package/skeleton/protocols/maintain/prompts/audit.md +111 -0
  217. package/skeleton/protocols/maintain/prompts/clean.md +91 -0
  218. package/skeleton/protocols/maintain/prompts/sync.md +113 -0
  219. package/skeleton/protocols/maintain/prompts/verify.md +110 -0
  220. package/skeleton/protocols/maintain/protocol.json +141 -0
  221. package/skeleton/protocols/maintain/protocol.md +14 -8
  222. package/skeleton/protocols/protocol-schema.json +54 -1
  223. package/skeleton/protocols/spir/builder-prompt.md +59 -0
  224. package/skeleton/protocols/spir/prompts/implement.md +208 -0
  225. package/skeleton/protocols/{spider → spir}/prompts/plan.md +6 -70
  226. package/skeleton/protocols/{spider → spir}/prompts/review.md +7 -25
  227. package/skeleton/protocols/{spider → spir}/prompts/specify.md +33 -61
  228. package/skeleton/protocols/spir/protocol.json +152 -0
  229. package/skeleton/protocols/{spider → spir}/protocol.md +35 -21
  230. package/skeleton/protocols/{spider → spir}/templates/plan.md +14 -0
  231. package/skeleton/protocols/{spider → spir}/templates/review.md +1 -1
  232. package/skeleton/protocols/tick/builder-prompt.md +56 -0
  233. package/skeleton/protocols/tick/protocol.json +7 -2
  234. package/skeleton/protocols/tick/protocol.md +18 -18
  235. package/skeleton/protocols/tick/templates/review.md +1 -1
  236. package/skeleton/resources/commands/agent-farm.md +25 -43
  237. package/skeleton/resources/commands/overview.md +7 -17
  238. package/skeleton/resources/workflow-reference.md +4 -4
  239. package/skeleton/roles/architect.md +152 -315
  240. package/skeleton/roles/builder.md +109 -218
  241. package/skeleton/templates/AGENTS.md +2 -2
  242. package/skeleton/templates/CLAUDE.md +2 -2
  243. package/skeleton/templates/cheatsheet.md +7 -5
  244. package/skeleton/templates/projectlist.md +1 -1
  245. package/templates/dashboard/index.html +17 -43
  246. package/templates/dashboard/js/dialogs.js +7 -7
  247. package/templates/dashboard/js/files.js +2 -2
  248. package/templates/dashboard/js/main.js +4 -4
  249. package/templates/dashboard/js/projects.js +3 -3
  250. package/templates/dashboard/js/tabs.js +1 -1
  251. package/templates/dashboard/js/utils.js +22 -87
  252. package/templates/open.html +26 -0
  253. package/templates/tower.html +542 -27
  254. package/dist/agent-farm/commands/kickoff.d.ts +0 -19
  255. package/dist/agent-farm/commands/kickoff.d.ts.map +0 -1
  256. package/dist/agent-farm/commands/kickoff.js +0 -331
  257. package/dist/agent-farm/commands/kickoff.js.map +0 -1
  258. package/dist/agent-farm/commands/rename.d.ts +0 -13
  259. package/dist/agent-farm/commands/rename.d.ts.map +0 -1
  260. package/dist/agent-farm/commands/rename.js +0 -33
  261. package/dist/agent-farm/commands/rename.js.map +0 -1
  262. package/dist/agent-farm/commands/tutorial.d.ts +0 -10
  263. package/dist/agent-farm/commands/tutorial.d.ts.map +0 -1
  264. package/dist/agent-farm/commands/tutorial.js +0 -49
  265. package/dist/agent-farm/commands/tutorial.js.map +0 -1
  266. package/dist/agent-farm/commands/util.d.ts.map +0 -1
  267. package/dist/agent-farm/commands/util.js.map +0 -1
  268. package/dist/agent-farm/servers/dashboard-server.d.ts +0 -7
  269. package/dist/agent-farm/servers/dashboard-server.d.ts.map +0 -1
  270. package/dist/agent-farm/servers/dashboard-server.js +0 -1872
  271. package/dist/agent-farm/servers/dashboard-server.js.map +0 -1
  272. package/dist/agent-farm/servers/open-server.d.ts +0 -7
  273. package/dist/agent-farm/servers/open-server.d.ts.map +0 -1
  274. package/dist/agent-farm/servers/open-server.js +0 -315
  275. package/dist/agent-farm/servers/open-server.js.map +0 -1
  276. package/dist/agent-farm/tutorial/index.d.ts +0 -8
  277. package/dist/agent-farm/tutorial/index.d.ts.map +0 -1
  278. package/dist/agent-farm/tutorial/index.js +0 -8
  279. package/dist/agent-farm/tutorial/index.js.map +0 -1
  280. package/dist/agent-farm/tutorial/prompts.d.ts +0 -57
  281. package/dist/agent-farm/tutorial/prompts.d.ts.map +0 -1
  282. package/dist/agent-farm/tutorial/prompts.js +0 -147
  283. package/dist/agent-farm/tutorial/prompts.js.map +0 -1
  284. package/dist/agent-farm/tutorial/runner.d.ts +0 -52
  285. package/dist/agent-farm/tutorial/runner.d.ts.map +0 -1
  286. package/dist/agent-farm/tutorial/runner.js +0 -204
  287. package/dist/agent-farm/tutorial/runner.js.map +0 -1
  288. package/dist/agent-farm/tutorial/state.d.ts +0 -26
  289. package/dist/agent-farm/tutorial/state.d.ts.map +0 -1
  290. package/dist/agent-farm/tutorial/state.js +0 -89
  291. package/dist/agent-farm/tutorial/state.js.map +0 -1
  292. package/dist/agent-farm/tutorial/steps/first-spec.d.ts +0 -7
  293. package/dist/agent-farm/tutorial/steps/first-spec.d.ts.map +0 -1
  294. package/dist/agent-farm/tutorial/steps/first-spec.js +0 -136
  295. package/dist/agent-farm/tutorial/steps/first-spec.js.map +0 -1
  296. package/dist/agent-farm/tutorial/steps/implementation.d.ts +0 -7
  297. package/dist/agent-farm/tutorial/steps/implementation.d.ts.map +0 -1
  298. package/dist/agent-farm/tutorial/steps/implementation.js +0 -76
  299. package/dist/agent-farm/tutorial/steps/implementation.js.map +0 -1
  300. package/dist/agent-farm/tutorial/steps/index.d.ts +0 -10
  301. package/dist/agent-farm/tutorial/steps/index.d.ts.map +0 -1
  302. package/dist/agent-farm/tutorial/steps/index.js +0 -10
  303. package/dist/agent-farm/tutorial/steps/index.js.map +0 -1
  304. package/dist/agent-farm/tutorial/steps/planning.d.ts +0 -7
  305. package/dist/agent-farm/tutorial/steps/planning.d.ts.map +0 -1
  306. package/dist/agent-farm/tutorial/steps/planning.js +0 -143
  307. package/dist/agent-farm/tutorial/steps/planning.js.map +0 -1
  308. package/dist/agent-farm/tutorial/steps/review.d.ts +0 -7
  309. package/dist/agent-farm/tutorial/steps/review.d.ts.map +0 -1
  310. package/dist/agent-farm/tutorial/steps/review.js +0 -78
  311. package/dist/agent-farm/tutorial/steps/review.js.map +0 -1
  312. package/dist/agent-farm/tutorial/steps/setup.d.ts +0 -7
  313. package/dist/agent-farm/tutorial/steps/setup.d.ts.map +0 -1
  314. package/dist/agent-farm/tutorial/steps/setup.js +0 -126
  315. package/dist/agent-farm/tutorial/steps/setup.js.map +0 -1
  316. package/dist/agent-farm/tutorial/steps/welcome.d.ts +0 -7
  317. package/dist/agent-farm/tutorial/steps/welcome.d.ts.map +0 -1
  318. package/dist/agent-farm/tutorial/steps/welcome.js +0 -50
  319. package/dist/agent-farm/tutorial/steps/welcome.js.map +0 -1
  320. package/dist/commands/pcheck/cache.d.ts +0 -48
  321. package/dist/commands/pcheck/cache.d.ts.map +0 -1
  322. package/dist/commands/pcheck/cache.js +0 -170
  323. package/dist/commands/pcheck/cache.js.map +0 -1
  324. package/dist/commands/pcheck/evaluator.d.ts +0 -15
  325. package/dist/commands/pcheck/evaluator.d.ts.map +0 -1
  326. package/dist/commands/pcheck/evaluator.js +0 -246
  327. package/dist/commands/pcheck/evaluator.js.map +0 -1
  328. package/dist/commands/pcheck/index.d.ts +0 -12
  329. package/dist/commands/pcheck/index.d.ts.map +0 -1
  330. package/dist/commands/pcheck/index.js +0 -249
  331. package/dist/commands/pcheck/index.js.map +0 -1
  332. package/dist/commands/pcheck/parser.d.ts +0 -39
  333. package/dist/commands/pcheck/parser.d.ts.map +0 -1
  334. package/dist/commands/pcheck/parser.js +0 -155
  335. package/dist/commands/pcheck/parser.js.map +0 -1
  336. package/dist/commands/pcheck/types.d.ts +0 -82
  337. package/dist/commands/pcheck/types.d.ts.map +0 -1
  338. package/dist/commands/pcheck/types.js +0 -5
  339. package/dist/commands/pcheck/types.js.map +0 -1
  340. package/dist/commands/porch/consultation.d.ts +0 -56
  341. package/dist/commands/porch/consultation.d.ts.map +0 -1
  342. package/dist/commands/porch/consultation.js +0 -330
  343. package/dist/commands/porch/consultation.js.map +0 -1
  344. package/dist/commands/porch/notifications.d.ts +0 -99
  345. package/dist/commands/porch/notifications.d.ts.map +0 -1
  346. package/dist/commands/porch/notifications.js +0 -223
  347. package/dist/commands/porch/notifications.js.map +0 -1
  348. package/dist/commands/porch/plan-parser.d.ts +0 -38
  349. package/dist/commands/porch/plan-parser.d.ts.map +0 -1
  350. package/dist/commands/porch/plan-parser.js +0 -166
  351. package/dist/commands/porch/plan-parser.js.map +0 -1
  352. package/dist/commands/porch/protocol-loader.d.ts +0 -46
  353. package/dist/commands/porch/protocol-loader.d.ts.map +0 -1
  354. package/dist/commands/porch/protocol-loader.js +0 -253
  355. package/dist/commands/porch/protocol-loader.js.map +0 -1
  356. package/dist/commands/porch/signal-parser.d.ts +0 -88
  357. package/dist/commands/porch/signal-parser.d.ts.map +0 -1
  358. package/dist/commands/porch/signal-parser.js +0 -148
  359. package/dist/commands/porch/signal-parser.js.map +0 -1
  360. package/dist/commands/tower.d.ts +0 -16
  361. package/dist/commands/tower.d.ts.map +0 -1
  362. package/dist/commands/tower.js +0 -21
  363. package/dist/commands/tower.js.map +0 -1
  364. package/skeleton/config.json +0 -7
  365. package/skeleton/porch/protocols/bugfix.json +0 -85
  366. package/skeleton/porch/protocols/spider.json +0 -135
  367. package/skeleton/porch/protocols/tick.json +0 -76
  368. package/skeleton/protocols/spider/prompts/defend.md +0 -215
  369. package/skeleton/protocols/spider/prompts/evaluate.md +0 -241
  370. package/skeleton/protocols/spider/prompts/implement.md +0 -149
  371. package/skeleton/protocols/spider/protocol.json +0 -210
  372. package/templates/dashboard/css/activity.css +0 -151
  373. package/templates/dashboard/js/activity.js +0 -112
  374. /package/skeleton/protocols/{spider → spir}/templates/spec.md +0 -0
@@ -7,17 +7,464 @@ import http from 'node:http';
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import net from 'node:net';
10
- import { spawn, execSync } from 'node:child_process';
11
- import { homedir } from 'node:os';
10
+ import crypto from 'node:crypto';
11
+ import { spawn, execSync, spawnSync } from 'node:child_process';
12
+ import { homedir, tmpdir } from 'node:os';
12
13
  import { fileURLToPath } from 'node:url';
13
14
  import { Command } from 'commander';
15
+ import { WebSocketServer, WebSocket } from 'ws';
14
16
  import { getGlobalDb } from '../db/index.js';
15
17
  import { cleanupStaleEntries } from '../utils/port-registry.js';
16
18
  import { parseJsonBody, isRequestAllowed } from '../utils/server-utils.js';
19
+ import { TerminalManager } from '../../terminal/pty-manager.js';
20
+ import { encodeData, encodeControl, decodeFrame } from '../../terminal/ws-protocol.js';
17
21
  const __filename = fileURLToPath(import.meta.url);
18
22
  const __dirname = path.dirname(__filename);
19
23
  // Default port for tower dashboard
20
24
  const DEFAULT_PORT = 4100;
25
+ // Rate limiting for activation requests (Spec 0090 Phase 1)
26
+ // Simple in-memory rate limiter: 10 activations per minute per client
27
+ const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
28
+ const RATE_LIMIT_MAX = 10;
29
+ const activationRateLimits = new Map();
30
+ /**
31
+ * Check if a client has exceeded the rate limit for activations
32
+ * Returns true if rate limit exceeded, false if allowed
33
+ */
34
+ function isRateLimited(clientIp) {
35
+ const now = Date.now();
36
+ const entry = activationRateLimits.get(clientIp);
37
+ if (!entry || now - entry.windowStart >= RATE_LIMIT_WINDOW_MS) {
38
+ // New window
39
+ activationRateLimits.set(clientIp, { count: 1, windowStart: now });
40
+ return false;
41
+ }
42
+ if (entry.count >= RATE_LIMIT_MAX) {
43
+ return true;
44
+ }
45
+ entry.count++;
46
+ return false;
47
+ }
48
+ /**
49
+ * Clean up old rate limit entries periodically
50
+ */
51
+ function cleanupRateLimits() {
52
+ const now = Date.now();
53
+ for (const [ip, entry] of activationRateLimits.entries()) {
54
+ if (now - entry.windowStart >= RATE_LIMIT_WINDOW_MS * 2) {
55
+ activationRateLimits.delete(ip);
56
+ }
57
+ }
58
+ }
59
+ // Cleanup stale rate limit entries every 5 minutes
60
+ setInterval(cleanupRateLimits, 5 * 60 * 1000);
61
+ // ============================================================================
62
+ // PHASE 2 & 4: Terminal Management (Spec 0090)
63
+ // ============================================================================
64
+ // Global TerminalManager instance for tower-managed terminals
65
+ // Uses a temporary directory as projectRoot since terminals can be for any project
66
+ let terminalManager = null;
67
+ const projectTerminals = new Map();
68
+ /**
69
+ * Get or create project terminal registry entry
70
+ */
71
+ function getProjectTerminalsEntry(projectPath) {
72
+ let entry = projectTerminals.get(projectPath);
73
+ if (!entry) {
74
+ entry = { builders: new Map(), shells: new Map(), fileTabs: new Map() };
75
+ projectTerminals.set(projectPath, entry);
76
+ }
77
+ // Migration: ensure fileTabs exists for older entries
78
+ if (!entry.fileTabs) {
79
+ entry.fileTabs = new Map();
80
+ }
81
+ return entry;
82
+ }
83
+ /**
84
+ * Get language identifier for syntax highlighting
85
+ */
86
+ function getLanguageForExt(ext) {
87
+ const langMap = {
88
+ js: 'javascript', ts: 'typescript', jsx: 'javascript', tsx: 'typescript',
89
+ py: 'python', sh: 'bash', bash: 'bash', md: 'markdown',
90
+ html: 'markup', css: 'css', json: 'json', yaml: 'yaml', yml: 'yaml',
91
+ rs: 'rust', go: 'go', java: 'java', c: 'c', cpp: 'cpp', h: 'c',
92
+ };
93
+ return langMap[ext] || ext || 'plaintext';
94
+ }
95
+ /**
96
+ * Get MIME type for file
97
+ */
98
+ function getMimeTypeForFile(filePath) {
99
+ const ext = path.extname(filePath).slice(1).toLowerCase();
100
+ const mimeTypes = {
101
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
102
+ gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
103
+ mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
104
+ pdf: 'application/pdf', txt: 'text/plain',
105
+ };
106
+ return mimeTypes[ext] || 'application/octet-stream';
107
+ }
108
+ /**
109
+ * Generate next shell ID for a project
110
+ */
111
+ function getNextShellId(projectPath) {
112
+ const entry = getProjectTerminalsEntry(projectPath);
113
+ let maxId = 0;
114
+ for (const id of entry.shells.keys()) {
115
+ const num = parseInt(id.replace('shell-', ''), 10);
116
+ if (!isNaN(num) && num > maxId)
117
+ maxId = num;
118
+ }
119
+ return `shell-${maxId + 1}`;
120
+ }
121
+ /**
122
+ * Get or create the global TerminalManager instance
123
+ */
124
+ function getTerminalManager() {
125
+ if (!terminalManager) {
126
+ // Use a neutral projectRoot - terminals specify their own cwd
127
+ const projectRoot = process.env.HOME || '/tmp';
128
+ terminalManager = new TerminalManager({
129
+ projectRoot,
130
+ logDir: path.join(homedir(), '.agent-farm', 'logs'),
131
+ maxSessions: 100,
132
+ ringBufferLines: 1000,
133
+ diskLogEnabled: true,
134
+ diskLogMaxBytes: 50 * 1024 * 1024,
135
+ reconnectTimeoutMs: 300_000,
136
+ });
137
+ }
138
+ return terminalManager;
139
+ }
140
+ /**
141
+ * Normalize a project path to its canonical form for consistent SQLite storage.
142
+ * Uses realpath to resolve symlinks and relative paths.
143
+ */
144
+ function normalizeProjectPath(projectPath) {
145
+ try {
146
+ return fs.realpathSync(projectPath);
147
+ }
148
+ catch {
149
+ // Path doesn't exist yet, normalize without realpath
150
+ return path.resolve(projectPath);
151
+ }
152
+ }
153
+ /**
154
+ * Save a terminal session to SQLite.
155
+ * Guards against race conditions by checking if project is still active.
156
+ */
157
+ function saveTerminalSession(terminalId, projectPath, type, roleId, pid, tmuxSession) {
158
+ try {
159
+ const normalizedPath = normalizeProjectPath(projectPath);
160
+ // Race condition guard: only save if project is still in the active registry
161
+ // This prevents zombie rows when stop races with session creation
162
+ if (!projectTerminals.has(normalizedPath) && !projectTerminals.has(projectPath)) {
163
+ log('INFO', `Skipping session save - project no longer active: ${projectPath}`);
164
+ return;
165
+ }
166
+ const db = getGlobalDb();
167
+ db.prepare(`
168
+ INSERT OR REPLACE INTO terminal_sessions (id, project_path, type, role_id, pid, tmux_session)
169
+ VALUES (?, ?, ?, ?, ?, ?)
170
+ `).run(terminalId, normalizedPath, type, roleId, pid, tmuxSession);
171
+ log('INFO', `Saved terminal session to SQLite: ${terminalId} (${type}) for ${path.basename(normalizedPath)}`);
172
+ }
173
+ catch (err) {
174
+ log('WARN', `Failed to save terminal session: ${err.message}`);
175
+ }
176
+ }
177
+ /**
178
+ * Delete a terminal session from SQLite
179
+ */
180
+ function deleteTerminalSession(terminalId) {
181
+ try {
182
+ const db = getGlobalDb();
183
+ db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(terminalId);
184
+ }
185
+ catch (err) {
186
+ log('WARN', `Failed to delete terminal session: ${err.message}`);
187
+ }
188
+ }
189
+ /**
190
+ * Delete all terminal sessions for a project from SQLite.
191
+ * Normalizes path to ensure consistent cleanup regardless of how path was provided.
192
+ */
193
+ function deleteProjectTerminalSessions(projectPath) {
194
+ try {
195
+ const normalizedPath = normalizeProjectPath(projectPath);
196
+ const db = getGlobalDb();
197
+ // Delete both normalized and raw path to handle any inconsistencies
198
+ db.prepare('DELETE FROM terminal_sessions WHERE project_path = ?').run(normalizedPath);
199
+ if (normalizedPath !== projectPath) {
200
+ db.prepare('DELETE FROM terminal_sessions WHERE project_path = ?').run(projectPath);
201
+ }
202
+ }
203
+ catch (err) {
204
+ log('WARN', `Failed to delete project terminal sessions: ${err.message}`);
205
+ }
206
+ }
207
+ // Whether tmux is available on this system (checked once at startup)
208
+ let tmuxAvailable = false;
209
+ /**
210
+ * Check if tmux is installed and available
211
+ */
212
+ function checkTmux() {
213
+ try {
214
+ execSync('tmux -V', { stdio: 'ignore' });
215
+ return true;
216
+ }
217
+ catch {
218
+ return false;
219
+ }
220
+ }
221
+ /**
222
+ * Create a tmux session with the given command.
223
+ * Returns true if created successfully, false on failure.
224
+ */
225
+ function createTmuxSession(sessionName, command, args, cwd, cols, rows) {
226
+ // Kill any stale session with this name
227
+ if (tmuxSessionExists(sessionName)) {
228
+ killTmuxSession(sessionName);
229
+ }
230
+ try {
231
+ // Use spawnSync with array args to avoid shell injection via project paths
232
+ const tmuxArgs = [
233
+ 'new-session', '-d',
234
+ '-s', sessionName,
235
+ '-c', cwd,
236
+ '-x', String(cols),
237
+ '-y', String(rows),
238
+ command, ...args,
239
+ ];
240
+ const result = spawnSync('tmux', tmuxArgs, { stdio: 'ignore' });
241
+ if (result.status !== 0) {
242
+ log('WARN', `tmux new-session exited with code ${result.status} for "${sessionName}"`);
243
+ return false;
244
+ }
245
+ // Hide tmux status bar (dashboard has its own tabs), enable mouse, and
246
+ // use aggressive-resize so tmux sizes to the largest client (not smallest)
247
+ spawnSync('tmux', ['set-option', '-t', sessionName, 'status', 'off'], { stdio: 'ignore' });
248
+ spawnSync('tmux', ['set-option', '-t', sessionName, 'mouse', 'on'], { stdio: 'ignore' });
249
+ spawnSync('tmux', ['set-option', '-t', sessionName, 'aggressive-resize', 'on'], { stdio: 'ignore' });
250
+ return true;
251
+ }
252
+ catch (err) {
253
+ log('WARN', `Failed to create tmux session "${sessionName}": ${err.message}`);
254
+ return false;
255
+ }
256
+ }
257
+ /**
258
+ * Check if a tmux session exists
259
+ */
260
+ function tmuxSessionExists(sessionName) {
261
+ try {
262
+ execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { stdio: 'ignore' });
263
+ return true;
264
+ }
265
+ catch {
266
+ return false;
267
+ }
268
+ }
269
+ /**
270
+ * Check if a process is running
271
+ */
272
+ function processExists(pid) {
273
+ try {
274
+ process.kill(pid, 0);
275
+ return true;
276
+ }
277
+ catch {
278
+ return false;
279
+ }
280
+ }
281
+ /**
282
+ * Kill a tmux session by name
283
+ */
284
+ function killTmuxSession(sessionName) {
285
+ try {
286
+ execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { stdio: 'ignore' });
287
+ log('INFO', `Killed orphaned tmux session: ${sessionName}`);
288
+ }
289
+ catch {
290
+ // Session may have already died
291
+ }
292
+ }
293
+ /**
294
+ * Reconcile terminal sessions from SQLite against reality on startup.
295
+ *
296
+ * For sessions with surviving tmux sessions: re-attach via new node-pty,
297
+ * register in projectTerminals, and update SQLite with new terminal ID.
298
+ * For dead sessions: clean up SQLite rows and kill orphaned processes.
299
+ */
300
+ async function reconcileTerminalSessions() {
301
+ const db = getGlobalDb();
302
+ let sessions;
303
+ try {
304
+ sessions = db.prepare('SELECT * FROM terminal_sessions').all();
305
+ }
306
+ catch (err) {
307
+ log('WARN', `Failed to read terminal sessions for reconciliation: ${err.message}`);
308
+ return;
309
+ }
310
+ if (sessions.length === 0) {
311
+ log('INFO', 'No terminal sessions to reconcile');
312
+ return;
313
+ }
314
+ log('INFO', `Reconciling ${sessions.length} terminal sessions from previous run...`);
315
+ const manager = getTerminalManager();
316
+ let reconnected = 0;
317
+ let killed = 0;
318
+ let cleaned = 0;
319
+ for (const session of sessions) {
320
+ // Can we reconnect to a surviving tmux session?
321
+ if (session.tmux_session && tmuxAvailable && tmuxSessionExists(session.tmux_session)) {
322
+ try {
323
+ // Create new node-pty that attaches to the surviving tmux session
324
+ const newSession = await manager.createSession({
325
+ command: 'tmux',
326
+ args: ['attach-session', '-t', session.tmux_session],
327
+ cwd: session.project_path,
328
+ label: session.type === 'architect' ? 'Architect' : `${session.type} ${session.role_id || session.id}`,
329
+ });
330
+ // Register in projectTerminals Map
331
+ const entry = getProjectTerminalsEntry(session.project_path);
332
+ if (session.type === 'architect') {
333
+ entry.architect = newSession.id;
334
+ }
335
+ else if (session.type === 'builder') {
336
+ const builderId = session.role_id || session.id;
337
+ entry.builders.set(builderId, newSession.id);
338
+ }
339
+ else if (session.type === 'shell') {
340
+ const shellId = session.role_id || session.id;
341
+ entry.shells.set(shellId, newSession.id);
342
+ }
343
+ // Update SQLite: delete old row, insert new with new terminal ID
344
+ db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(session.id);
345
+ saveTerminalSession(newSession.id, session.project_path, session.type, session.role_id, newSession.pid, session.tmux_session);
346
+ log('INFO', `Reconnected to tmux session "${session.tmux_session}" → terminal ${newSession.id} (${session.type} for ${path.basename(session.project_path)})`);
347
+ reconnected++;
348
+ continue;
349
+ }
350
+ catch (err) {
351
+ log('WARN', `Failed to reconnect to tmux session "${session.tmux_session}": ${err.message}`);
352
+ // Fall through to cleanup
353
+ killTmuxSession(session.tmux_session);
354
+ killed++;
355
+ }
356
+ }
357
+ // No tmux or tmux session dead — check for orphaned processes
358
+ else if (session.tmux_session && tmuxSessionExists(session.tmux_session)) {
359
+ // tmux exists but tmuxAvailable is false (shouldn't happen, but be safe)
360
+ killTmuxSession(session.tmux_session);
361
+ killed++;
362
+ }
363
+ else if (session.pid && processExists(session.pid)) {
364
+ log('INFO', `Found orphaned process: PID ${session.pid} (${session.type} for ${path.basename(session.project_path)})`);
365
+ try {
366
+ process.kill(session.pid, 'SIGTERM');
367
+ killed++;
368
+ }
369
+ catch {
370
+ // Process may not be killable (different user, etc)
371
+ }
372
+ }
373
+ // Clean up the DB row for sessions we couldn't reconnect
374
+ db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(session.id);
375
+ cleaned++;
376
+ }
377
+ log('INFO', `Reconciliation complete: ${reconnected} reconnected, ${killed} orphaned killed, ${cleaned} cleaned up`);
378
+ }
379
+ /**
380
+ * Get terminal sessions from SQLite for a project.
381
+ * Normalizes path for consistent lookup.
382
+ */
383
+ function getTerminalSessionsForProject(projectPath) {
384
+ try {
385
+ const normalizedPath = normalizeProjectPath(projectPath);
386
+ const db = getGlobalDb();
387
+ return db.prepare('SELECT * FROM terminal_sessions WHERE project_path = ?').all(normalizedPath);
388
+ }
389
+ catch {
390
+ return [];
391
+ }
392
+ }
393
+ /**
394
+ * Handle WebSocket connection to a terminal session
395
+ * Uses hybrid binary protocol (Spec 0085):
396
+ * - 0x00 prefix: Control frame (JSON)
397
+ * - 0x01 prefix: Data frame (raw PTY bytes)
398
+ */
399
+ function handleTerminalWebSocket(ws, session, req) {
400
+ const resumeSeq = req.headers['x-session-resume'];
401
+ // Create a client adapter for the PTY session
402
+ // Uses binary protocol for data frames
403
+ const client = {
404
+ send: (data) => {
405
+ if (ws.readyState === WebSocket.OPEN) {
406
+ // Encode as binary data frame (0x01 prefix)
407
+ ws.send(encodeData(data));
408
+ }
409
+ },
410
+ };
411
+ // Attach client to session and get replay data
412
+ let replayLines;
413
+ if (resumeSeq && typeof resumeSeq === 'string') {
414
+ replayLines = session.attachResume(client, parseInt(resumeSeq, 10));
415
+ }
416
+ else {
417
+ replayLines = session.attach(client);
418
+ }
419
+ // Send replay data as binary data frame
420
+ if (replayLines.length > 0) {
421
+ const replayData = replayLines.join('\n');
422
+ if (ws.readyState === WebSocket.OPEN) {
423
+ ws.send(encodeData(replayData));
424
+ }
425
+ }
426
+ // Handle incoming messages from client (binary protocol)
427
+ ws.on('message', (rawData) => {
428
+ try {
429
+ const frame = decodeFrame(Buffer.from(rawData));
430
+ if (frame.type === 'data') {
431
+ // Write raw input to terminal
432
+ session.write(frame.data.toString('utf-8'));
433
+ }
434
+ else if (frame.type === 'control') {
435
+ // Handle control messages
436
+ const msg = frame.message;
437
+ if (msg.type === 'resize') {
438
+ const cols = msg.payload.cols;
439
+ const rows = msg.payload.rows;
440
+ if (typeof cols === 'number' && typeof rows === 'number') {
441
+ session.resize(cols, rows);
442
+ }
443
+ }
444
+ else if (msg.type === 'ping') {
445
+ if (ws.readyState === WebSocket.OPEN) {
446
+ ws.send(encodeControl({ type: 'pong', payload: {} }));
447
+ }
448
+ }
449
+ }
450
+ }
451
+ catch {
452
+ // If decode fails, try treating as raw UTF-8 input (for simpler clients)
453
+ try {
454
+ session.write(rawData.toString('utf-8'));
455
+ }
456
+ catch {
457
+ // Ignore malformed input
458
+ }
459
+ }
460
+ });
461
+ ws.on('close', () => {
462
+ session.detach(client);
463
+ });
464
+ ws.on('error', () => {
465
+ session.detach(client);
466
+ });
467
+ }
21
468
  // Parse arguments with Commander
22
469
  const program = new Command()
23
470
  .name('tower-server')
@@ -52,6 +499,41 @@ function log(level, message) {
52
499
  }
53
500
  }
54
501
  }
502
+ // Global exception handlers to catch uncaught errors
503
+ process.on('uncaughtException', (err) => {
504
+ log('ERROR', `Uncaught exception: ${err.message}\n${err.stack}`);
505
+ process.exit(1);
506
+ });
507
+ process.on('unhandledRejection', (reason) => {
508
+ const message = reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason);
509
+ log('ERROR', `Unhandled rejection: ${message}`);
510
+ process.exit(1);
511
+ });
512
+ // Graceful shutdown handler (Phase 2 - Spec 0090)
513
+ async function gracefulShutdown(signal) {
514
+ log('INFO', `Received ${signal}, starting graceful shutdown...`);
515
+ // 1. Stop accepting new connections
516
+ server?.close();
517
+ // 2. Close all WebSocket connections
518
+ if (terminalWss) {
519
+ for (const client of terminalWss.clients) {
520
+ client.close(1001, 'Server shutting down');
521
+ }
522
+ terminalWss.close();
523
+ }
524
+ // 3. Kill all PTY sessions
525
+ if (terminalManager) {
526
+ log('INFO', 'Shutting down terminal manager...');
527
+ terminalManager.shutdown();
528
+ }
529
+ // 4. Stop cloudflared tunnel if running
530
+ stopTunnel();
531
+ log('INFO', 'Graceful shutdown complete');
532
+ process.exit(0);
533
+ }
534
+ // Catch signals for clean shutdown
535
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
536
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
55
537
  if (isNaN(port) || port < 1 || port > 65535) {
56
538
  log('ERROR', `Invalid port "${portArg}". Must be a number between 1 and 65535.`);
57
539
  process.exit(1);
@@ -97,6 +579,293 @@ async function isPortListening(port) {
97
579
  function getProjectName(projectPath) {
98
580
  return path.basename(projectPath);
99
581
  }
582
+ /**
583
+ * Get the base port for a project from global.db
584
+ * Returns null if project not found or not running
585
+ */
586
+ async function getBasePortForProject(projectPath) {
587
+ try {
588
+ const db = getGlobalDb();
589
+ const row = db.prepare('SELECT base_port FROM port_allocations WHERE project_path = ?').get(projectPath);
590
+ if (!row)
591
+ return null;
592
+ // Check if actually running
593
+ const isRunning = await isPortListening(row.base_port);
594
+ return isRunning ? row.base_port : null;
595
+ }
596
+ catch {
597
+ return null;
598
+ }
599
+ }
600
+ // Cloudflared tunnel management
601
+ let tunnelProcess = null;
602
+ let tunnelUrl = null;
603
+ function isCloudflaredInstalled() {
604
+ try {
605
+ execSync('which cloudflared', { stdio: 'ignore' });
606
+ return true;
607
+ }
608
+ catch {
609
+ return false;
610
+ }
611
+ }
612
+ function getTunnelStatus() {
613
+ return {
614
+ available: isCloudflaredInstalled(),
615
+ running: tunnelProcess !== null && tunnelUrl !== null,
616
+ url: tunnelUrl,
617
+ };
618
+ }
619
+ async function startTunnel(port) {
620
+ if (!isCloudflaredInstalled()) {
621
+ return { success: false, error: 'cloudflared not installed. Install with: brew install cloudflared' };
622
+ }
623
+ if (tunnelProcess) {
624
+ return { success: true, url: tunnelUrl || undefined };
625
+ }
626
+ return new Promise((resolve) => {
627
+ tunnelProcess = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
628
+ stdio: ['ignore', 'pipe', 'pipe'],
629
+ });
630
+ const handleOutput = (data) => {
631
+ const text = data.toString();
632
+ const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
633
+ if (match && !tunnelUrl) {
634
+ tunnelUrl = match[0];
635
+ log('INFO', `Cloudflared tunnel started: ${tunnelUrl}`);
636
+ resolve({ success: true, url: tunnelUrl });
637
+ }
638
+ };
639
+ tunnelProcess.stdout?.on('data', handleOutput);
640
+ tunnelProcess.stderr?.on('data', handleOutput);
641
+ tunnelProcess.on('close', (code) => {
642
+ log('INFO', `Cloudflared tunnel closed with code ${code}`);
643
+ tunnelProcess = null;
644
+ tunnelUrl = null;
645
+ });
646
+ // Timeout after 30 seconds
647
+ setTimeout(() => {
648
+ if (!tunnelUrl) {
649
+ tunnelProcess?.kill();
650
+ tunnelProcess = null;
651
+ resolve({ success: false, error: 'Tunnel startup timed out' });
652
+ }
653
+ }, 30000);
654
+ });
655
+ }
656
+ function stopTunnel() {
657
+ if (tunnelProcess) {
658
+ tunnelProcess.kill();
659
+ tunnelProcess = null;
660
+ tunnelUrl = null;
661
+ log('INFO', 'Cloudflared tunnel stopped');
662
+ }
663
+ return { success: true };
664
+ }
665
+ const sseClients = [];
666
+ let notificationIdCounter = 0;
667
+ /**
668
+ * Broadcast a notification to all connected SSE clients
669
+ */
670
+ function broadcastNotification(notification) {
671
+ const id = ++notificationIdCounter;
672
+ const data = JSON.stringify({ ...notification, id });
673
+ const message = `id: ${id}\ndata: ${data}\n\n`;
674
+ for (const client of sseClients) {
675
+ try {
676
+ client.res.write(message);
677
+ }
678
+ catch {
679
+ // Client disconnected, will be cleaned up on next iteration
680
+ }
681
+ }
682
+ }
683
+ /**
684
+ * Get gate status for a project by querying its dashboard API.
685
+ * Uses timeout to prevent hung projects from stalling tower status.
686
+ */
687
+ async function getGateStatusForProject(basePort) {
688
+ const controller = new AbortController();
689
+ const timeout = setTimeout(() => controller.abort(), 2000); // 2-second timeout
690
+ try {
691
+ const response = await fetch(`http://localhost:${basePort}/api/status`, {
692
+ signal: controller.signal,
693
+ });
694
+ clearTimeout(timeout);
695
+ if (!response.ok)
696
+ return { hasGate: false };
697
+ const projectStatus = await response.json();
698
+ // Check if any builder has a pending gate
699
+ const builderWithGate = projectStatus.builders?.find((b) => b.gateStatus?.waiting || b.status === 'gate-pending');
700
+ if (builderWithGate) {
701
+ return {
702
+ hasGate: true,
703
+ gateName: builderWithGate.gateStatus?.gateName || builderWithGate.currentGate,
704
+ builderId: builderWithGate.id,
705
+ timestamp: builderWithGate.gateStatus?.timestamp || Date.now(),
706
+ };
707
+ }
708
+ }
709
+ catch {
710
+ // Project dashboard not responding or timeout
711
+ }
712
+ return { hasGate: false };
713
+ }
714
+ /**
715
+ * Get terminal list for a project from tower's registry.
716
+ * Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server fetch.
717
+ * Returns architect, builders, and shells with their URLs.
718
+ */
719
+ async function getTerminalsForProject(projectPath, proxyUrl) {
720
+ const manager = getTerminalManager();
721
+ const terminals = [];
722
+ // SQLite is authoritative - query it first (Spec 0090 requirement)
723
+ const dbSessions = getTerminalSessionsForProject(projectPath);
724
+ // Use normalized path for cache consistency
725
+ const normalizedPath = normalizeProjectPath(projectPath);
726
+ // Build a fresh entry from SQLite, then replace atomically to avoid
727
+ // destroying in-memory state that was registered via POST /api/terminals.
728
+ // Previous approach cleared the cache then rebuilt, which lost terminals
729
+ // if their SQLite rows were deleted by external interference (e.g., tests).
730
+ const freshEntry = { builders: new Map(), shells: new Map(), fileTabs: new Map() };
731
+ // Preserve file tabs from existing entry (not stored in SQLite)
732
+ const existingEntry = projectTerminals.get(normalizedPath);
733
+ if (existingEntry) {
734
+ freshEntry.fileTabs = existingEntry.fileTabs;
735
+ }
736
+ for (const dbSession of dbSessions) {
737
+ // Verify session still exists in TerminalManager (runtime state)
738
+ let session = manager.getSession(dbSession.id);
739
+ if (!session && dbSession.tmux_session && tmuxAvailable && tmuxSessionExists(dbSession.tmux_session)) {
740
+ // PTY session gone but tmux session survives — reconnect on-the-fly
741
+ try {
742
+ const newSession = await manager.createSession({
743
+ command: 'tmux',
744
+ args: ['attach-session', '-t', dbSession.tmux_session],
745
+ cwd: dbSession.project_path,
746
+ label: dbSession.type === 'architect' ? 'Architect' : `${dbSession.type} ${dbSession.role_id || dbSession.id}`,
747
+ env: process.env,
748
+ });
749
+ // Update SQLite with new terminal ID
750
+ deleteTerminalSession(dbSession.id);
751
+ saveTerminalSession(newSession.id, dbSession.project_path, dbSession.type, dbSession.role_id, newSession.pid, dbSession.tmux_session);
752
+ dbSession.id = newSession.id;
753
+ session = manager.getSession(newSession.id);
754
+ log('INFO', `Reconnected to tmux "${dbSession.tmux_session}" on-the-fly → ${newSession.id}`);
755
+ }
756
+ catch (err) {
757
+ log('WARN', `Failed to reconnect to tmux "${dbSession.tmux_session}": ${err.message} — will retry on next poll`);
758
+ continue;
759
+ }
760
+ }
761
+ else if (!session) {
762
+ // Stale row in SQLite, no tmux to reconnect — clean it up
763
+ deleteTerminalSession(dbSession.id);
764
+ continue;
765
+ }
766
+ if (dbSession.type === 'architect') {
767
+ freshEntry.architect = dbSession.id;
768
+ terminals.push({
769
+ type: 'architect',
770
+ id: 'architect',
771
+ label: 'Architect',
772
+ url: `${proxyUrl}?tab=architect`,
773
+ active: true,
774
+ });
775
+ }
776
+ else if (dbSession.type === 'builder') {
777
+ const builderId = dbSession.role_id || dbSession.id;
778
+ freshEntry.builders.set(builderId, dbSession.id);
779
+ terminals.push({
780
+ type: 'builder',
781
+ id: builderId,
782
+ label: `Builder ${builderId}`,
783
+ url: `${proxyUrl}?tab=builder-${builderId}`,
784
+ active: true,
785
+ });
786
+ }
787
+ else if (dbSession.type === 'shell') {
788
+ const shellId = dbSession.role_id || dbSession.id;
789
+ freshEntry.shells.set(shellId, dbSession.id);
790
+ terminals.push({
791
+ type: 'shell',
792
+ id: shellId,
793
+ label: `Shell ${shellId.replace('shell-', '')}`,
794
+ url: `${proxyUrl}?tab=shell-${shellId}`,
795
+ active: true,
796
+ });
797
+ }
798
+ }
799
+ // Also merge in-memory entries that may not be in SQLite yet
800
+ // (e.g., registered via POST /api/terminals but SQLite row was lost)
801
+ if (existingEntry) {
802
+ if (existingEntry.architect && !freshEntry.architect) {
803
+ const session = manager.getSession(existingEntry.architect);
804
+ if (session) {
805
+ freshEntry.architect = existingEntry.architect;
806
+ terminals.push({
807
+ type: 'architect',
808
+ id: 'architect',
809
+ label: 'Architect',
810
+ url: `${proxyUrl}?tab=architect`,
811
+ active: true,
812
+ });
813
+ }
814
+ }
815
+ for (const [builderId, terminalId] of existingEntry.builders) {
816
+ if (!freshEntry.builders.has(builderId)) {
817
+ const session = manager.getSession(terminalId);
818
+ if (session) {
819
+ freshEntry.builders.set(builderId, terminalId);
820
+ terminals.push({
821
+ type: 'builder',
822
+ id: builderId,
823
+ label: `Builder ${builderId}`,
824
+ url: `${proxyUrl}?tab=builder-${builderId}`,
825
+ active: true,
826
+ });
827
+ }
828
+ }
829
+ }
830
+ for (const [shellId, terminalId] of existingEntry.shells) {
831
+ if (!freshEntry.shells.has(shellId)) {
832
+ const session = manager.getSession(terminalId);
833
+ if (session) {
834
+ freshEntry.shells.set(shellId, terminalId);
835
+ terminals.push({
836
+ type: 'shell',
837
+ id: shellId,
838
+ label: `Shell ${shellId.replace('shell-', '')}`,
839
+ url: `${proxyUrl}?tab=shell-${shellId}`,
840
+ active: true,
841
+ });
842
+ }
843
+ }
844
+ }
845
+ }
846
+ // Atomically replace the cache entry
847
+ projectTerminals.set(normalizedPath, freshEntry);
848
+ // Gate status - builders don't have gate tracking yet in tower
849
+ // TODO: Add gate status tracking when porch integration is updated
850
+ const gateStatus = { hasGate: false };
851
+ return { terminals, gateStatus };
852
+ }
853
+ // Resolve once at module load: both symlinked and real temp dir paths
854
+ const _tmpDir = tmpdir();
855
+ const _tmpDirResolved = (() => {
856
+ try {
857
+ return fs.realpathSync(_tmpDir);
858
+ }
859
+ catch {
860
+ return _tmpDir;
861
+ }
862
+ })();
863
+ function isTempDirectory(projectPath) {
864
+ return (projectPath.startsWith(_tmpDir + '/') ||
865
+ projectPath.startsWith(_tmpDirResolved + '/') ||
866
+ projectPath.startsWith('/tmp/') ||
867
+ projectPath.startsWith('/private/tmp/'));
868
+ }
100
869
  /**
101
870
  * Get all instances with their status
102
871
  */
@@ -108,25 +877,31 @@ async function getInstances() {
108
877
  if (allocation.project_path.includes('/.builders/')) {
109
878
  continue;
110
879
  }
880
+ // Skip projects in temp directories (e.g. test artifacts) or whose directories no longer exist
881
+ if (!allocation.project_path.startsWith('remote:')) {
882
+ if (!fs.existsSync(allocation.project_path)) {
883
+ continue;
884
+ }
885
+ if (isTempDirectory(allocation.project_path)) {
886
+ continue;
887
+ }
888
+ }
111
889
  const basePort = allocation.base_port;
112
890
  const dashboardPort = basePort;
113
- const architectPort = basePort + 1;
114
- // Check if dashboard is running (main indicator of running instance)
115
- const dashboardActive = await isPortListening(dashboardPort);
116
- // Only check architect port if dashboard is active (to avoid unnecessary probing)
117
- const architectActive = dashboardActive ? await isPortListening(architectPort) : false;
891
+ // Encode project path for proxy URL
892
+ const encodedPath = Buffer.from(allocation.project_path).toString('base64url');
893
+ const proxyUrl = `/project/${encodedPath}/`;
894
+ // Get terminals and gate status from tower's registry
895
+ // Phase 4 (Spec 0090): Tower manages terminals directly - no separate dashboard server
896
+ const { terminals, gateStatus } = await getTerminalsForProject(allocation.project_path, proxyUrl);
897
+ // Project is active if it has any terminals (Phase 4: no port check needed)
898
+ const isActive = terminals.length > 0;
118
899
  const ports = [
119
900
  {
120
901
  type: 'Dashboard',
121
902
  port: dashboardPort,
122
- url: `http://localhost:${dashboardPort}`,
123
- active: dashboardActive,
124
- },
125
- {
126
- type: 'Architect',
127
- port: architectPort,
128
- url: `http://localhost:${architectPort}`,
129
- active: architectActive,
903
+ url: proxyUrl, // Use tower proxy URL, not raw localhost
904
+ active: isActive,
130
905
  },
131
906
  ];
132
907
  instances.push({
@@ -134,11 +909,15 @@ async function getInstances() {
134
909
  projectName: getProjectName(allocation.project_path),
135
910
  basePort,
136
911
  dashboardPort,
137
- architectPort,
912
+ architectPort: basePort + 1, // Legacy field for backward compat
138
913
  registered: allocation.registered_at,
139
914
  lastUsed: allocation.last_used_at,
140
- running: dashboardActive,
915
+ running: isActive,
916
+ proxyUrl, // Tower proxy URL for dashboard
917
+ architectUrl: `${proxyUrl}?tab=architect`, // Direct URL to architect terminal
918
+ terminals, // All available terminals
141
919
  ports,
920
+ gateStatus,
142
921
  });
143
922
  }
144
923
  // Sort: running first, then by last used (most recent first)
@@ -213,8 +992,8 @@ async function getDirectorySuggestions(inputPath) {
213
992
  }
214
993
  /**
215
994
  * Launch a new agent-farm instance
216
- * First stops any stale state, then starts fresh
217
- * Auto-adopts non-codev directories
995
+ * Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server
996
+ * Auto-adopts non-codev directories and creates architect terminal
218
997
  */
219
998
  async function launchInstance(projectPath) {
220
999
  // Clean up stale port allocations before launching (handles machine restarts)
@@ -246,74 +1025,122 @@ async function launchInstance(projectPath) {
246
1025
  return { success: false, error: `Failed to adopt codev: ${err.message}` };
247
1026
  }
248
1027
  }
249
- // Use codev af command (avoids npx cache issues)
250
- // Falls back to npx codev af if codev not in PATH
251
- // SECURITY: Use spawn with cwd option to avoid command injection
252
- // Do NOT use bash -c with string concatenation
1028
+ // Phase 4 (Spec 0090): Tower manages terminals directly
1029
+ // No dashboard-server spawning - tower handles everything
253
1030
  try {
254
- // First, stop any existing (possibly stale) instance
255
- const stopChild = spawn('codev', ['af', 'stop'], {
256
- cwd: projectPath,
257
- stdio: 'ignore',
258
- });
259
- // Wait for stop to complete
260
- await new Promise((resolve) => {
261
- stopChild.on('close', () => resolve());
262
- stopChild.on('error', () => resolve());
263
- // Timeout after 3 seconds
264
- setTimeout(() => resolve(), 3000);
265
- });
266
- // Small delay to ensure cleanup
267
- await new Promise((resolve) => setTimeout(resolve, 500));
268
- // Now start using codev af (avoids npx caching issues)
269
- // Capture output to detect errors
270
- const child = spawn('codev', ['af', 'start'], {
271
- detached: true,
272
- stdio: ['ignore', 'pipe', 'pipe'],
273
- cwd: projectPath,
274
- });
275
- let stdout = '';
276
- let stderr = '';
277
- child.stdout?.on('data', (data) => {
278
- stdout += data.toString();
279
- });
280
- child.stderr?.on('data', (data) => {
281
- stderr += data.toString();
282
- });
283
- // Wait a moment for the process to start (or fail)
284
- await new Promise((resolve) => setTimeout(resolve, 2000));
285
- // Check if the dashboard port is listening
286
- // Resolve symlinks (macOS /tmp -> /private/tmp)
1031
+ // Clear any stale state file
1032
+ const stateFile = path.join(projectPath, '.agent-farm', 'state.json');
1033
+ if (fs.existsSync(stateFile)) {
1034
+ try {
1035
+ fs.unlinkSync(stateFile);
1036
+ }
1037
+ catch {
1038
+ // Ignore - file might not exist or be locked
1039
+ }
1040
+ }
1041
+ // Ensure project has port allocation
287
1042
  const resolvedPath = fs.realpathSync(projectPath);
288
1043
  const db = getGlobalDb();
289
- const allocation = db
1044
+ let allocation = db
290
1045
  .prepare('SELECT base_port FROM port_allocations WHERE project_path = ? OR project_path = ?')
291
1046
  .get(projectPath, resolvedPath);
292
- if (allocation) {
293
- const dashboardPort = allocation.base_port;
294
- const isRunning = await isPortListening(dashboardPort);
295
- if (!isRunning) {
296
- // Process failed to start - try to get error info
297
- const errorInfo = stderr || stdout || 'Unknown error - check codev installation';
298
- child.unref();
299
- return {
300
- success: false,
301
- error: `Failed to start: ${errorInfo.trim().split('\n')[0]}`,
302
- };
303
- }
304
- }
305
- else {
306
- // No allocation found - process might have failed before registering
307
- if (stderr || stdout) {
308
- const errorInfo = stderr || stdout;
309
- child.unref();
310
- return {
311
- success: false,
312
- error: `Failed to start: ${errorInfo.trim().split('\n')[0]}`,
313
- };
314
- }
315
- }
316
- child.unref();
1047
+ if (!allocation) {
1048
+ // Allocate a new port for this project
1049
+ // Find the next available port block (starting at 4200, incrementing by 100)
1050
+ const existingPorts = db
1051
+ .prepare('SELECT base_port FROM port_allocations ORDER BY base_port')
1052
+ .all();
1053
+ let nextPort = 4200;
1054
+ for (const { base_port } of existingPorts) {
1055
+ if (base_port >= nextPort) {
1056
+ nextPort = base_port + 100;
1057
+ }
1058
+ }
1059
+ db.prepare("INSERT INTO port_allocations (project_path, project_name, base_port, created_at) VALUES (?, ?, ?, datetime('now'))").run(resolvedPath, path.basename(projectPath), nextPort);
1060
+ allocation = { base_port: nextPort };
1061
+ log('INFO', `Allocated port ${nextPort} for project: ${projectPath}`);
1062
+ }
1063
+ // Initialize project terminal entry
1064
+ const entry = getProjectTerminalsEntry(resolvedPath);
1065
+ // Create architect terminal if not already present
1066
+ if (!entry.architect) {
1067
+ const manager = getTerminalManager();
1068
+ // Read af-config.json to get the architect command
1069
+ let architectCmd = 'claude';
1070
+ const configPath = path.join(projectPath, 'af-config.json');
1071
+ if (fs.existsSync(configPath)) {
1072
+ try {
1073
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1074
+ if (config.shell?.architect) {
1075
+ architectCmd = config.shell.architect;
1076
+ }
1077
+ }
1078
+ catch {
1079
+ // Ignore config read errors, use default
1080
+ }
1081
+ }
1082
+ try {
1083
+ // Parse command string to separate command and args
1084
+ const cmdParts = architectCmd.split(/\s+/);
1085
+ let cmd = cmdParts[0];
1086
+ let cmdArgs = cmdParts.slice(1);
1087
+ // Wrap in tmux for session persistence across Tower restarts
1088
+ const tmuxName = `architect-${path.basename(projectPath)}`;
1089
+ let activeTmuxSession = null;
1090
+ if (tmuxAvailable) {
1091
+ const tmuxCreated = createTmuxSession(tmuxName, cmd, cmdArgs, projectPath, 200, 50);
1092
+ if (tmuxCreated) {
1093
+ cmd = 'tmux';
1094
+ cmdArgs = ['attach-session', '-t', tmuxName];
1095
+ activeTmuxSession = tmuxName;
1096
+ log('INFO', `Created tmux session "${tmuxName}" for architect`);
1097
+ }
1098
+ }
1099
+ const session = await manager.createSession({
1100
+ command: cmd,
1101
+ args: cmdArgs,
1102
+ cwd: projectPath,
1103
+ label: 'Architect',
1104
+ env: process.env,
1105
+ });
1106
+ entry.architect = session.id;
1107
+ // TICK-001: Save to SQLite for persistence (with tmux session name)
1108
+ saveTerminalSession(session.id, resolvedPath, 'architect', null, session.pid, activeTmuxSession);
1109
+ // Auto-restart architect on exit (restored from pre-Phase 4 dashboard-server.ts)
1110
+ const ptySession = manager.getSession(session.id);
1111
+ if (ptySession) {
1112
+ const startedAt = Date.now();
1113
+ ptySession.on('exit', () => {
1114
+ entry.architect = undefined;
1115
+ deleteTerminalSession(session.id);
1116
+ // Kill stale tmux session so restart can create a fresh one
1117
+ if (activeTmuxSession) {
1118
+ try {
1119
+ execSync(`tmux kill-session -t "${activeTmuxSession}" 2>/dev/null`, { stdio: 'ignore' });
1120
+ }
1121
+ catch { /* already gone */ }
1122
+ }
1123
+ // Only restart if the architect ran for at least 5s (prevents crash loops)
1124
+ const uptime = Date.now() - startedAt;
1125
+ if (uptime < 5000) {
1126
+ log('INFO', `Architect exited after ${uptime}ms for ${projectPath}, not restarting (too short)`);
1127
+ return;
1128
+ }
1129
+ log('INFO', `Architect exited for ${projectPath}, restarting in 2s...`);
1130
+ setTimeout(() => {
1131
+ launchInstance(projectPath).catch((err) => {
1132
+ log('WARN', `Failed to restart architect for ${projectPath}: ${err.message}`);
1133
+ });
1134
+ }, 2000);
1135
+ });
1136
+ }
1137
+ log('INFO', `Created architect terminal for project: ${projectPath}`);
1138
+ }
1139
+ catch (err) {
1140
+ log('WARN', `Failed to create architect terminal: ${err.message}`);
1141
+ // Don't fail the launch - project is still active, just without architect
1142
+ }
1143
+ }
317
1144
  return { success: true, adopted };
318
1145
  }
319
1146
  catch (err) {
@@ -321,40 +1148,69 @@ async function launchInstance(projectPath) {
321
1148
  }
322
1149
  }
323
1150
  /**
324
- * Get PID of process listening on a port
1151
+ * Stop an agent-farm instance by killing all its terminals
1152
+ * Phase 4 (Spec 0090): Tower manages terminals directly
325
1153
  */
326
- function getProcessOnPort(targetPort) {
1154
+ async function stopInstance(projectPath) {
1155
+ const stopped = [];
1156
+ const manager = getTerminalManager();
1157
+ // Resolve symlinks for consistent lookup
1158
+ let resolvedPath = projectPath;
327
1159
  try {
328
- const result = execSync(`lsof -ti :${targetPort} 2>/dev/null`, { encoding: 'utf-8' });
329
- const pid = parseInt(result.trim().split('\n')[0], 10);
330
- return isNaN(pid) ? null : pid;
1160
+ if (fs.existsSync(projectPath)) {
1161
+ resolvedPath = fs.realpathSync(projectPath);
1162
+ }
331
1163
  }
332
1164
  catch {
333
- return null;
1165
+ // Ignore - use original path
334
1166
  }
335
- }
336
- /**
337
- * Stop an agent-farm instance by killing processes on its ports
338
- */
339
- async function stopInstance(basePort) {
340
- const stopped = [];
341
- // Kill processes on the main port range (dashboard, architect, builders)
342
- // Dashboard is basePort, architect is basePort+1, builders start at basePort+100
343
- const portsToCheck = [basePort, basePort + 1];
344
- for (const p of portsToCheck) {
345
- const pid = getProcessOnPort(p);
346
- if (pid) {
347
- try {
348
- process.kill(pid, 'SIGTERM');
349
- stopped.push(p);
1167
+ // Get project terminals
1168
+ const entry = projectTerminals.get(resolvedPath) || projectTerminals.get(projectPath);
1169
+ if (entry) {
1170
+ // Query SQLite for tmux session names BEFORE deleting rows
1171
+ const dbSessions = getTerminalSessionsForProject(resolvedPath);
1172
+ const tmuxSessions = dbSessions
1173
+ .filter(s => s.tmux_session)
1174
+ .map(s => s.tmux_session);
1175
+ // Kill architect
1176
+ if (entry.architect) {
1177
+ const session = manager.getSession(entry.architect);
1178
+ if (session) {
1179
+ manager.killSession(entry.architect);
1180
+ stopped.push(session.pid);
350
1181
  }
351
- catch {
352
- // Process may have already exited
1182
+ }
1183
+ // Kill all shells
1184
+ for (const terminalId of entry.shells.values()) {
1185
+ const session = manager.getSession(terminalId);
1186
+ if (session) {
1187
+ manager.killSession(terminalId);
1188
+ stopped.push(session.pid);
1189
+ }
1190
+ }
1191
+ // Kill all builders
1192
+ for (const terminalId of entry.builders.values()) {
1193
+ const session = manager.getSession(terminalId);
1194
+ if (session) {
1195
+ manager.killSession(terminalId);
1196
+ stopped.push(session.pid);
353
1197
  }
354
1198
  }
1199
+ // Kill tmux sessions (node-pty kill only detaches, tmux keeps running)
1200
+ for (const tmuxName of tmuxSessions) {
1201
+ killTmuxSession(tmuxName);
1202
+ }
1203
+ // Clear project from registry
1204
+ projectTerminals.delete(resolvedPath);
1205
+ projectTerminals.delete(projectPath);
1206
+ // TICK-001: Delete all terminal sessions from SQLite
1207
+ deleteProjectTerminalSessions(resolvedPath);
1208
+ if (resolvedPath !== projectPath) {
1209
+ deleteProjectTerminalSessions(projectPath);
1210
+ }
355
1211
  }
356
1212
  if (stopped.length === 0) {
357
- return { success: true, error: 'No processes found to stop', stopped };
1213
+ return { success: true, error: 'No terminals found to stop', stopped };
358
1214
  }
359
1215
  return { success: true, stopped };
360
1216
  }
@@ -375,6 +1231,54 @@ function findTemplatePath() {
375
1231
  // escapeHtml, parseJsonBody, isRequestAllowed imported from ../utils/server-utils.js
376
1232
  // Find template path
377
1233
  const templatePath = findTemplatePath();
1234
+ // WebSocket server for terminal connections (Phase 2 - Spec 0090)
1235
+ let terminalWss = null;
1236
+ // React dashboard dist path (for serving directly from tower)
1237
+ // React dashboard dist path (for serving directly from tower)
1238
+ // Phase 4 (Spec 0090): Tower serves everything directly, no dashboard-server
1239
+ const reactDashboardPath = path.resolve(__dirname, '../../../dashboard/dist');
1240
+ const hasReactDashboard = fs.existsSync(reactDashboardPath);
1241
+ if (hasReactDashboard) {
1242
+ log('INFO', `React dashboard found at: ${reactDashboardPath}`);
1243
+ }
1244
+ else {
1245
+ log('WARN', 'React dashboard not found - project dashboards will not work');
1246
+ }
1247
+ // MIME types for static file serving
1248
+ const MIME_TYPES = {
1249
+ '.html': 'text/html',
1250
+ '.js': 'application/javascript',
1251
+ '.css': 'text/css',
1252
+ '.json': 'application/json',
1253
+ '.png': 'image/png',
1254
+ '.jpg': 'image/jpeg',
1255
+ '.gif': 'image/gif',
1256
+ '.svg': 'image/svg+xml',
1257
+ '.ico': 'image/x-icon',
1258
+ '.woff': 'font/woff',
1259
+ '.woff2': 'font/woff2',
1260
+ '.ttf': 'font/ttf',
1261
+ '.map': 'application/json',
1262
+ };
1263
+ /**
1264
+ * Serve a static file from the React dashboard dist
1265
+ */
1266
+ function serveStaticFile(filePath, res) {
1267
+ if (!fs.existsSync(filePath)) {
1268
+ return false;
1269
+ }
1270
+ const ext = path.extname(filePath);
1271
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
1272
+ try {
1273
+ const content = fs.readFileSync(filePath);
1274
+ res.writeHead(200, { 'Content-Type': contentType });
1275
+ res.end(content);
1276
+ return true;
1277
+ }
1278
+ catch {
1279
+ return false;
1280
+ }
1281
+ }
378
1282
  // Create server
379
1283
  const server = http.createServer(async (req, res) => {
380
1284
  // Security: Validate Host and Origin headers
@@ -398,13 +1302,320 @@ const server = http.createServer(async (req, res) => {
398
1302
  }
399
1303
  const url = new URL(req.url || '/', `http://localhost:${port}`);
400
1304
  try {
401
- // API: Get status of all instances
1305
+ // =========================================================================
1306
+ // NEW API ENDPOINTS (Spec 0090 - Tower as Single Daemon)
1307
+ // =========================================================================
1308
+ // Health check endpoint (Spec 0090 Phase 1)
1309
+ if (req.method === 'GET' && url.pathname === '/health') {
1310
+ const instances = await getInstances();
1311
+ const activeCount = instances.filter((i) => i.running).length;
1312
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1313
+ res.end(JSON.stringify({
1314
+ status: 'healthy',
1315
+ uptime: process.uptime(),
1316
+ activeProjects: activeCount,
1317
+ totalProjects: instances.length,
1318
+ memoryUsage: process.memoryUsage().heapUsed,
1319
+ timestamp: new Date().toISOString(),
1320
+ }));
1321
+ return;
1322
+ }
1323
+ // API: List all projects (Spec 0090 Phase 1)
1324
+ if (req.method === 'GET' && url.pathname === '/api/projects') {
1325
+ const instances = await getInstances();
1326
+ const projects = instances.map((i) => ({
1327
+ path: i.projectPath,
1328
+ name: i.projectName,
1329
+ basePort: i.basePort,
1330
+ active: i.running,
1331
+ proxyUrl: i.proxyUrl,
1332
+ terminals: i.terminals.length,
1333
+ lastUsed: i.lastUsed,
1334
+ }));
1335
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1336
+ res.end(JSON.stringify({ projects }));
1337
+ return;
1338
+ }
1339
+ // API: Project-specific endpoints (Spec 0090 Phase 1)
1340
+ // Routes: /api/projects/:encodedPath/activate, /deactivate, /status
1341
+ const projectApiMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/(activate|deactivate|status)$/);
1342
+ if (projectApiMatch) {
1343
+ const [, encodedPath, action] = projectApiMatch;
1344
+ let projectPath;
1345
+ try {
1346
+ projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
1347
+ if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
1348
+ throw new Error('Invalid path');
1349
+ }
1350
+ // Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
1351
+ projectPath = normalizeProjectPath(projectPath);
1352
+ }
1353
+ catch {
1354
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1355
+ res.end(JSON.stringify({ error: 'Invalid project path encoding' }));
1356
+ return;
1357
+ }
1358
+ // GET /api/projects/:path/status
1359
+ if (req.method === 'GET' && action === 'status') {
1360
+ const instances = await getInstances();
1361
+ const instance = instances.find((i) => i.projectPath === projectPath);
1362
+ if (!instance) {
1363
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1364
+ res.end(JSON.stringify({ error: 'Project not found' }));
1365
+ return;
1366
+ }
1367
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1368
+ res.end(JSON.stringify({
1369
+ path: instance.projectPath,
1370
+ name: instance.projectName,
1371
+ active: instance.running,
1372
+ basePort: instance.basePort,
1373
+ terminals: instance.terminals,
1374
+ gateStatus: instance.gateStatus,
1375
+ }));
1376
+ return;
1377
+ }
1378
+ // POST /api/projects/:path/activate
1379
+ if (req.method === 'POST' && action === 'activate') {
1380
+ // Rate limiting: 10 activations per minute per client
1381
+ const clientIp = req.socket.remoteAddress || '127.0.0.1';
1382
+ if (isRateLimited(clientIp)) {
1383
+ res.writeHead(429, { 'Content-Type': 'application/json' });
1384
+ res.end(JSON.stringify({ error: 'Too many activations, try again later' }));
1385
+ return;
1386
+ }
1387
+ const result = await launchInstance(projectPath);
1388
+ if (result.success) {
1389
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1390
+ res.end(JSON.stringify({ success: true, adopted: result.adopted }));
1391
+ }
1392
+ else {
1393
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1394
+ res.end(JSON.stringify({ success: false, error: result.error }));
1395
+ }
1396
+ return;
1397
+ }
1398
+ // POST /api/projects/:path/deactivate
1399
+ if (req.method === 'POST' && action === 'deactivate') {
1400
+ // Check if project exists in port allocations
1401
+ const allocations = loadPortAllocations();
1402
+ const resolvedPath = fs.existsSync(projectPath) ? fs.realpathSync(projectPath) : projectPath;
1403
+ const allocation = allocations.find((a) => a.project_path === projectPath || a.project_path === resolvedPath);
1404
+ if (!allocation) {
1405
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1406
+ res.end(JSON.stringify({ ok: false, error: 'Project not found' }));
1407
+ return;
1408
+ }
1409
+ // Phase 4: Stop terminals directly via tower
1410
+ const result = await stopInstance(projectPath);
1411
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1412
+ res.end(JSON.stringify(result));
1413
+ return;
1414
+ }
1415
+ }
1416
+ // =========================================================================
1417
+ // TERMINAL API (Phase 2 - Spec 0090)
1418
+ // =========================================================================
1419
+ // POST /api/terminals - Create a new terminal
1420
+ if (req.method === 'POST' && url.pathname === '/api/terminals') {
1421
+ try {
1422
+ const body = await parseJsonBody(req);
1423
+ const manager = getTerminalManager();
1424
+ // Parse request fields
1425
+ let command = typeof body.command === 'string' ? body.command : undefined;
1426
+ let args = Array.isArray(body.args) ? body.args : undefined;
1427
+ const cols = typeof body.cols === 'number' ? body.cols : undefined;
1428
+ const rows = typeof body.rows === 'number' ? body.rows : undefined;
1429
+ const cwd = typeof body.cwd === 'string' ? body.cwd : undefined;
1430
+ const env = typeof body.env === 'object' && body.env !== null ? body.env : undefined;
1431
+ const label = typeof body.label === 'string' ? body.label : undefined;
1432
+ // Optional tmux wrapping: create tmux session, then node-pty attaches to it
1433
+ const tmuxSession = typeof body.tmuxSession === 'string' ? body.tmuxSession : null;
1434
+ let activeTmuxSession = null;
1435
+ if (tmuxSession && tmuxAvailable && command && cwd) {
1436
+ const tmuxCreated = createTmuxSession(tmuxSession, command, args || [], cwd, cols || 200, rows || 50);
1437
+ if (tmuxCreated) {
1438
+ // Override: node-pty attaches to the tmux session
1439
+ command = 'tmux';
1440
+ args = ['attach-session', '-t', tmuxSession];
1441
+ activeTmuxSession = tmuxSession;
1442
+ log('INFO', `Created tmux session "${tmuxSession}" for terminal`);
1443
+ }
1444
+ // If tmux creation failed, fall through to bare node-pty
1445
+ }
1446
+ let info;
1447
+ try {
1448
+ info = await manager.createSession({ command, args, cols, rows, cwd, env, label });
1449
+ }
1450
+ catch (createErr) {
1451
+ // Clean up orphaned tmux session if node-pty creation failed
1452
+ if (activeTmuxSession) {
1453
+ killTmuxSession(activeTmuxSession);
1454
+ log('WARN', `Cleaned up orphaned tmux session "${activeTmuxSession}" after node-pty failure`);
1455
+ }
1456
+ throw createErr;
1457
+ }
1458
+ // Optional project association: register terminal with project state
1459
+ const projectPath = typeof body.projectPath === 'string' ? body.projectPath : null;
1460
+ const termType = typeof body.type === 'string' && ['builder', 'shell'].includes(body.type) ? body.type : null;
1461
+ const roleId = typeof body.roleId === 'string' ? body.roleId : null;
1462
+ if (projectPath && termType && roleId) {
1463
+ const entry = getProjectTerminalsEntry(normalizeProjectPath(projectPath));
1464
+ if (termType === 'builder') {
1465
+ entry.builders.set(roleId, info.id);
1466
+ }
1467
+ else {
1468
+ entry.shells.set(roleId, info.id);
1469
+ }
1470
+ saveTerminalSession(info.id, projectPath, termType, roleId, info.pid, activeTmuxSession);
1471
+ log('INFO', `Registered terminal ${info.id} as ${termType} "${roleId}" for project ${projectPath}${activeTmuxSession ? ` (tmux: ${activeTmuxSession})` : ''}`);
1472
+ }
1473
+ // Return tmuxSession so caller knows whether tmux is backing this terminal
1474
+ res.writeHead(201, { 'Content-Type': 'application/json' });
1475
+ res.end(JSON.stringify({ ...info, wsPath: `/ws/terminal/${info.id}`, tmuxSession: activeTmuxSession }));
1476
+ }
1477
+ catch (err) {
1478
+ const message = err instanceof Error ? err.message : 'Unknown error';
1479
+ log('ERROR', `Failed to create terminal: ${message}`);
1480
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1481
+ res.end(JSON.stringify({ error: 'INTERNAL_ERROR', message }));
1482
+ }
1483
+ return;
1484
+ }
1485
+ // GET /api/terminals - List all terminals
1486
+ if (req.method === 'GET' && url.pathname === '/api/terminals') {
1487
+ const manager = getTerminalManager();
1488
+ const terminals = manager.listSessions();
1489
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1490
+ res.end(JSON.stringify({ terminals }));
1491
+ return;
1492
+ }
1493
+ // Terminal-specific routes: /api/terminals/:id/*
1494
+ const terminalRouteMatch = url.pathname.match(/^\/api\/terminals\/([^/]+)(\/.*)?$/);
1495
+ if (terminalRouteMatch) {
1496
+ const [, terminalId, subpath] = terminalRouteMatch;
1497
+ const manager = getTerminalManager();
1498
+ // GET /api/terminals/:id - Get terminal info
1499
+ if (req.method === 'GET' && (!subpath || subpath === '')) {
1500
+ const session = manager.getSession(terminalId);
1501
+ if (!session) {
1502
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1503
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1504
+ return;
1505
+ }
1506
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1507
+ res.end(JSON.stringify(session.info));
1508
+ return;
1509
+ }
1510
+ // DELETE /api/terminals/:id - Kill terminal
1511
+ if (req.method === 'DELETE' && (!subpath || subpath === '')) {
1512
+ if (!manager.killSession(terminalId)) {
1513
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1514
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1515
+ return;
1516
+ }
1517
+ // TICK-001: Delete from SQLite
1518
+ deleteTerminalSession(terminalId);
1519
+ res.writeHead(204);
1520
+ res.end();
1521
+ return;
1522
+ }
1523
+ // POST /api/terminals/:id/resize - Resize terminal
1524
+ if (req.method === 'POST' && subpath === '/resize') {
1525
+ try {
1526
+ const body = await parseJsonBody(req);
1527
+ if (typeof body.cols !== 'number' || typeof body.rows !== 'number') {
1528
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1529
+ res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'cols and rows must be numbers' }));
1530
+ return;
1531
+ }
1532
+ const info = manager.resizeSession(terminalId, body.cols, body.rows);
1533
+ if (!info) {
1534
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1535
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1536
+ return;
1537
+ }
1538
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1539
+ res.end(JSON.stringify(info));
1540
+ }
1541
+ catch {
1542
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1543
+ res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'Invalid JSON body' }));
1544
+ }
1545
+ return;
1546
+ }
1547
+ // GET /api/terminals/:id/output - Get terminal output
1548
+ if (req.method === 'GET' && subpath === '/output') {
1549
+ const lines = parseInt(url.searchParams.get('lines') ?? '100', 10);
1550
+ const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
1551
+ const output = manager.getOutput(terminalId, lines, offset);
1552
+ if (!output) {
1553
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1554
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1555
+ return;
1556
+ }
1557
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1558
+ res.end(JSON.stringify(output));
1559
+ return;
1560
+ }
1561
+ }
1562
+ // =========================================================================
1563
+ // EXISTING API ENDPOINTS
1564
+ // =========================================================================
1565
+ // API: Get status of all instances (legacy - kept for backward compat)
402
1566
  if (req.method === 'GET' && url.pathname === '/api/status') {
403
1567
  const instances = await getInstances();
404
1568
  res.writeHead(200, { 'Content-Type': 'application/json' });
405
1569
  res.end(JSON.stringify({ instances }));
406
1570
  return;
407
1571
  }
1572
+ // API: Server-Sent Events for push notifications
1573
+ if (req.method === 'GET' && url.pathname === '/api/events') {
1574
+ const clientId = crypto.randomBytes(8).toString('hex');
1575
+ res.writeHead(200, {
1576
+ 'Content-Type': 'text/event-stream',
1577
+ 'Cache-Control': 'no-cache',
1578
+ Connection: 'keep-alive',
1579
+ });
1580
+ // Send initial connection event
1581
+ res.write(`data: ${JSON.stringify({ type: 'connected', id: clientId })}\n\n`);
1582
+ const client = { res, id: clientId };
1583
+ sseClients.push(client);
1584
+ log('INFO', `SSE client connected: ${clientId} (total: ${sseClients.length})`);
1585
+ // Clean up on disconnect
1586
+ req.on('close', () => {
1587
+ const index = sseClients.findIndex((c) => c.id === clientId);
1588
+ if (index !== -1) {
1589
+ sseClients.splice(index, 1);
1590
+ }
1591
+ log('INFO', `SSE client disconnected: ${clientId} (total: ${sseClients.length})`);
1592
+ });
1593
+ return;
1594
+ }
1595
+ // API: Receive notification from builder
1596
+ if (req.method === 'POST' && url.pathname === '/api/notify') {
1597
+ const body = await parseJsonBody(req);
1598
+ const type = typeof body.type === 'string' ? body.type : 'info';
1599
+ const title = typeof body.title === 'string' ? body.title : '';
1600
+ const messageBody = typeof body.body === 'string' ? body.body : '';
1601
+ const project = typeof body.project === 'string' ? body.project : undefined;
1602
+ if (!title || !messageBody) {
1603
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1604
+ res.end(JSON.stringify({ success: false, error: 'Missing title or body' }));
1605
+ return;
1606
+ }
1607
+ // Broadcast to all connected SSE clients
1608
+ broadcastNotification({
1609
+ type,
1610
+ title,
1611
+ body: messageBody,
1612
+ project,
1613
+ });
1614
+ log('INFO', `Notification broadcast: ${title}`);
1615
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1616
+ res.end(JSON.stringify({ success: true }));
1617
+ return;
1618
+ }
408
1619
  // API: Browse directories for autocomplete
409
1620
  if (req.method === 'GET' && url.pathname === '/api/browse') {
410
1621
  const inputPath = url.searchParams.get('path') || '';
@@ -499,16 +1710,44 @@ const server = http.createServer(async (req, res) => {
499
1710
  res.end(JSON.stringify(result));
500
1711
  return;
501
1712
  }
1713
+ // API: Get tunnel status (cloudflared availability and running tunnel)
1714
+ if (req.method === 'GET' && url.pathname === '/api/tunnel/status') {
1715
+ const status = getTunnelStatus();
1716
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1717
+ res.end(JSON.stringify(status));
1718
+ return;
1719
+ }
1720
+ // API: Start cloudflared tunnel
1721
+ if (req.method === 'POST' && url.pathname === '/api/tunnel/start') {
1722
+ const result = await startTunnel(port);
1723
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1724
+ res.end(JSON.stringify(result));
1725
+ return;
1726
+ }
1727
+ // API: Stop cloudflared tunnel
1728
+ if (req.method === 'POST' && url.pathname === '/api/tunnel/stop') {
1729
+ const result = stopTunnel();
1730
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1731
+ res.end(JSON.stringify(result));
1732
+ return;
1733
+ }
502
1734
  // API: Stop an instance
1735
+ // Phase 4 (Spec 0090): Accept projectPath or basePort for backwards compat
503
1736
  if (req.method === 'POST' && url.pathname === '/api/stop') {
504
1737
  const body = await parseJsonBody(req);
505
- const basePort = body.basePort;
506
- if (!basePort) {
1738
+ let targetPath = body.projectPath;
1739
+ // Backwards compat: if basePort provided, find the project path
1740
+ if (!targetPath && body.basePort) {
1741
+ const allocations = loadPortAllocations();
1742
+ const allocation = allocations.find((a) => a.base_port === body.basePort);
1743
+ targetPath = allocation?.project_path || '';
1744
+ }
1745
+ if (!targetPath) {
507
1746
  res.writeHead(400, { 'Content-Type': 'application/json' });
508
- res.end(JSON.stringify({ success: false, error: 'Missing basePort' }));
1747
+ res.end(JSON.stringify({ success: false, error: 'Missing projectPath or basePort' }));
509
1748
  return;
510
1749
  }
511
- const result = await stopInstance(basePort);
1750
+ const result = await stopInstance(targetPath);
512
1751
  res.writeHead(200, { 'Content-Type': 'application/json' });
513
1752
  res.end(JSON.stringify(result));
514
1753
  return;
@@ -531,6 +1770,714 @@ const server = http.createServer(async (req, res) => {
531
1770
  }
532
1771
  return;
533
1772
  }
1773
+ // Project routes: /project/:base64urlPath/*
1774
+ // Phase 4 (Spec 0090): Tower serves React dashboard and handles APIs directly
1775
+ // Uses Base64URL (RFC 4648) encoding to avoid issues with slashes in paths
1776
+ if (url.pathname.startsWith('/project/')) {
1777
+ const pathParts = url.pathname.split('/');
1778
+ // ['', 'project', base64urlPath, ...rest]
1779
+ const encodedPath = pathParts[2];
1780
+ const subPath = pathParts.slice(3).join('/');
1781
+ if (!encodedPath) {
1782
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
1783
+ res.end('Missing project path');
1784
+ return;
1785
+ }
1786
+ // Decode Base64URL (RFC 4648)
1787
+ let projectPath;
1788
+ try {
1789
+ projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
1790
+ // Support both POSIX (/) and Windows (C:\) paths
1791
+ if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
1792
+ throw new Error('Invalid project path');
1793
+ }
1794
+ // Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
1795
+ projectPath = normalizeProjectPath(projectPath);
1796
+ }
1797
+ catch {
1798
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
1799
+ res.end('Invalid project path encoding');
1800
+ return;
1801
+ }
1802
+ const basePort = await getBasePortForProject(projectPath);
1803
+ // Phase 4 (Spec 0090): Tower handles everything directly
1804
+ const isApiCall = subPath.startsWith('api/') || subPath === 'api';
1805
+ const isWsPath = subPath.startsWith('ws/') || subPath === 'ws';
1806
+ // GET /file?path=<relative-path> — Read project file by path (for StatusPanel project list)
1807
+ if (req.method === 'GET' && subPath === 'file' && url.searchParams.has('path')) {
1808
+ const relPath = url.searchParams.get('path');
1809
+ const fullPath = path.resolve(projectPath, relPath);
1810
+ // Security: ensure resolved path stays within project directory
1811
+ if (!fullPath.startsWith(projectPath + path.sep) && fullPath !== projectPath) {
1812
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
1813
+ res.end('Forbidden');
1814
+ return;
1815
+ }
1816
+ try {
1817
+ const content = fs.readFileSync(fullPath, 'utf-8');
1818
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
1819
+ res.end(content);
1820
+ }
1821
+ catch {
1822
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
1823
+ res.end('Not found');
1824
+ }
1825
+ return;
1826
+ }
1827
+ // Serve React dashboard static files directly if:
1828
+ // 1. Not an API call
1829
+ // 2. Not a WebSocket path
1830
+ // 3. React dashboard is available
1831
+ // 4. Project doesn't need to be running for static files
1832
+ if (!isApiCall && !isWsPath && hasReactDashboard) {
1833
+ // Determine which static file to serve
1834
+ let staticPath;
1835
+ if (!subPath || subPath === '' || subPath === 'index.html') {
1836
+ staticPath = path.join(reactDashboardPath, 'index.html');
1837
+ }
1838
+ else {
1839
+ // Check if it's a static asset
1840
+ staticPath = path.join(reactDashboardPath, subPath);
1841
+ }
1842
+ // Try to serve the static file
1843
+ if (serveStaticFile(staticPath, res)) {
1844
+ return;
1845
+ }
1846
+ // SPA fallback: serve index.html for client-side routing
1847
+ const indexPath = path.join(reactDashboardPath, 'index.html');
1848
+ if (serveStaticFile(indexPath, res)) {
1849
+ return;
1850
+ }
1851
+ }
1852
+ // Phase 4 (Spec 0090): Handle project APIs directly instead of proxying to dashboard-server
1853
+ if (isApiCall) {
1854
+ const apiPath = subPath.replace(/^api\/?/, '');
1855
+ // GET /api/state - Return project state (architect, builders, shells)
1856
+ if (req.method === 'GET' && (apiPath === 'state' || apiPath === '')) {
1857
+ const entry = getProjectTerminalsEntry(projectPath);
1858
+ const manager = getTerminalManager();
1859
+ // Build state response compatible with React dashboard
1860
+ const state = {
1861
+ architect: null,
1862
+ builders: [],
1863
+ utils: [],
1864
+ annotations: [],
1865
+ projectName: path.basename(projectPath),
1866
+ };
1867
+ // Add architect if exists
1868
+ if (entry.architect) {
1869
+ const session = manager.getSession(entry.architect);
1870
+ state.architect = {
1871
+ port: basePort || 0,
1872
+ pid: session?.pid || 0,
1873
+ terminalId: entry.architect,
1874
+ };
1875
+ }
1876
+ // Add shells (skip stale entries whose terminal session is gone or exited)
1877
+ const staleShellIds = [];
1878
+ for (const [shellId, terminalId] of entry.shells) {
1879
+ const session = manager.getSession(terminalId);
1880
+ if (!session || session.status === 'exited') {
1881
+ staleShellIds.push(shellId);
1882
+ continue;
1883
+ }
1884
+ state.utils.push({
1885
+ id: shellId,
1886
+ name: `Shell ${shellId.replace('shell-', '')}`,
1887
+ port: basePort || 0,
1888
+ pid: session?.pid || 0,
1889
+ terminalId,
1890
+ });
1891
+ }
1892
+ for (const id of staleShellIds)
1893
+ entry.shells.delete(id);
1894
+ // Add builders (skip stale entries whose terminal session is gone or exited)
1895
+ const staleBuilderIds = [];
1896
+ for (const [builderId, terminalId] of entry.builders) {
1897
+ const session = manager.getSession(terminalId);
1898
+ if (!session || session.status === 'exited') {
1899
+ staleBuilderIds.push(builderId);
1900
+ continue;
1901
+ }
1902
+ state.builders.push({
1903
+ id: builderId,
1904
+ name: `Builder ${builderId}`,
1905
+ port: basePort || 0,
1906
+ pid: session?.pid || 0,
1907
+ status: 'running',
1908
+ phase: '',
1909
+ worktree: '',
1910
+ branch: '',
1911
+ type: 'spec',
1912
+ terminalId,
1913
+ });
1914
+ }
1915
+ for (const id of staleBuilderIds)
1916
+ entry.builders.delete(id);
1917
+ // Add file tabs (Spec 0092 - served through Tower, no separate ports)
1918
+ for (const [tabId, tab] of entry.fileTabs) {
1919
+ state.annotations.push({
1920
+ id: tabId,
1921
+ file: tab.path,
1922
+ port: 0, // No separate port - served through Tower
1923
+ pid: 0, // No separate process
1924
+ });
1925
+ }
1926
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1927
+ res.end(JSON.stringify(state));
1928
+ return;
1929
+ }
1930
+ // POST /api/tabs/shell - Create a new shell terminal
1931
+ if (req.method === 'POST' && apiPath === 'tabs/shell') {
1932
+ try {
1933
+ const manager = getTerminalManager();
1934
+ const shellId = getNextShellId(projectPath);
1935
+ // Wrap in tmux for session persistence
1936
+ let shellCmd = process.env.SHELL || '/bin/bash';
1937
+ let shellArgs = [];
1938
+ const tmuxName = `shell-${path.basename(projectPath)}-${shellId}`;
1939
+ let activeTmuxSession = null;
1940
+ if (tmuxAvailable) {
1941
+ const tmuxCreated = createTmuxSession(tmuxName, shellCmd, shellArgs, projectPath, 200, 50);
1942
+ if (tmuxCreated) {
1943
+ shellCmd = 'tmux';
1944
+ shellArgs = ['attach-session', '-t', tmuxName];
1945
+ activeTmuxSession = tmuxName;
1946
+ }
1947
+ }
1948
+ // Create terminal session
1949
+ const session = await manager.createSession({
1950
+ command: shellCmd,
1951
+ args: shellArgs,
1952
+ cwd: projectPath,
1953
+ label: `Shell ${shellId.replace('shell-', '')}`,
1954
+ env: process.env,
1955
+ });
1956
+ // Register terminal with project
1957
+ const entry = getProjectTerminalsEntry(projectPath);
1958
+ entry.shells.set(shellId, session.id);
1959
+ // TICK-001: Save to SQLite for persistence
1960
+ saveTerminalSession(session.id, projectPath, 'shell', shellId, session.pid, activeTmuxSession);
1961
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1962
+ res.end(JSON.stringify({
1963
+ id: shellId,
1964
+ port: basePort || 0,
1965
+ name: `Shell ${shellId.replace('shell-', '')}`,
1966
+ terminalId: session.id,
1967
+ }));
1968
+ }
1969
+ catch (err) {
1970
+ log('ERROR', `Failed to create shell: ${err.message}`);
1971
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1972
+ res.end(JSON.stringify({ error: err.message }));
1973
+ }
1974
+ return;
1975
+ }
1976
+ // POST /api/tabs/file - Create a file tab (Spec 0092)
1977
+ if (req.method === 'POST' && apiPath === 'tabs/file') {
1978
+ try {
1979
+ const body = await new Promise((resolve) => {
1980
+ let data = '';
1981
+ req.on('data', (chunk) => data += chunk.toString());
1982
+ req.on('end', () => resolve(data));
1983
+ });
1984
+ const { path: filePath, line } = JSON.parse(body || '{}');
1985
+ if (!filePath || typeof filePath !== 'string') {
1986
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1987
+ res.end(JSON.stringify({ error: 'Missing path parameter' }));
1988
+ return;
1989
+ }
1990
+ // Resolve path relative to project
1991
+ const fullPath = path.isAbsolute(filePath)
1992
+ ? filePath
1993
+ : path.join(projectPath, filePath);
1994
+ // Security: ensure path is within project or is absolute path user provided
1995
+ const normalizedFull = path.normalize(fullPath);
1996
+ const normalizedProject = path.normalize(projectPath);
1997
+ if (!normalizedFull.startsWith(normalizedProject) && !path.isAbsolute(filePath)) {
1998
+ res.writeHead(403, { 'Content-Type': 'application/json' });
1999
+ res.end(JSON.stringify({ error: 'Path outside project' }));
2000
+ return;
2001
+ }
2002
+ // Check file exists
2003
+ if (!fs.existsSync(fullPath)) {
2004
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2005
+ res.end(JSON.stringify({ error: 'File not found' }));
2006
+ return;
2007
+ }
2008
+ const entry = getProjectTerminalsEntry(projectPath);
2009
+ // Check if already open
2010
+ for (const [id, tab] of entry.fileTabs) {
2011
+ if (tab.path === fullPath) {
2012
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2013
+ res.end(JSON.stringify({ id, existing: true, line }));
2014
+ return;
2015
+ }
2016
+ }
2017
+ // Create new file tab
2018
+ const id = `file-${Date.now().toString(36)}`;
2019
+ entry.fileTabs.set(id, { id, path: fullPath, createdAt: Date.now() });
2020
+ log('INFO', `Created file tab: ${id} for ${path.basename(fullPath)}`);
2021
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2022
+ res.end(JSON.stringify({ id, existing: false, line }));
2023
+ }
2024
+ catch (err) {
2025
+ log('ERROR', `Failed to create file tab: ${err.message}`);
2026
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2027
+ res.end(JSON.stringify({ error: err.message }));
2028
+ }
2029
+ return;
2030
+ }
2031
+ // GET /api/file/:id - Get file content as JSON (Spec 0092)
2032
+ const fileGetMatch = apiPath.match(/^file\/([^/]+)$/);
2033
+ if (req.method === 'GET' && fileGetMatch) {
2034
+ const tabId = fileGetMatch[1];
2035
+ const entry = getProjectTerminalsEntry(projectPath);
2036
+ const tab = entry.fileTabs.get(tabId);
2037
+ if (!tab) {
2038
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2039
+ res.end(JSON.stringify({ error: 'File tab not found' }));
2040
+ return;
2041
+ }
2042
+ try {
2043
+ const ext = path.extname(tab.path).slice(1).toLowerCase();
2044
+ const isText = !['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'mp4', 'webm', 'mov', 'pdf'].includes(ext);
2045
+ if (isText) {
2046
+ const content = fs.readFileSync(tab.path, 'utf-8');
2047
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2048
+ res.end(JSON.stringify({
2049
+ path: tab.path,
2050
+ name: path.basename(tab.path),
2051
+ content,
2052
+ language: getLanguageForExt(ext),
2053
+ isMarkdown: ext === 'md',
2054
+ isImage: false,
2055
+ isVideo: false,
2056
+ }));
2057
+ }
2058
+ else {
2059
+ // For binary files, just return metadata
2060
+ const stat = fs.statSync(tab.path);
2061
+ const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext);
2062
+ const isVideo = ['mp4', 'webm', 'mov'].includes(ext);
2063
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2064
+ res.end(JSON.stringify({
2065
+ path: tab.path,
2066
+ name: path.basename(tab.path),
2067
+ content: null,
2068
+ language: ext,
2069
+ isMarkdown: false,
2070
+ isImage,
2071
+ isVideo,
2072
+ size: stat.size,
2073
+ }));
2074
+ }
2075
+ }
2076
+ catch (err) {
2077
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2078
+ res.end(JSON.stringify({ error: err.message }));
2079
+ }
2080
+ return;
2081
+ }
2082
+ // GET /api/file/:id/raw - Get raw file content (for images/video) (Spec 0092)
2083
+ const fileRawMatch = apiPath.match(/^file\/([^/]+)\/raw$/);
2084
+ if (req.method === 'GET' && fileRawMatch) {
2085
+ const tabId = fileRawMatch[1];
2086
+ const entry = getProjectTerminalsEntry(projectPath);
2087
+ const tab = entry.fileTabs.get(tabId);
2088
+ if (!tab) {
2089
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
2090
+ res.end('File tab not found');
2091
+ return;
2092
+ }
2093
+ try {
2094
+ const data = fs.readFileSync(tab.path);
2095
+ const mimeType = getMimeTypeForFile(tab.path);
2096
+ res.writeHead(200, {
2097
+ 'Content-Type': mimeType,
2098
+ 'Content-Length': data.length,
2099
+ 'Cache-Control': 'no-cache',
2100
+ });
2101
+ res.end(data);
2102
+ }
2103
+ catch (err) {
2104
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
2105
+ res.end(err.message);
2106
+ }
2107
+ return;
2108
+ }
2109
+ // POST /api/file/:id/save - Save file content (Spec 0092)
2110
+ const fileSaveMatch = apiPath.match(/^file\/([^/]+)\/save$/);
2111
+ if (req.method === 'POST' && fileSaveMatch) {
2112
+ const tabId = fileSaveMatch[1];
2113
+ const entry = getProjectTerminalsEntry(projectPath);
2114
+ const tab = entry.fileTabs.get(tabId);
2115
+ if (!tab) {
2116
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2117
+ res.end(JSON.stringify({ error: 'File tab not found' }));
2118
+ return;
2119
+ }
2120
+ try {
2121
+ const body = await new Promise((resolve) => {
2122
+ let data = '';
2123
+ req.on('data', (chunk) => data += chunk.toString());
2124
+ req.on('end', () => resolve(data));
2125
+ });
2126
+ const { content } = JSON.parse(body || '{}');
2127
+ if (typeof content !== 'string') {
2128
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2129
+ res.end(JSON.stringify({ error: 'Missing content parameter' }));
2130
+ return;
2131
+ }
2132
+ fs.writeFileSync(tab.path, content, 'utf-8');
2133
+ log('INFO', `Saved file: ${tab.path}`);
2134
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2135
+ res.end(JSON.stringify({ success: true }));
2136
+ }
2137
+ catch (err) {
2138
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2139
+ res.end(JSON.stringify({ error: err.message }));
2140
+ }
2141
+ return;
2142
+ }
2143
+ // DELETE /api/tabs/:id - Delete a terminal or file tab
2144
+ const deleteMatch = apiPath.match(/^tabs\/(.+)$/);
2145
+ if (req.method === 'DELETE' && deleteMatch) {
2146
+ const tabId = deleteMatch[1];
2147
+ const entry = getProjectTerminalsEntry(projectPath);
2148
+ const manager = getTerminalManager();
2149
+ // Check if it's a file tab first (Spec 0092)
2150
+ if (tabId.startsWith('file-')) {
2151
+ if (entry.fileTabs.has(tabId)) {
2152
+ entry.fileTabs.delete(tabId);
2153
+ log('INFO', `Deleted file tab: ${tabId}`);
2154
+ res.writeHead(204);
2155
+ res.end();
2156
+ }
2157
+ else {
2158
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2159
+ res.end(JSON.stringify({ error: 'File tab not found' }));
2160
+ }
2161
+ return;
2162
+ }
2163
+ // Find and delete the terminal
2164
+ let terminalId;
2165
+ if (tabId.startsWith('shell-')) {
2166
+ terminalId = entry.shells.get(tabId);
2167
+ if (terminalId) {
2168
+ entry.shells.delete(tabId);
2169
+ }
2170
+ }
2171
+ else if (tabId.startsWith('builder-')) {
2172
+ terminalId = entry.builders.get(tabId);
2173
+ if (terminalId) {
2174
+ entry.builders.delete(tabId);
2175
+ }
2176
+ }
2177
+ else if (tabId === 'architect') {
2178
+ terminalId = entry.architect;
2179
+ if (terminalId) {
2180
+ entry.architect = undefined;
2181
+ }
2182
+ }
2183
+ if (terminalId) {
2184
+ manager.killSession(terminalId);
2185
+ // TICK-001: Delete from SQLite
2186
+ deleteTerminalSession(terminalId);
2187
+ res.writeHead(204);
2188
+ res.end();
2189
+ }
2190
+ else {
2191
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2192
+ res.end(JSON.stringify({ error: 'Tab not found' }));
2193
+ }
2194
+ return;
2195
+ }
2196
+ // POST /api/stop - Stop all terminals for project
2197
+ if (req.method === 'POST' && apiPath === 'stop') {
2198
+ const entry = getProjectTerminalsEntry(projectPath);
2199
+ const manager = getTerminalManager();
2200
+ // Kill all terminals
2201
+ if (entry.architect) {
2202
+ manager.killSession(entry.architect);
2203
+ }
2204
+ for (const terminalId of entry.shells.values()) {
2205
+ manager.killSession(terminalId);
2206
+ }
2207
+ for (const terminalId of entry.builders.values()) {
2208
+ manager.killSession(terminalId);
2209
+ }
2210
+ // Clear registry
2211
+ projectTerminals.delete(projectPath);
2212
+ // TICK-001: Delete all terminal sessions from SQLite
2213
+ deleteProjectTerminalSessions(projectPath);
2214
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2215
+ res.end(JSON.stringify({ ok: true }));
2216
+ return;
2217
+ }
2218
+ // GET /api/files - Return project directory tree for file browser (Spec 0092)
2219
+ if (req.method === 'GET' && apiPath === 'files') {
2220
+ const maxDepth = parseInt(url.searchParams.get('depth') || '3', 10);
2221
+ const ignore = new Set(['.git', 'node_modules', '.builders', 'dist', '.agent-farm', '.next', '.cache', '__pycache__']);
2222
+ function readTree(dir, depth) {
2223
+ if (depth <= 0)
2224
+ return [];
2225
+ try {
2226
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
2227
+ return entries
2228
+ .filter(e => !e.name.startsWith('.') || e.name === '.env.example')
2229
+ .filter(e => !ignore.has(e.name))
2230
+ .sort((a, b) => {
2231
+ // Directories first, then alphabetical
2232
+ if (a.isDirectory() && !b.isDirectory())
2233
+ return -1;
2234
+ if (!a.isDirectory() && b.isDirectory())
2235
+ return 1;
2236
+ return a.name.localeCompare(b.name);
2237
+ })
2238
+ .map(e => {
2239
+ const fullPath = path.join(dir, e.name);
2240
+ const relativePath = path.relative(projectPath, fullPath);
2241
+ if (e.isDirectory()) {
2242
+ return { name: e.name, path: relativePath, type: 'directory', children: readTree(fullPath, depth - 1) };
2243
+ }
2244
+ return { name: e.name, path: relativePath, type: 'file' };
2245
+ });
2246
+ }
2247
+ catch {
2248
+ return [];
2249
+ }
2250
+ }
2251
+ const tree = readTree(projectPath, maxDepth);
2252
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2253
+ res.end(JSON.stringify(tree));
2254
+ return;
2255
+ }
2256
+ // GET /api/git/status - Return git status for file browser (Spec 0092)
2257
+ if (req.method === 'GET' && apiPath === 'git/status') {
2258
+ try {
2259
+ // Get git status in porcelain format for parsing
2260
+ const result = execSync('git status --porcelain', {
2261
+ cwd: projectPath,
2262
+ encoding: 'utf-8',
2263
+ timeout: 5000,
2264
+ });
2265
+ // Parse porcelain output: XY filename
2266
+ // X = staging area status, Y = working tree status
2267
+ const modified = [];
2268
+ const staged = [];
2269
+ const untracked = [];
2270
+ for (const line of result.split('\n')) {
2271
+ if (!line)
2272
+ continue;
2273
+ const x = line[0]; // staging area
2274
+ const y = line[1]; // working tree
2275
+ const filepath = line.slice(3);
2276
+ if (x === '?' && y === '?') {
2277
+ untracked.push(filepath);
2278
+ }
2279
+ else {
2280
+ if (x !== ' ' && x !== '?') {
2281
+ staged.push(filepath);
2282
+ }
2283
+ if (y !== ' ' && y !== '?') {
2284
+ modified.push(filepath);
2285
+ }
2286
+ }
2287
+ }
2288
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2289
+ res.end(JSON.stringify({ modified, staged, untracked }));
2290
+ }
2291
+ catch (err) {
2292
+ // Not a git repo or git command failed
2293
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2294
+ res.end(JSON.stringify({ modified: [], staged: [], untracked: [], error: err.message }));
2295
+ }
2296
+ return;
2297
+ }
2298
+ // GET /api/files/recent - Return recently opened file tabs (Spec 0092)
2299
+ if (req.method === 'GET' && apiPath === 'files/recent') {
2300
+ const entry = getProjectTerminalsEntry(projectPath);
2301
+ // Get all file tabs sorted by creation time (most recent first)
2302
+ const recentFiles = Array.from(entry.fileTabs.values())
2303
+ .sort((a, b) => b.createdAt - a.createdAt)
2304
+ .slice(0, 10) // Limit to 10 most recent
2305
+ .map(tab => ({
2306
+ id: tab.id,
2307
+ path: tab.path,
2308
+ name: path.basename(tab.path),
2309
+ relativePath: path.relative(projectPath, tab.path),
2310
+ }));
2311
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2312
+ res.end(JSON.stringify(recentFiles));
2313
+ return;
2314
+ }
2315
+ // GET /api/annotate/:tabId/* — Serve rich annotator template and sub-APIs
2316
+ const annotateMatch = apiPath.match(/^annotate\/([^/]+)(\/(.*))?$/);
2317
+ if (annotateMatch) {
2318
+ const tabId = annotateMatch[1];
2319
+ const subRoute = annotateMatch[3] || '';
2320
+ const entry = getProjectTerminalsEntry(projectPath);
2321
+ const tab = entry.fileTabs.get(tabId);
2322
+ if (!tab) {
2323
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
2324
+ res.end('File tab not found');
2325
+ return;
2326
+ }
2327
+ const filePath = tab.path;
2328
+ const ext = path.extname(filePath).slice(1).toLowerCase();
2329
+ const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext);
2330
+ const isVideo = ['mp4', 'webm', 'mov'].includes(ext);
2331
+ const is3D = ['stl', '3mf'].includes(ext);
2332
+ const isPdf = ext === 'pdf';
2333
+ const isMarkdown = ext === 'md';
2334
+ // Sub-route: GET /file — re-read file content from disk
2335
+ if (req.method === 'GET' && subRoute === 'file') {
2336
+ try {
2337
+ const content = fs.readFileSync(filePath, 'utf-8');
2338
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
2339
+ res.end(content);
2340
+ }
2341
+ catch (err) {
2342
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
2343
+ res.end(err.message);
2344
+ }
2345
+ return;
2346
+ }
2347
+ // Sub-route: POST /save — save file content
2348
+ if (req.method === 'POST' && subRoute === 'save') {
2349
+ try {
2350
+ const body = await new Promise((resolve) => {
2351
+ let data = '';
2352
+ req.on('data', (chunk) => data += chunk.toString());
2353
+ req.on('end', () => resolve(data));
2354
+ });
2355
+ const parsed = JSON.parse(body || '{}');
2356
+ const fileContent = parsed.content;
2357
+ if (typeof fileContent !== 'string') {
2358
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
2359
+ res.end('Missing content');
2360
+ return;
2361
+ }
2362
+ fs.writeFileSync(filePath, fileContent, 'utf-8');
2363
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2364
+ res.end(JSON.stringify({ ok: true }));
2365
+ }
2366
+ catch (err) {
2367
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
2368
+ res.end(err.message);
2369
+ }
2370
+ return;
2371
+ }
2372
+ // Sub-route: GET /api/mtime — file modification time
2373
+ if (req.method === 'GET' && subRoute === 'api/mtime') {
2374
+ try {
2375
+ const stat = fs.statSync(filePath);
2376
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2377
+ res.end(JSON.stringify({ mtime: stat.mtimeMs }));
2378
+ }
2379
+ catch (err) {
2380
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
2381
+ res.end(err.message);
2382
+ }
2383
+ return;
2384
+ }
2385
+ // Sub-route: GET /api/image, /api/video, /api/model, /api/pdf — raw binary content
2386
+ if (req.method === 'GET' && (subRoute === 'api/image' || subRoute === 'api/video' || subRoute === 'api/model' || subRoute === 'api/pdf')) {
2387
+ try {
2388
+ const data = fs.readFileSync(filePath);
2389
+ const mimeType = getMimeTypeForFile(filePath);
2390
+ res.writeHead(200, {
2391
+ 'Content-Type': mimeType,
2392
+ 'Content-Length': data.length,
2393
+ 'Cache-Control': 'no-cache',
2394
+ });
2395
+ res.end(data);
2396
+ }
2397
+ catch (err) {
2398
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
2399
+ res.end(err.message);
2400
+ }
2401
+ return;
2402
+ }
2403
+ // Default: serve the annotator HTML template
2404
+ if (req.method === 'GET' && (subRoute === '' || subRoute === undefined)) {
2405
+ try {
2406
+ const templateFile = is3D ? '3d-viewer.html' : 'open.html';
2407
+ const tplPath = path.resolve(__dirname, `../../../templates/${templateFile}`);
2408
+ let html = fs.readFileSync(tplPath, 'utf-8');
2409
+ const fileName = path.basename(filePath);
2410
+ const fileSize = fs.statSync(filePath).size;
2411
+ if (is3D) {
2412
+ html = html.replace(/\{\{FILE\}\}/g, fileName);
2413
+ html = html.replace(/\{\{FILE_PATH_JSON\}\}/g, JSON.stringify(filePath));
2414
+ html = html.replace(/\{\{FORMAT\}\}/g, ext);
2415
+ }
2416
+ else {
2417
+ html = html.replace(/\{\{FILE\}\}/g, fileName);
2418
+ html = html.replace(/\{\{FILE_PATH\}\}/g, filePath);
2419
+ html = html.replace(/\{\{BUILDER_ID\}\}/g, '');
2420
+ html = html.replace(/\{\{LANG\}\}/g, getLanguageForExt(ext));
2421
+ html = html.replace(/\{\{IS_MARKDOWN\}\}/g, String(isMarkdown));
2422
+ html = html.replace(/\{\{IS_IMAGE\}\}/g, String(isImage));
2423
+ html = html.replace(/\{\{IS_VIDEO\}\}/g, String(isVideo));
2424
+ html = html.replace(/\{\{IS_PDF\}\}/g, String(isPdf));
2425
+ html = html.replace(/\{\{FILE_SIZE\}\}/g, String(fileSize));
2426
+ // Inject initialization script (template loads content via fetch)
2427
+ let initScript;
2428
+ if (isImage) {
2429
+ initScript = `initImage(${fileSize});`;
2430
+ }
2431
+ else if (isVideo) {
2432
+ initScript = `initVideo(${fileSize});`;
2433
+ }
2434
+ else if (isPdf) {
2435
+ initScript = `initPdf(${fileSize});`;
2436
+ }
2437
+ else {
2438
+ initScript = `fetch('file').then(r=>r.text()).then(init);`;
2439
+ }
2440
+ html = html.replace('// FILE_CONTENT will be injected by the server', initScript);
2441
+ }
2442
+ // Handle ?line= query param for scroll-to-line
2443
+ const lineParam = url.searchParams.get('line');
2444
+ if (lineParam) {
2445
+ const scrollScript = `<script>window.addEventListener('load',()=>{setTimeout(()=>{const el=document.querySelector('[data-line="${lineParam}"]');if(el){el.scrollIntoView({block:'center'});el.classList.add('highlighted-line');}},200);})</script>`;
2446
+ html = html.replace('</body>', `${scrollScript}</body>`);
2447
+ }
2448
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
2449
+ res.end(html);
2450
+ }
2451
+ catch (err) {
2452
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
2453
+ res.end(`Failed to serve annotator: ${err.message}`);
2454
+ }
2455
+ return;
2456
+ }
2457
+ }
2458
+ // Unhandled API route
2459
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2460
+ res.end(JSON.stringify({ error: 'API endpoint not found', path: apiPath }));
2461
+ return;
2462
+ }
2463
+ // For WebSocket paths, let the upgrade handler deal with it
2464
+ if (isWsPath) {
2465
+ // WebSocket paths are handled by the upgrade handler
2466
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
2467
+ res.end('WebSocket connections should use ws:// protocol');
2468
+ return;
2469
+ }
2470
+ // If we get here for non-API, non-WS paths and React dashboard is not available
2471
+ if (!hasReactDashboard) {
2472
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
2473
+ res.end('Dashboard not available');
2474
+ return;
2475
+ }
2476
+ // Fallback for unmatched paths
2477
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
2478
+ res.end('Not found');
2479
+ return;
2480
+ }
534
2481
  // 404 for everything else
535
2482
  res.writeHead(404, { 'Content-Type': 'text/plain' });
536
2483
  res.end('Not found');
@@ -542,8 +2489,86 @@ const server = http.createServer(async (req, res) => {
542
2489
  }
543
2490
  });
544
2491
  // SECURITY: Bind to localhost only to prevent network exposure
545
- server.listen(port, '127.0.0.1', () => {
2492
+ server.listen(port, '127.0.0.1', async () => {
546
2493
  log('INFO', `Tower server listening at http://localhost:${port}`);
2494
+ // Check tmux availability once at startup
2495
+ tmuxAvailable = checkTmux();
2496
+ log('INFO', `tmux available: ${tmuxAvailable}${tmuxAvailable ? '' : ' (terminals will not persist across restarts)'}`);
2497
+ // TICK-001: Reconcile terminal sessions from previous run
2498
+ await reconcileTerminalSessions();
2499
+ });
2500
+ // Initialize terminal WebSocket server (Phase 2 - Spec 0090)
2501
+ terminalWss = new WebSocketServer({ noServer: true });
2502
+ // WebSocket upgrade handler for terminal connections and proxying
2503
+ server.on('upgrade', async (req, socket, head) => {
2504
+ const reqUrl = new URL(req.url || '/', `http://localhost:${port}`);
2505
+ // Phase 2: Handle /ws/terminal/:id routes directly
2506
+ const terminalMatch = reqUrl.pathname.match(/^\/ws\/terminal\/([^/]+)$/);
2507
+ if (terminalMatch) {
2508
+ const terminalId = terminalMatch[1];
2509
+ const manager = getTerminalManager();
2510
+ const session = manager.getSession(terminalId);
2511
+ if (!session) {
2512
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
2513
+ socket.destroy();
2514
+ return;
2515
+ }
2516
+ terminalWss.handleUpgrade(req, socket, head, (ws) => {
2517
+ handleTerminalWebSocket(ws, session, req);
2518
+ });
2519
+ return;
2520
+ }
2521
+ // Phase 4 (Spec 0090): Handle project WebSocket routes directly
2522
+ // Route: /project/:encodedPath/ws/terminal/:terminalId
2523
+ if (!reqUrl.pathname.startsWith('/project/')) {
2524
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
2525
+ socket.destroy();
2526
+ return;
2527
+ }
2528
+ const pathParts = reqUrl.pathname.split('/');
2529
+ // ['', 'project', base64urlPath, 'ws', 'terminal', terminalId]
2530
+ const encodedPath = pathParts[2];
2531
+ if (!encodedPath) {
2532
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
2533
+ socket.destroy();
2534
+ return;
2535
+ }
2536
+ // Decode Base64URL (RFC 4648) - NOT URL encoding
2537
+ // Wrap in try/catch to handle malformed Base64 input gracefully
2538
+ let projectPath;
2539
+ try {
2540
+ projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
2541
+ // Support both POSIX (/) and Windows (C:\) paths
2542
+ if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
2543
+ throw new Error('Invalid project path');
2544
+ }
2545
+ // Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
2546
+ projectPath = normalizeProjectPath(projectPath);
2547
+ }
2548
+ catch {
2549
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
2550
+ socket.destroy();
2551
+ return;
2552
+ }
2553
+ // Check for terminal WebSocket route: /project/:path/ws/terminal/:id
2554
+ const wsMatch = reqUrl.pathname.match(/^\/project\/[^/]+\/ws\/terminal\/([^/]+)$/);
2555
+ if (wsMatch) {
2556
+ const terminalId = wsMatch[1];
2557
+ const manager = getTerminalManager();
2558
+ const session = manager.getSession(terminalId);
2559
+ if (!session) {
2560
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
2561
+ socket.destroy();
2562
+ return;
2563
+ }
2564
+ terminalWss.handleUpgrade(req, socket, head, (ws) => {
2565
+ handleTerminalWebSocket(ws, session, req);
2566
+ });
2567
+ return;
2568
+ }
2569
+ // Unhandled WebSocket route
2570
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
2571
+ socket.destroy();
547
2572
  });
548
2573
  // Handle uncaught errors
549
2574
  process.on('uncaughtException', (err) => {