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

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 (377) 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 +34 -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/shell.d.ts +15 -0
  35. package/dist/agent-farm/commands/shell.d.ts.map +1 -0
  36. package/dist/agent-farm/commands/shell.js +61 -0
  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 +58 -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 +2340 -109
  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 +9 -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 +2 -0
  115. package/dist/commands/consult/index.d.ts.map +1 -1
  116. package/dist/commands/consult/index.js +103 -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 +481 -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/consult-types/impl-review.md +9 -0
  199. package/skeleton/porch/prompts/defend.md +1 -1
  200. package/skeleton/porch/prompts/evaluate.md +2 -2
  201. package/skeleton/porch/prompts/implement.md +1 -1
  202. package/skeleton/porch/prompts/plan.md +1 -1
  203. package/skeleton/porch/prompts/review.md +4 -4
  204. package/skeleton/porch/prompts/specify.md +1 -1
  205. package/skeleton/porch/prompts/understand.md +2 -2
  206. package/skeleton/protocol-schema.json +282 -0
  207. package/skeleton/protocols/bugfix/builder-prompt.md +54 -0
  208. package/skeleton/protocols/bugfix/prompts/fix.md +77 -0
  209. package/skeleton/protocols/bugfix/prompts/investigate.md +77 -0
  210. package/skeleton/protocols/bugfix/prompts/pr.md +61 -0
  211. package/skeleton/protocols/bugfix/protocol.json +19 -2
  212. package/skeleton/protocols/experiment/builder-prompt.md +52 -0
  213. package/skeleton/protocols/experiment/protocol.json +101 -0
  214. package/skeleton/protocols/experiment/protocol.md +3 -3
  215. package/skeleton/protocols/experiment/templates/notes.md +1 -1
  216. package/skeleton/protocols/maintain/builder-prompt.md +46 -0
  217. package/skeleton/protocols/maintain/prompts/audit.md +111 -0
  218. package/skeleton/protocols/maintain/prompts/clean.md +91 -0
  219. package/skeleton/protocols/maintain/prompts/sync.md +113 -0
  220. package/skeleton/protocols/maintain/prompts/verify.md +110 -0
  221. package/skeleton/protocols/maintain/protocol.json +141 -0
  222. package/skeleton/protocols/maintain/protocol.md +14 -8
  223. package/skeleton/protocols/protocol-schema.json +54 -1
  224. package/skeleton/protocols/spir/builder-prompt.md +59 -0
  225. package/skeleton/protocols/spir/prompts/implement.md +208 -0
  226. package/skeleton/protocols/{spider → spir}/prompts/plan.md +6 -70
  227. package/skeleton/protocols/{spider → spir}/prompts/review.md +7 -25
  228. package/skeleton/protocols/{spider → spir}/prompts/specify.md +33 -61
  229. package/skeleton/protocols/spir/protocol.json +152 -0
  230. package/skeleton/protocols/{spider → spir}/protocol.md +35 -21
  231. package/skeleton/protocols/{spider → spir}/templates/plan.md +14 -0
  232. package/skeleton/protocols/{spider → spir}/templates/review.md +1 -1
  233. package/skeleton/protocols/tick/builder-prompt.md +56 -0
  234. package/skeleton/protocols/tick/protocol.json +7 -2
  235. package/skeleton/protocols/tick/protocol.md +18 -18
  236. package/skeleton/protocols/tick/templates/review.md +1 -1
  237. package/skeleton/resources/commands/agent-farm.md +25 -43
  238. package/skeleton/resources/commands/overview.md +7 -17
  239. package/skeleton/resources/workflow-reference.md +4 -4
  240. package/skeleton/roles/architect.md +152 -315
  241. package/skeleton/roles/builder.md +109 -218
  242. package/skeleton/templates/AGENTS.md +2 -2
  243. package/skeleton/templates/CLAUDE.md +2 -2
  244. package/skeleton/templates/cheatsheet.md +7 -5
  245. package/skeleton/templates/projectlist.md +1 -1
  246. package/templates/dashboard/index.html +17 -43
  247. package/templates/dashboard/js/dialogs.js +7 -7
  248. package/templates/dashboard/js/files.js +2 -2
  249. package/templates/dashboard/js/main.js +4 -4
  250. package/templates/dashboard/js/projects.js +3 -3
  251. package/templates/dashboard/js/tabs.js +1 -1
  252. package/templates/dashboard/js/utils.js +22 -87
  253. package/templates/open.html +26 -0
  254. package/templates/tower.html +542 -27
  255. package/dist/agent-farm/commands/kickoff.d.ts +0 -19
  256. package/dist/agent-farm/commands/kickoff.d.ts.map +0 -1
  257. package/dist/agent-farm/commands/kickoff.js +0 -331
  258. package/dist/agent-farm/commands/kickoff.js.map +0 -1
  259. package/dist/agent-farm/commands/rename.d.ts +0 -13
  260. package/dist/agent-farm/commands/rename.d.ts.map +0 -1
  261. package/dist/agent-farm/commands/rename.js +0 -33
  262. package/dist/agent-farm/commands/rename.js.map +0 -1
  263. package/dist/agent-farm/commands/tutorial.d.ts +0 -10
  264. package/dist/agent-farm/commands/tutorial.d.ts.map +0 -1
  265. package/dist/agent-farm/commands/tutorial.js +0 -49
  266. package/dist/agent-farm/commands/tutorial.js.map +0 -1
  267. package/dist/agent-farm/commands/util.d.ts +0 -15
  268. package/dist/agent-farm/commands/util.d.ts.map +0 -1
  269. package/dist/agent-farm/commands/util.js +0 -108
  270. package/dist/agent-farm/commands/util.js.map +0 -1
  271. package/dist/agent-farm/servers/dashboard-server.d.ts +0 -7
  272. package/dist/agent-farm/servers/dashboard-server.d.ts.map +0 -1
  273. package/dist/agent-farm/servers/dashboard-server.js +0 -1872
  274. package/dist/agent-farm/servers/dashboard-server.js.map +0 -1
  275. package/dist/agent-farm/servers/open-server.d.ts +0 -7
  276. package/dist/agent-farm/servers/open-server.d.ts.map +0 -1
  277. package/dist/agent-farm/servers/open-server.js +0 -315
  278. package/dist/agent-farm/servers/open-server.js.map +0 -1
  279. package/dist/agent-farm/tutorial/index.d.ts +0 -8
  280. package/dist/agent-farm/tutorial/index.d.ts.map +0 -1
  281. package/dist/agent-farm/tutorial/index.js +0 -8
  282. package/dist/agent-farm/tutorial/index.js.map +0 -1
  283. package/dist/agent-farm/tutorial/prompts.d.ts +0 -57
  284. package/dist/agent-farm/tutorial/prompts.d.ts.map +0 -1
  285. package/dist/agent-farm/tutorial/prompts.js +0 -147
  286. package/dist/agent-farm/tutorial/prompts.js.map +0 -1
  287. package/dist/agent-farm/tutorial/runner.d.ts +0 -52
  288. package/dist/agent-farm/tutorial/runner.d.ts.map +0 -1
  289. package/dist/agent-farm/tutorial/runner.js +0 -204
  290. package/dist/agent-farm/tutorial/runner.js.map +0 -1
  291. package/dist/agent-farm/tutorial/state.d.ts +0 -26
  292. package/dist/agent-farm/tutorial/state.d.ts.map +0 -1
  293. package/dist/agent-farm/tutorial/state.js +0 -89
  294. package/dist/agent-farm/tutorial/state.js.map +0 -1
  295. package/dist/agent-farm/tutorial/steps/first-spec.d.ts +0 -7
  296. package/dist/agent-farm/tutorial/steps/first-spec.d.ts.map +0 -1
  297. package/dist/agent-farm/tutorial/steps/first-spec.js +0 -136
  298. package/dist/agent-farm/tutorial/steps/first-spec.js.map +0 -1
  299. package/dist/agent-farm/tutorial/steps/implementation.d.ts +0 -7
  300. package/dist/agent-farm/tutorial/steps/implementation.d.ts.map +0 -1
  301. package/dist/agent-farm/tutorial/steps/implementation.js +0 -76
  302. package/dist/agent-farm/tutorial/steps/implementation.js.map +0 -1
  303. package/dist/agent-farm/tutorial/steps/index.d.ts +0 -10
  304. package/dist/agent-farm/tutorial/steps/index.d.ts.map +0 -1
  305. package/dist/agent-farm/tutorial/steps/index.js +0 -10
  306. package/dist/agent-farm/tutorial/steps/index.js.map +0 -1
  307. package/dist/agent-farm/tutorial/steps/planning.d.ts +0 -7
  308. package/dist/agent-farm/tutorial/steps/planning.d.ts.map +0 -1
  309. package/dist/agent-farm/tutorial/steps/planning.js +0 -143
  310. package/dist/agent-farm/tutorial/steps/planning.js.map +0 -1
  311. package/dist/agent-farm/tutorial/steps/review.d.ts +0 -7
  312. package/dist/agent-farm/tutorial/steps/review.d.ts.map +0 -1
  313. package/dist/agent-farm/tutorial/steps/review.js +0 -78
  314. package/dist/agent-farm/tutorial/steps/review.js.map +0 -1
  315. package/dist/agent-farm/tutorial/steps/setup.d.ts +0 -7
  316. package/dist/agent-farm/tutorial/steps/setup.d.ts.map +0 -1
  317. package/dist/agent-farm/tutorial/steps/setup.js +0 -126
  318. package/dist/agent-farm/tutorial/steps/setup.js.map +0 -1
  319. package/dist/agent-farm/tutorial/steps/welcome.d.ts +0 -7
  320. package/dist/agent-farm/tutorial/steps/welcome.d.ts.map +0 -1
  321. package/dist/agent-farm/tutorial/steps/welcome.js +0 -50
  322. package/dist/agent-farm/tutorial/steps/welcome.js.map +0 -1
  323. package/dist/commands/pcheck/cache.d.ts +0 -48
  324. package/dist/commands/pcheck/cache.d.ts.map +0 -1
  325. package/dist/commands/pcheck/cache.js +0 -170
  326. package/dist/commands/pcheck/cache.js.map +0 -1
  327. package/dist/commands/pcheck/evaluator.d.ts +0 -15
  328. package/dist/commands/pcheck/evaluator.d.ts.map +0 -1
  329. package/dist/commands/pcheck/evaluator.js +0 -246
  330. package/dist/commands/pcheck/evaluator.js.map +0 -1
  331. package/dist/commands/pcheck/index.d.ts +0 -12
  332. package/dist/commands/pcheck/index.d.ts.map +0 -1
  333. package/dist/commands/pcheck/index.js +0 -249
  334. package/dist/commands/pcheck/index.js.map +0 -1
  335. package/dist/commands/pcheck/parser.d.ts +0 -39
  336. package/dist/commands/pcheck/parser.d.ts.map +0 -1
  337. package/dist/commands/pcheck/parser.js +0 -155
  338. package/dist/commands/pcheck/parser.js.map +0 -1
  339. package/dist/commands/pcheck/types.d.ts +0 -82
  340. package/dist/commands/pcheck/types.d.ts.map +0 -1
  341. package/dist/commands/pcheck/types.js +0 -5
  342. package/dist/commands/pcheck/types.js.map +0 -1
  343. package/dist/commands/porch/consultation.d.ts +0 -56
  344. package/dist/commands/porch/consultation.d.ts.map +0 -1
  345. package/dist/commands/porch/consultation.js +0 -330
  346. package/dist/commands/porch/consultation.js.map +0 -1
  347. package/dist/commands/porch/notifications.d.ts +0 -99
  348. package/dist/commands/porch/notifications.d.ts.map +0 -1
  349. package/dist/commands/porch/notifications.js +0 -223
  350. package/dist/commands/porch/notifications.js.map +0 -1
  351. package/dist/commands/porch/plan-parser.d.ts +0 -38
  352. package/dist/commands/porch/plan-parser.d.ts.map +0 -1
  353. package/dist/commands/porch/plan-parser.js +0 -166
  354. package/dist/commands/porch/plan-parser.js.map +0 -1
  355. package/dist/commands/porch/protocol-loader.d.ts +0 -46
  356. package/dist/commands/porch/protocol-loader.d.ts.map +0 -1
  357. package/dist/commands/porch/protocol-loader.js +0 -253
  358. package/dist/commands/porch/protocol-loader.js.map +0 -1
  359. package/dist/commands/porch/signal-parser.d.ts +0 -88
  360. package/dist/commands/porch/signal-parser.d.ts.map +0 -1
  361. package/dist/commands/porch/signal-parser.js +0 -148
  362. package/dist/commands/porch/signal-parser.js.map +0 -1
  363. package/dist/commands/tower.d.ts +0 -16
  364. package/dist/commands/tower.d.ts.map +0 -1
  365. package/dist/commands/tower.js +0 -21
  366. package/dist/commands/tower.js.map +0 -1
  367. package/skeleton/config.json +0 -7
  368. package/skeleton/porch/protocols/bugfix.json +0 -85
  369. package/skeleton/porch/protocols/spider.json +0 -135
  370. package/skeleton/porch/protocols/tick.json +0 -76
  371. package/skeleton/protocols/spider/prompts/defend.md +0 -215
  372. package/skeleton/protocols/spider/prompts/evaluate.md +0 -241
  373. package/skeleton/protocols/spider/prompts/implement.md +0 -149
  374. package/skeleton/protocols/spider/protocol.json +0 -210
  375. package/templates/dashboard/css/activity.css +0 -151
  376. package/templates/dashboard/js/activity.js +0 -112
  377. /package/skeleton/protocols/{spider → spir}/templates/spec.md +0 -0
@@ -7,17 +7,606 @@ 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
+ * Sanitize a tmux session name to match what tmux actually creates.
223
+ * tmux replaces dots with underscores and strips colons from session names.
224
+ * Without this, stored names won't match actual tmux session names,
225
+ * causing reconnection to fail (e.g., "builder-codevos.ai-0001" vs "builder-codevos_ai-0001").
226
+ */
227
+ function sanitizeTmuxSessionName(name) {
228
+ return name.replace(/\./g, '_').replace(/:/g, '');
229
+ }
230
+ /**
231
+ * Create a tmux session with the given command.
232
+ * Returns the sanitized session name if created successfully, null on failure.
233
+ * Session names are sanitized to match tmux behavior (dots → underscores, colons stripped).
234
+ */
235
+ function createTmuxSession(sessionName, command, args, cwd, cols, rows) {
236
+ // Sanitize to match what tmux actually creates (dots → underscores, colons stripped)
237
+ sessionName = sanitizeTmuxSessionName(sessionName);
238
+ // Kill any stale session with this name
239
+ if (tmuxSessionExists(sessionName)) {
240
+ killTmuxSession(sessionName);
241
+ }
242
+ try {
243
+ // Use spawnSync with array args to avoid shell injection via project paths
244
+ const tmuxArgs = [
245
+ 'new-session', '-d',
246
+ '-s', sessionName,
247
+ '-c', cwd,
248
+ '-x', String(cols),
249
+ '-y', String(rows),
250
+ command, ...args,
251
+ ];
252
+ const result = spawnSync('tmux', tmuxArgs, { stdio: 'ignore' });
253
+ if (result.status !== 0) {
254
+ log('WARN', `tmux new-session exited with code ${result.status} for "${sessionName}"`);
255
+ return null;
256
+ }
257
+ // Hide tmux status bar (dashboard has its own tabs), enable mouse, and
258
+ // use aggressive-resize so tmux sizes to the largest client (not smallest)
259
+ spawnSync('tmux', ['set-option', '-t', sessionName, 'status', 'off'], { stdio: 'ignore' });
260
+ spawnSync('tmux', ['set-option', '-t', sessionName, 'mouse', 'on'], { stdio: 'ignore' });
261
+ spawnSync('tmux', ['set-option', '-t', sessionName, 'aggressive-resize', 'on'], { stdio: 'ignore' });
262
+ return sessionName;
263
+ }
264
+ catch (err) {
265
+ log('WARN', `Failed to create tmux session "${sessionName}": ${err.message}`);
266
+ return null;
267
+ }
268
+ }
269
+ /**
270
+ * Check if a tmux session exists.
271
+ * Sanitizes the name to handle legacy entries stored before dot-replacement fix.
272
+ */
273
+ function tmuxSessionExists(sessionName) {
274
+ const sanitized = sanitizeTmuxSessionName(sessionName);
275
+ try {
276
+ execSync(`tmux has-session -t "${sanitized}" 2>/dev/null`, { stdio: 'ignore' });
277
+ return true;
278
+ }
279
+ catch {
280
+ return false;
281
+ }
282
+ }
283
+ /**
284
+ * Check if a process is running
285
+ */
286
+ function processExists(pid) {
287
+ try {
288
+ process.kill(pid, 0);
289
+ return true;
290
+ }
291
+ catch {
292
+ return false;
293
+ }
294
+ }
295
+ /**
296
+ * Kill a tmux session by name
297
+ */
298
+ function killTmuxSession(sessionName) {
299
+ try {
300
+ execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { stdio: 'ignore' });
301
+ log('INFO', `Killed orphaned tmux session: ${sessionName}`);
302
+ }
303
+ catch {
304
+ // Session may have already died
305
+ }
306
+ }
307
+ /**
308
+ * Parse a codev tmux session name to extract type, project, and role.
309
+ * Returns null if the name doesn't match any known codev pattern.
310
+ *
311
+ * Examples:
312
+ * "architect-codev-public" → { type: 'architect', projectBasename: 'codev-public', roleId: null }
313
+ * "builder-codevos_ai-0001" → { type: 'builder', projectBasename: 'codevos_ai', roleId: '0001' }
314
+ * "shell-codev-public-shell-1" → { type: 'shell', projectBasename: 'codev-public', roleId: 'shell-1' }
315
+ */
316
+ function parseTmuxSessionName(name) {
317
+ // architect-{basename}
318
+ const architectMatch = name.match(/^architect-(.+)$/);
319
+ if (architectMatch) {
320
+ return { type: 'architect', projectBasename: architectMatch[1], roleId: null };
321
+ }
322
+ // builder-{basename}-{specId} — specId is always the last segment (digits like "0001")
323
+ const builderMatch = name.match(/^builder-(.+)-(\d{4,})$/);
324
+ if (builderMatch) {
325
+ return { type: 'builder', projectBasename: builderMatch[1], roleId: builderMatch[2] };
326
+ }
327
+ // shell-{basename}-{shellId} — shellId is "shell-N" (last two segments)
328
+ const shellMatch = name.match(/^shell-(.+)-(shell-\d+)$/);
329
+ if (shellMatch) {
330
+ return { type: 'shell', projectBasename: shellMatch[1], roleId: shellMatch[2] };
331
+ }
332
+ return null;
333
+ }
334
+ /**
335
+ * List all tmux sessions that match codev naming conventions.
336
+ * Returns an array of { tmuxName, parsed } for each matching session.
337
+ */
338
+ // Cache for listCodevTmuxSessions — avoid shelling out on every dashboard poll
339
+ let _tmuxListCache = [];
340
+ let _tmuxListCacheTime = 0;
341
+ const TMUX_LIST_CACHE_TTL = 10_000; // 10 seconds
342
+ function listCodevTmuxSessions(bypassCache = false) {
343
+ if (!tmuxAvailable)
344
+ return [];
345
+ const now = Date.now();
346
+ if (!bypassCache && now - _tmuxListCacheTime < TMUX_LIST_CACHE_TTL) {
347
+ return _tmuxListCache;
348
+ }
349
+ try {
350
+ const result = execSync('tmux list-sessions -F "#{session_name}" 2>/dev/null', { encoding: 'utf-8' });
351
+ const sessions = result.trim().split('\n').filter(Boolean);
352
+ const codevSessions = [];
353
+ for (const name of sessions) {
354
+ const parsed = parseTmuxSessionName(name);
355
+ if (parsed) {
356
+ codevSessions.push({ tmuxName: name, parsed });
357
+ }
358
+ }
359
+ _tmuxListCache = codevSessions;
360
+ _tmuxListCacheTime = now;
361
+ return codevSessions;
362
+ }
363
+ catch {
364
+ _tmuxListCache = [];
365
+ _tmuxListCacheTime = now;
366
+ return [];
367
+ }
368
+ }
369
+ /**
370
+ * Find the SQLite row that matches a given tmux session name.
371
+ * Looks up by tmux_session column directly.
372
+ */
373
+ function findSqliteRowForTmuxSession(tmuxName) {
374
+ try {
375
+ const db = getGlobalDb();
376
+ return db.prepare('SELECT * FROM terminal_sessions WHERE tmux_session = ?').get(tmuxName) || null;
377
+ }
378
+ catch {
379
+ return null;
380
+ }
381
+ }
382
+ /**
383
+ * Find the full project path for a tmux session's project basename.
384
+ * Checks active port allocations (which have full paths) for a matching basename.
385
+ * Returns null if no match found.
386
+ */
387
+ function resolveProjectPathFromBasename(projectBasename) {
388
+ const allocations = loadPortAllocations();
389
+ for (const alloc of allocations) {
390
+ if (path.basename(alloc.project_path) === projectBasename) {
391
+ return normalizeProjectPath(alloc.project_path);
392
+ }
393
+ }
394
+ // Also check projectTerminals cache (may have entries not yet in allocations)
395
+ for (const [projectPath] of projectTerminals) {
396
+ if (path.basename(projectPath) === projectBasename) {
397
+ return projectPath;
398
+ }
399
+ }
400
+ return null;
401
+ }
402
+ /**
403
+ * Reconcile terminal sessions on startup.
404
+ *
405
+ * STRATEGY: tmux is the source of truth for existence.
406
+ *
407
+ * Phase 1 — tmux-first discovery:
408
+ * List all codev tmux sessions. For each, look up SQLite for metadata.
409
+ * If SQLite has a matching row → reconnect with full metadata.
410
+ * If SQLite has no row (orphaned tmux) → derive metadata from session name, reconnect.
411
+ *
412
+ * Phase 2 — SQLite sweep:
413
+ * Any SQLite rows not matched to a tmux session are stale → clean up.
414
+ * (Also kills orphaned processes that have no tmux backing.)
415
+ */
416
+ async function reconcileTerminalSessions() {
417
+ const manager = getTerminalManager();
418
+ const db = getGlobalDb();
419
+ // Phase 1: Discover living tmux sessions (bypass cache on startup)
420
+ const liveTmuxSessions = listCodevTmuxSessions(/* bypassCache */ true);
421
+ // Track which SQLite rows we matched (by tmux_session name)
422
+ const matchedTmuxNames = new Set();
423
+ let reconnected = 0;
424
+ let orphanReconnected = 0;
425
+ if (liveTmuxSessions.length > 0) {
426
+ log('INFO', `Found ${liveTmuxSessions.length} live codev tmux session(s) — reconnecting...`);
427
+ }
428
+ for (const { tmuxName, parsed } of liveTmuxSessions) {
429
+ // Look up SQLite for this tmux session's metadata
430
+ const dbRow = findSqliteRowForTmuxSession(tmuxName);
431
+ matchedTmuxNames.add(tmuxName);
432
+ // Determine metadata — prefer SQLite, fall back to parsed name
433
+ const projectPath = dbRow?.project_path || resolveProjectPathFromBasename(parsed.projectBasename);
434
+ const type = dbRow?.type || parsed.type;
435
+ const roleId = dbRow?.role_id || parsed.roleId;
436
+ if (!projectPath) {
437
+ log('WARN', `Cannot resolve project path for tmux session "${tmuxName}" (basename: ${parsed.projectBasename}) — skipping`);
438
+ continue;
439
+ }
440
+ try {
441
+ const label = type === 'architect' ? 'Architect' : `${type} ${roleId || 'unknown'}`;
442
+ const newSession = await manager.createSession({
443
+ command: 'tmux',
444
+ args: ['attach-session', '-t', tmuxName],
445
+ cwd: projectPath,
446
+ label,
447
+ });
448
+ // Register in projectTerminals Map
449
+ const entry = getProjectTerminalsEntry(projectPath);
450
+ if (type === 'architect') {
451
+ entry.architect = newSession.id;
452
+ }
453
+ else if (type === 'builder') {
454
+ entry.builders.set(roleId || tmuxName, newSession.id);
455
+ }
456
+ else if (type === 'shell') {
457
+ entry.shells.set(roleId || tmuxName, newSession.id);
458
+ }
459
+ // Update SQLite: delete old row (if any), insert fresh one
460
+ if (dbRow) {
461
+ db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbRow.id);
462
+ }
463
+ saveTerminalSession(newSession.id, projectPath, type, roleId, newSession.pid, tmuxName);
464
+ if (dbRow) {
465
+ log('INFO', `Reconnected tmux "${tmuxName}" → terminal ${newSession.id} (${type} for ${path.basename(projectPath)})`);
466
+ reconnected++;
467
+ }
468
+ else {
469
+ log('INFO', `Recovered orphaned tmux "${tmuxName}" → terminal ${newSession.id} (${type} for ${path.basename(projectPath)}) [no SQLite row]`);
470
+ orphanReconnected++;
471
+ }
472
+ }
473
+ catch (err) {
474
+ log('WARN', `Failed to reconnect to tmux "${tmuxName}": ${err.message}`);
475
+ }
476
+ }
477
+ // Phase 2: Sweep stale SQLite rows (those with no matching live tmux session)
478
+ let killed = 0;
479
+ let cleaned = 0;
480
+ let allDbSessions;
481
+ try {
482
+ allDbSessions = db.prepare('SELECT * FROM terminal_sessions').all();
483
+ }
484
+ catch (err) {
485
+ log('WARN', `Failed to read terminal sessions for sweep: ${err.message}`);
486
+ allDbSessions = [];
487
+ }
488
+ for (const session of allDbSessions) {
489
+ // Skip rows that were already reconnected in Phase 1
490
+ if (session.tmux_session && matchedTmuxNames.has(session.tmux_session)) {
491
+ continue;
492
+ }
493
+ // Also skip rows whose terminal is still alive in PtyManager
494
+ // (non-tmux sessions created during this Tower run)
495
+ const existing = manager.getSession(session.id);
496
+ if (existing && existing.status !== 'exited') {
497
+ continue;
498
+ }
499
+ // Stale row — kill orphaned process if any, then delete
500
+ if (session.pid && processExists(session.pid)) {
501
+ log('INFO', `Killing orphaned process: PID ${session.pid} (${session.type} for ${path.basename(session.project_path)})`);
502
+ try {
503
+ process.kill(session.pid, 'SIGTERM');
504
+ killed++;
505
+ }
506
+ catch {
507
+ // Process may not be killable
508
+ }
509
+ }
510
+ db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(session.id);
511
+ cleaned++;
512
+ }
513
+ const total = reconnected + orphanReconnected;
514
+ if (total > 0 || killed > 0 || cleaned > 0) {
515
+ log('INFO', `Reconciliation complete: ${reconnected} reconnected, ${orphanReconnected} orphan-recovered, ${killed} killed, ${cleaned} stale rows cleaned`);
516
+ }
517
+ else {
518
+ log('INFO', 'No terminal sessions to reconcile');
519
+ }
520
+ }
521
+ /**
522
+ * Get terminal sessions from SQLite for a project.
523
+ * Normalizes path for consistent lookup.
524
+ */
525
+ function getTerminalSessionsForProject(projectPath) {
526
+ try {
527
+ const normalizedPath = normalizeProjectPath(projectPath);
528
+ const db = getGlobalDb();
529
+ return db.prepare('SELECT * FROM terminal_sessions WHERE project_path = ?').all(normalizedPath);
530
+ }
531
+ catch {
532
+ return [];
533
+ }
534
+ }
535
+ /**
536
+ * Handle WebSocket connection to a terminal session
537
+ * Uses hybrid binary protocol (Spec 0085):
538
+ * - 0x00 prefix: Control frame (JSON)
539
+ * - 0x01 prefix: Data frame (raw PTY bytes)
540
+ */
541
+ function handleTerminalWebSocket(ws, session, req) {
542
+ const resumeSeq = req.headers['x-session-resume'];
543
+ // Create a client adapter for the PTY session
544
+ // Uses binary protocol for data frames
545
+ const client = {
546
+ send: (data) => {
547
+ if (ws.readyState === WebSocket.OPEN) {
548
+ // Encode as binary data frame (0x01 prefix)
549
+ ws.send(encodeData(data));
550
+ }
551
+ },
552
+ };
553
+ // Attach client to session and get replay data
554
+ let replayLines;
555
+ if (resumeSeq && typeof resumeSeq === 'string') {
556
+ replayLines = session.attachResume(client, parseInt(resumeSeq, 10));
557
+ }
558
+ else {
559
+ replayLines = session.attach(client);
560
+ }
561
+ // Send replay data as binary data frame
562
+ if (replayLines.length > 0) {
563
+ const replayData = replayLines.join('\n');
564
+ if (ws.readyState === WebSocket.OPEN) {
565
+ ws.send(encodeData(replayData));
566
+ }
567
+ }
568
+ // Handle incoming messages from client (binary protocol)
569
+ ws.on('message', (rawData) => {
570
+ try {
571
+ const frame = decodeFrame(Buffer.from(rawData));
572
+ if (frame.type === 'data') {
573
+ // Write raw input to terminal
574
+ session.write(frame.data.toString('utf-8'));
575
+ }
576
+ else if (frame.type === 'control') {
577
+ // Handle control messages
578
+ const msg = frame.message;
579
+ if (msg.type === 'resize') {
580
+ const cols = msg.payload.cols;
581
+ const rows = msg.payload.rows;
582
+ if (typeof cols === 'number' && typeof rows === 'number') {
583
+ session.resize(cols, rows);
584
+ }
585
+ }
586
+ else if (msg.type === 'ping') {
587
+ if (ws.readyState === WebSocket.OPEN) {
588
+ ws.send(encodeControl({ type: 'pong', payload: {} }));
589
+ }
590
+ }
591
+ }
592
+ }
593
+ catch {
594
+ // If decode fails, try treating as raw UTF-8 input (for simpler clients)
595
+ try {
596
+ session.write(rawData.toString('utf-8'));
597
+ }
598
+ catch {
599
+ // Ignore malformed input
600
+ }
601
+ }
602
+ });
603
+ ws.on('close', () => {
604
+ session.detach(client);
605
+ });
606
+ ws.on('error', () => {
607
+ session.detach(client);
608
+ });
609
+ }
21
610
  // Parse arguments with Commander
22
611
  const program = new Command()
23
612
  .name('tower-server')
@@ -52,6 +641,41 @@ function log(level, message) {
52
641
  }
53
642
  }
54
643
  }
644
+ // Global exception handlers to catch uncaught errors
645
+ process.on('uncaughtException', (err) => {
646
+ log('ERROR', `Uncaught exception: ${err.message}\n${err.stack}`);
647
+ process.exit(1);
648
+ });
649
+ process.on('unhandledRejection', (reason) => {
650
+ const message = reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason);
651
+ log('ERROR', `Unhandled rejection: ${message}`);
652
+ process.exit(1);
653
+ });
654
+ // Graceful shutdown handler (Phase 2 - Spec 0090)
655
+ async function gracefulShutdown(signal) {
656
+ log('INFO', `Received ${signal}, starting graceful shutdown...`);
657
+ // 1. Stop accepting new connections
658
+ server?.close();
659
+ // 2. Close all WebSocket connections
660
+ if (terminalWss) {
661
+ for (const client of terminalWss.clients) {
662
+ client.close(1001, 'Server shutting down');
663
+ }
664
+ terminalWss.close();
665
+ }
666
+ // 3. Kill all PTY sessions
667
+ if (terminalManager) {
668
+ log('INFO', 'Shutting down terminal manager...');
669
+ terminalManager.shutdown();
670
+ }
671
+ // 4. Stop cloudflared tunnel if running
672
+ stopTunnel();
673
+ log('INFO', 'Graceful shutdown complete');
674
+ process.exit(0);
675
+ }
676
+ // Catch signals for clean shutdown
677
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
678
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
55
679
  if (isNaN(port) || port < 1 || port > 65535) {
56
680
  log('ERROR', `Invalid port "${portArg}". Must be a number between 1 and 65535.`);
57
681
  process.exit(1);
@@ -97,6 +721,340 @@ async function isPortListening(port) {
97
721
  function getProjectName(projectPath) {
98
722
  return path.basename(projectPath);
99
723
  }
724
+ /**
725
+ * Get the base port for a project from global.db
726
+ * Returns null if project not found or not running
727
+ */
728
+ async function getBasePortForProject(projectPath) {
729
+ try {
730
+ const db = getGlobalDb();
731
+ const row = db.prepare('SELECT base_port FROM port_allocations WHERE project_path = ?').get(projectPath);
732
+ if (!row)
733
+ return null;
734
+ // Check if actually running
735
+ const isRunning = await isPortListening(row.base_port);
736
+ return isRunning ? row.base_port : null;
737
+ }
738
+ catch {
739
+ return null;
740
+ }
741
+ }
742
+ // Cloudflared tunnel management
743
+ let tunnelProcess = null;
744
+ let tunnelUrl = null;
745
+ function isCloudflaredInstalled() {
746
+ try {
747
+ execSync('which cloudflared', { stdio: 'ignore' });
748
+ return true;
749
+ }
750
+ catch {
751
+ return false;
752
+ }
753
+ }
754
+ function getTunnelStatus() {
755
+ return {
756
+ available: isCloudflaredInstalled(),
757
+ running: tunnelProcess !== null && tunnelUrl !== null,
758
+ url: tunnelUrl,
759
+ };
760
+ }
761
+ async function startTunnel(port) {
762
+ if (!isCloudflaredInstalled()) {
763
+ return { success: false, error: 'cloudflared not installed. Install with: brew install cloudflared' };
764
+ }
765
+ if (tunnelProcess) {
766
+ return { success: true, url: tunnelUrl || undefined };
767
+ }
768
+ return new Promise((resolve) => {
769
+ tunnelProcess = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
770
+ stdio: ['ignore', 'pipe', 'pipe'],
771
+ });
772
+ const handleOutput = (data) => {
773
+ const text = data.toString();
774
+ const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
775
+ if (match && !tunnelUrl) {
776
+ tunnelUrl = match[0];
777
+ log('INFO', `Cloudflared tunnel started: ${tunnelUrl}`);
778
+ resolve({ success: true, url: tunnelUrl });
779
+ }
780
+ };
781
+ tunnelProcess.stdout?.on('data', handleOutput);
782
+ tunnelProcess.stderr?.on('data', handleOutput);
783
+ tunnelProcess.on('close', (code) => {
784
+ log('INFO', `Cloudflared tunnel closed with code ${code}`);
785
+ tunnelProcess = null;
786
+ tunnelUrl = null;
787
+ });
788
+ // Timeout after 30 seconds
789
+ setTimeout(() => {
790
+ if (!tunnelUrl) {
791
+ tunnelProcess?.kill();
792
+ tunnelProcess = null;
793
+ resolve({ success: false, error: 'Tunnel startup timed out' });
794
+ }
795
+ }, 30000);
796
+ });
797
+ }
798
+ function stopTunnel() {
799
+ if (tunnelProcess) {
800
+ tunnelProcess.kill();
801
+ tunnelProcess = null;
802
+ tunnelUrl = null;
803
+ log('INFO', 'Cloudflared tunnel stopped');
804
+ }
805
+ return { success: true };
806
+ }
807
+ const sseClients = [];
808
+ let notificationIdCounter = 0;
809
+ /**
810
+ * Broadcast a notification to all connected SSE clients
811
+ */
812
+ function broadcastNotification(notification) {
813
+ const id = ++notificationIdCounter;
814
+ const data = JSON.stringify({ ...notification, id });
815
+ const message = `id: ${id}\ndata: ${data}\n\n`;
816
+ for (const client of sseClients) {
817
+ try {
818
+ client.res.write(message);
819
+ }
820
+ catch {
821
+ // Client disconnected, will be cleaned up on next iteration
822
+ }
823
+ }
824
+ }
825
+ /**
826
+ * Get gate status for a project by querying its dashboard API.
827
+ * Uses timeout to prevent hung projects from stalling tower status.
828
+ */
829
+ async function getGateStatusForProject(basePort) {
830
+ const controller = new AbortController();
831
+ const timeout = setTimeout(() => controller.abort(), 2000); // 2-second timeout
832
+ try {
833
+ const response = await fetch(`http://localhost:${basePort}/api/status`, {
834
+ signal: controller.signal,
835
+ });
836
+ clearTimeout(timeout);
837
+ if (!response.ok)
838
+ return { hasGate: false };
839
+ const projectStatus = await response.json();
840
+ // Check if any builder has a pending gate
841
+ const builderWithGate = projectStatus.builders?.find((b) => b.gateStatus?.waiting || b.status === 'gate-pending');
842
+ if (builderWithGate) {
843
+ return {
844
+ hasGate: true,
845
+ gateName: builderWithGate.gateStatus?.gateName || builderWithGate.currentGate,
846
+ builderId: builderWithGate.id,
847
+ timestamp: builderWithGate.gateStatus?.timestamp || Date.now(),
848
+ };
849
+ }
850
+ }
851
+ catch {
852
+ // Project dashboard not responding or timeout
853
+ }
854
+ return { hasGate: false };
855
+ }
856
+ /**
857
+ * Get terminal list for a project from tower's registry.
858
+ * Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server fetch.
859
+ * Returns architect, builders, and shells with their URLs.
860
+ */
861
+ async function getTerminalsForProject(projectPath, proxyUrl) {
862
+ const manager = getTerminalManager();
863
+ const terminals = [];
864
+ // Query SQLite first, then augment with tmux discovery
865
+ const dbSessions = getTerminalSessionsForProject(projectPath);
866
+ // Use normalized path for cache consistency
867
+ const normalizedPath = normalizeProjectPath(projectPath);
868
+ // Build a fresh entry from SQLite, then replace atomically to avoid
869
+ // destroying in-memory state that was registered via POST /api/terminals.
870
+ // Previous approach cleared the cache then rebuilt, which lost terminals
871
+ // if their SQLite rows were deleted by external interference (e.g., tests).
872
+ const freshEntry = { builders: new Map(), shells: new Map(), fileTabs: new Map() };
873
+ // Preserve file tabs from existing entry (not stored in SQLite)
874
+ const existingEntry = projectTerminals.get(normalizedPath);
875
+ if (existingEntry) {
876
+ freshEntry.fileTabs = existingEntry.fileTabs;
877
+ }
878
+ for (const dbSession of dbSessions) {
879
+ // Verify session still exists in TerminalManager (runtime state)
880
+ let session = manager.getSession(dbSession.id);
881
+ const sanitizedTmux = dbSession.tmux_session ? sanitizeTmuxSessionName(dbSession.tmux_session) : null;
882
+ if (!session && sanitizedTmux && tmuxAvailable && tmuxSessionExists(sanitizedTmux)) {
883
+ // PTY session gone but tmux session survives — reconnect on-the-fly
884
+ try {
885
+ const newSession = await manager.createSession({
886
+ command: 'tmux',
887
+ args: ['attach-session', '-t', sanitizedTmux],
888
+ cwd: dbSession.project_path,
889
+ label: dbSession.type === 'architect' ? 'Architect' : `${dbSession.type} ${dbSession.role_id || dbSession.id}`,
890
+ env: process.env,
891
+ });
892
+ // Update SQLite with new terminal ID (use sanitized tmux name)
893
+ deleteTerminalSession(dbSession.id);
894
+ saveTerminalSession(newSession.id, dbSession.project_path, dbSession.type, dbSession.role_id, newSession.pid, sanitizedTmux);
895
+ dbSession.id = newSession.id;
896
+ session = manager.getSession(newSession.id);
897
+ log('INFO', `Reconnected to tmux "${sanitizedTmux}" on-the-fly → ${newSession.id}`);
898
+ }
899
+ catch (err) {
900
+ log('WARN', `Failed to reconnect to tmux "${dbSession.tmux_session}": ${err.message} — will retry on next poll`);
901
+ continue;
902
+ }
903
+ }
904
+ else if (!session) {
905
+ // Stale row in SQLite, no tmux to reconnect — clean it up
906
+ deleteTerminalSession(dbSession.id);
907
+ continue;
908
+ }
909
+ if (dbSession.type === 'architect') {
910
+ freshEntry.architect = dbSession.id;
911
+ terminals.push({
912
+ type: 'architect',
913
+ id: 'architect',
914
+ label: 'Architect',
915
+ url: `${proxyUrl}?tab=architect`,
916
+ active: true,
917
+ });
918
+ }
919
+ else if (dbSession.type === 'builder') {
920
+ const builderId = dbSession.role_id || dbSession.id;
921
+ freshEntry.builders.set(builderId, dbSession.id);
922
+ terminals.push({
923
+ type: 'builder',
924
+ id: builderId,
925
+ label: `Builder ${builderId}`,
926
+ url: `${proxyUrl}?tab=builder-${builderId}`,
927
+ active: true,
928
+ });
929
+ }
930
+ else if (dbSession.type === 'shell') {
931
+ const shellId = dbSession.role_id || dbSession.id;
932
+ freshEntry.shells.set(shellId, dbSession.id);
933
+ terminals.push({
934
+ type: 'shell',
935
+ id: shellId,
936
+ label: `Shell ${shellId.replace('shell-', '')}`,
937
+ url: `${proxyUrl}?tab=shell-${shellId}`,
938
+ active: true,
939
+ });
940
+ }
941
+ }
942
+ // Also merge in-memory entries that may not be in SQLite yet
943
+ // (e.g., registered via POST /api/terminals but SQLite row was lost)
944
+ if (existingEntry) {
945
+ if (existingEntry.architect && !freshEntry.architect) {
946
+ const session = manager.getSession(existingEntry.architect);
947
+ if (session) {
948
+ freshEntry.architect = existingEntry.architect;
949
+ terminals.push({
950
+ type: 'architect',
951
+ id: 'architect',
952
+ label: 'Architect',
953
+ url: `${proxyUrl}?tab=architect`,
954
+ active: true,
955
+ });
956
+ }
957
+ }
958
+ for (const [builderId, terminalId] of existingEntry.builders) {
959
+ if (!freshEntry.builders.has(builderId)) {
960
+ const session = manager.getSession(terminalId);
961
+ if (session) {
962
+ freshEntry.builders.set(builderId, terminalId);
963
+ terminals.push({
964
+ type: 'builder',
965
+ id: builderId,
966
+ label: `Builder ${builderId}`,
967
+ url: `${proxyUrl}?tab=builder-${builderId}`,
968
+ active: true,
969
+ });
970
+ }
971
+ }
972
+ }
973
+ for (const [shellId, terminalId] of existingEntry.shells) {
974
+ if (!freshEntry.shells.has(shellId)) {
975
+ const session = manager.getSession(terminalId);
976
+ if (session) {
977
+ freshEntry.shells.set(shellId, terminalId);
978
+ terminals.push({
979
+ type: 'shell',
980
+ id: shellId,
981
+ label: `Shell ${shellId.replace('shell-', '')}`,
982
+ url: `${proxyUrl}?tab=shell-${shellId}`,
983
+ active: true,
984
+ });
985
+ }
986
+ }
987
+ }
988
+ }
989
+ // Phase 3: tmux discovery — find tmux sessions for this project that are
990
+ // missing from both SQLite and the in-memory cache.
991
+ // This is the safety net: if SQLite rows got deleted but tmux survived,
992
+ // the session will still appear in the dashboard.
993
+ const projectBasename = sanitizeTmuxSessionName(path.basename(normalizedPath));
994
+ const liveTmux = listCodevTmuxSessions();
995
+ for (const { tmuxName, parsed } of liveTmux) {
996
+ // Only process sessions whose sanitized project basename matches
997
+ if (parsed.projectBasename !== projectBasename)
998
+ continue;
999
+ // Skip if we already have this session registered (from SQLite or in-memory)
1000
+ const alreadyRegistered = (parsed.type === 'architect' && freshEntry.architect) ||
1001
+ (parsed.type === 'builder' && parsed.roleId && freshEntry.builders.has(parsed.roleId)) ||
1002
+ (parsed.type === 'shell' && parsed.roleId && freshEntry.shells.has(parsed.roleId));
1003
+ if (alreadyRegistered)
1004
+ continue;
1005
+ // Orphaned tmux session — reconnect it
1006
+ try {
1007
+ const label = parsed.type === 'architect' ? 'Architect' : `${parsed.type} ${parsed.roleId || 'unknown'}`;
1008
+ const newSession = await manager.createSession({
1009
+ command: 'tmux',
1010
+ args: ['attach-session', '-t', tmuxName],
1011
+ cwd: normalizedPath,
1012
+ label,
1013
+ });
1014
+ const roleId = parsed.roleId;
1015
+ if (parsed.type === 'architect') {
1016
+ freshEntry.architect = newSession.id;
1017
+ terminals.push({ type: 'architect', id: 'architect', label: 'Architect', url: `${proxyUrl}?tab=architect`, active: true });
1018
+ }
1019
+ else if (parsed.type === 'builder' && roleId) {
1020
+ freshEntry.builders.set(roleId, newSession.id);
1021
+ terminals.push({ type: 'builder', id: roleId, label: `Builder ${roleId}`, url: `${proxyUrl}?tab=builder-${roleId}`, active: true });
1022
+ }
1023
+ else if (parsed.type === 'shell' && roleId) {
1024
+ freshEntry.shells.set(roleId, newSession.id);
1025
+ terminals.push({ type: 'shell', id: roleId, label: `Shell ${roleId.replace('shell-', '')}`, url: `${proxyUrl}?tab=shell-${roleId}`, active: true });
1026
+ }
1027
+ // Persist to SQLite so future polls find it directly
1028
+ saveTerminalSession(newSession.id, normalizedPath, parsed.type, roleId, newSession.pid, tmuxName);
1029
+ log('INFO', `[tmux-discovery] Recovered orphaned tmux "${tmuxName}" → ${newSession.id} (${parsed.type})`);
1030
+ }
1031
+ catch (err) {
1032
+ log('WARN', `[tmux-discovery] Failed to recover tmux "${tmuxName}": ${err.message}`);
1033
+ }
1034
+ }
1035
+ // Atomically replace the cache entry
1036
+ projectTerminals.set(normalizedPath, freshEntry);
1037
+ // Gate status - builders don't have gate tracking yet in tower
1038
+ // TODO: Add gate status tracking when porch integration is updated
1039
+ const gateStatus = { hasGate: false };
1040
+ return { terminals, gateStatus };
1041
+ }
1042
+ // Resolve once at module load: both symlinked and real temp dir paths
1043
+ const _tmpDir = tmpdir();
1044
+ const _tmpDirResolved = (() => {
1045
+ try {
1046
+ return fs.realpathSync(_tmpDir);
1047
+ }
1048
+ catch {
1049
+ return _tmpDir;
1050
+ }
1051
+ })();
1052
+ function isTempDirectory(projectPath) {
1053
+ return (projectPath.startsWith(_tmpDir + '/') ||
1054
+ projectPath.startsWith(_tmpDirResolved + '/') ||
1055
+ projectPath.startsWith('/tmp/') ||
1056
+ projectPath.startsWith('/private/tmp/'));
1057
+ }
100
1058
  /**
101
1059
  * Get all instances with their status
102
1060
  */
@@ -108,25 +1066,31 @@ async function getInstances() {
108
1066
  if (allocation.project_path.includes('/.builders/')) {
109
1067
  continue;
110
1068
  }
1069
+ // Skip projects in temp directories (e.g. test artifacts) or whose directories no longer exist
1070
+ if (!allocation.project_path.startsWith('remote:')) {
1071
+ if (!fs.existsSync(allocation.project_path)) {
1072
+ continue;
1073
+ }
1074
+ if (isTempDirectory(allocation.project_path)) {
1075
+ continue;
1076
+ }
1077
+ }
111
1078
  const basePort = allocation.base_port;
112
1079
  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;
1080
+ // Encode project path for proxy URL
1081
+ const encodedPath = Buffer.from(allocation.project_path).toString('base64url');
1082
+ const proxyUrl = `/project/${encodedPath}/`;
1083
+ // Get terminals and gate status from tower's registry
1084
+ // Phase 4 (Spec 0090): Tower manages terminals directly - no separate dashboard server
1085
+ const { terminals, gateStatus } = await getTerminalsForProject(allocation.project_path, proxyUrl);
1086
+ // Project is active if it has any terminals (Phase 4: no port check needed)
1087
+ const isActive = terminals.length > 0;
118
1088
  const ports = [
119
1089
  {
120
1090
  type: 'Dashboard',
121
1091
  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,
1092
+ url: proxyUrl, // Use tower proxy URL, not raw localhost
1093
+ active: isActive,
130
1094
  },
131
1095
  ];
132
1096
  instances.push({
@@ -134,11 +1098,15 @@ async function getInstances() {
134
1098
  projectName: getProjectName(allocation.project_path),
135
1099
  basePort,
136
1100
  dashboardPort,
137
- architectPort,
1101
+ architectPort: basePort + 1, // Legacy field for backward compat
138
1102
  registered: allocation.registered_at,
139
1103
  lastUsed: allocation.last_used_at,
140
- running: dashboardActive,
1104
+ running: isActive,
1105
+ proxyUrl, // Tower proxy URL for dashboard
1106
+ architectUrl: `${proxyUrl}?tab=architect`, // Direct URL to architect terminal
1107
+ terminals, // All available terminals
141
1108
  ports,
1109
+ gateStatus,
142
1110
  });
143
1111
  }
144
1112
  // Sort: running first, then by last used (most recent first)
@@ -164,6 +1132,10 @@ async function getDirectorySuggestions(inputPath) {
164
1132
  if (inputPath.startsWith('~')) {
165
1133
  inputPath = inputPath.replace('~', homedir());
166
1134
  }
1135
+ // Relative paths are meaningless for the tower daemon — only absolute paths
1136
+ if (!path.isAbsolute(inputPath)) {
1137
+ return [];
1138
+ }
167
1139
  // Determine the directory to list and the prefix to filter by
168
1140
  let dirToList;
169
1141
  let prefix;
@@ -213,8 +1185,8 @@ async function getDirectorySuggestions(inputPath) {
213
1185
  }
214
1186
  /**
215
1187
  * Launch a new agent-farm instance
216
- * First stops any stale state, then starts fresh
217
- * Auto-adopts non-codev directories
1188
+ * Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server
1189
+ * Auto-adopts non-codev directories and creates architect terminal
218
1190
  */
219
1191
  async function launchInstance(projectPath) {
220
1192
  // Clean up stale port allocations before launching (handles machine restarts)
@@ -246,74 +1218,122 @@ async function launchInstance(projectPath) {
246
1218
  return { success: false, error: `Failed to adopt codev: ${err.message}` };
247
1219
  }
248
1220
  }
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
1221
+ // Phase 4 (Spec 0090): Tower manages terminals directly
1222
+ // No dashboard-server spawning - tower handles everything
253
1223
  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)
1224
+ // Clear any stale state file
1225
+ const stateFile = path.join(projectPath, '.agent-farm', 'state.json');
1226
+ if (fs.existsSync(stateFile)) {
1227
+ try {
1228
+ fs.unlinkSync(stateFile);
1229
+ }
1230
+ catch {
1231
+ // Ignore - file might not exist or be locked
1232
+ }
1233
+ }
1234
+ // Ensure project has port allocation
287
1235
  const resolvedPath = fs.realpathSync(projectPath);
288
1236
  const db = getGlobalDb();
289
- const allocation = db
1237
+ let allocation = db
290
1238
  .prepare('SELECT base_port FROM port_allocations WHERE project_path = ? OR project_path = ?')
291
1239
  .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
- };
1240
+ if (!allocation) {
1241
+ // Allocate a new port for this project
1242
+ // Find the next available port block (starting at 4200, incrementing by 100)
1243
+ const existingPorts = db
1244
+ .prepare('SELECT base_port FROM port_allocations ORDER BY base_port')
1245
+ .all();
1246
+ let nextPort = 4200;
1247
+ for (const { base_port } of existingPorts) {
1248
+ if (base_port >= nextPort) {
1249
+ nextPort = base_port + 100;
1250
+ }
303
1251
  }
1252
+ db.prepare("INSERT INTO port_allocations (project_path, project_name, base_port, created_at) VALUES (?, ?, ?, datetime('now'))").run(resolvedPath, path.basename(projectPath), nextPort);
1253
+ allocation = { base_port: nextPort };
1254
+ log('INFO', `Allocated port ${nextPort} for project: ${projectPath}`);
304
1255
  }
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
- };
1256
+ // Initialize project terminal entry
1257
+ const entry = getProjectTerminalsEntry(resolvedPath);
1258
+ // Create architect terminal if not already present
1259
+ if (!entry.architect) {
1260
+ const manager = getTerminalManager();
1261
+ // Read af-config.json to get the architect command
1262
+ let architectCmd = 'claude';
1263
+ const configPath = path.join(projectPath, 'af-config.json');
1264
+ if (fs.existsSync(configPath)) {
1265
+ try {
1266
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1267
+ if (config.shell?.architect) {
1268
+ architectCmd = config.shell.architect;
1269
+ }
1270
+ }
1271
+ catch {
1272
+ // Ignore config read errors, use default
1273
+ }
1274
+ }
1275
+ try {
1276
+ // Parse command string to separate command and args
1277
+ const cmdParts = architectCmd.split(/\s+/);
1278
+ let cmd = cmdParts[0];
1279
+ let cmdArgs = cmdParts.slice(1);
1280
+ // Wrap in tmux for session persistence across Tower restarts
1281
+ const tmuxName = `architect-${path.basename(projectPath)}`;
1282
+ let activeTmuxSession = null;
1283
+ if (tmuxAvailable) {
1284
+ const sanitizedName = createTmuxSession(tmuxName, cmd, cmdArgs, projectPath, 200, 50);
1285
+ if (sanitizedName) {
1286
+ cmd = 'tmux';
1287
+ cmdArgs = ['attach-session', '-t', sanitizedName];
1288
+ activeTmuxSession = sanitizedName;
1289
+ log('INFO', `Created tmux session "${sanitizedName}" for architect`);
1290
+ }
1291
+ }
1292
+ const session = await manager.createSession({
1293
+ command: cmd,
1294
+ args: cmdArgs,
1295
+ cwd: projectPath,
1296
+ label: 'Architect',
1297
+ env: process.env,
1298
+ });
1299
+ entry.architect = session.id;
1300
+ // TICK-001: Save to SQLite for persistence (with tmux session name)
1301
+ saveTerminalSession(session.id, resolvedPath, 'architect', null, session.pid, activeTmuxSession);
1302
+ // Auto-restart architect on exit (restored from pre-Phase 4 dashboard-server.ts)
1303
+ const ptySession = manager.getSession(session.id);
1304
+ if (ptySession) {
1305
+ const startedAt = Date.now();
1306
+ ptySession.on('exit', () => {
1307
+ entry.architect = undefined;
1308
+ deleteTerminalSession(session.id);
1309
+ // Kill stale tmux session so restart can create a fresh one
1310
+ if (activeTmuxSession) {
1311
+ try {
1312
+ execSync(`tmux kill-session -t "${activeTmuxSession}" 2>/dev/null`, { stdio: 'ignore' });
1313
+ }
1314
+ catch { /* already gone */ }
1315
+ }
1316
+ // Only restart if the architect ran for at least 5s (prevents crash loops)
1317
+ const uptime = Date.now() - startedAt;
1318
+ if (uptime < 5000) {
1319
+ log('INFO', `Architect exited after ${uptime}ms for ${projectPath}, not restarting (too short)`);
1320
+ return;
1321
+ }
1322
+ log('INFO', `Architect exited for ${projectPath}, restarting in 2s...`);
1323
+ setTimeout(() => {
1324
+ launchInstance(projectPath).catch((err) => {
1325
+ log('WARN', `Failed to restart architect for ${projectPath}: ${err.message}`);
1326
+ });
1327
+ }, 2000);
1328
+ });
1329
+ }
1330
+ log('INFO', `Created architect terminal for project: ${projectPath}`);
1331
+ }
1332
+ catch (err) {
1333
+ log('WARN', `Failed to create architect terminal: ${err.message}`);
1334
+ // Don't fail the launch - project is still active, just without architect
314
1335
  }
315
1336
  }
316
- child.unref();
317
1337
  return { success: true, adopted };
318
1338
  }
319
1339
  catch (err) {
@@ -321,40 +1341,69 @@ async function launchInstance(projectPath) {
321
1341
  }
322
1342
  }
323
1343
  /**
324
- * Get PID of process listening on a port
1344
+ * Stop an agent-farm instance by killing all its terminals
1345
+ * Phase 4 (Spec 0090): Tower manages terminals directly
325
1346
  */
326
- function getProcessOnPort(targetPort) {
1347
+ async function stopInstance(projectPath) {
1348
+ const stopped = [];
1349
+ const manager = getTerminalManager();
1350
+ // Resolve symlinks for consistent lookup
1351
+ let resolvedPath = projectPath;
327
1352
  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;
1353
+ if (fs.existsSync(projectPath)) {
1354
+ resolvedPath = fs.realpathSync(projectPath);
1355
+ }
331
1356
  }
332
1357
  catch {
333
- return null;
1358
+ // Ignore - use original path
334
1359
  }
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);
1360
+ // Get project terminals
1361
+ const entry = projectTerminals.get(resolvedPath) || projectTerminals.get(projectPath);
1362
+ if (entry) {
1363
+ // Query SQLite for tmux session names BEFORE deleting rows
1364
+ const dbSessions = getTerminalSessionsForProject(resolvedPath);
1365
+ const tmuxSessions = dbSessions
1366
+ .filter(s => s.tmux_session)
1367
+ .map(s => s.tmux_session);
1368
+ // Kill architect
1369
+ if (entry.architect) {
1370
+ const session = manager.getSession(entry.architect);
1371
+ if (session) {
1372
+ manager.killSession(entry.architect);
1373
+ stopped.push(session.pid);
350
1374
  }
351
- catch {
352
- // Process may have already exited
1375
+ }
1376
+ // Kill all shells
1377
+ for (const terminalId of entry.shells.values()) {
1378
+ const session = manager.getSession(terminalId);
1379
+ if (session) {
1380
+ manager.killSession(terminalId);
1381
+ stopped.push(session.pid);
1382
+ }
1383
+ }
1384
+ // Kill all builders
1385
+ for (const terminalId of entry.builders.values()) {
1386
+ const session = manager.getSession(terminalId);
1387
+ if (session) {
1388
+ manager.killSession(terminalId);
1389
+ stopped.push(session.pid);
353
1390
  }
354
1391
  }
1392
+ // Kill tmux sessions (node-pty kill only detaches, tmux keeps running)
1393
+ for (const tmuxName of tmuxSessions) {
1394
+ killTmuxSession(tmuxName);
1395
+ }
1396
+ // Clear project from registry
1397
+ projectTerminals.delete(resolvedPath);
1398
+ projectTerminals.delete(projectPath);
1399
+ // TICK-001: Delete all terminal sessions from SQLite
1400
+ deleteProjectTerminalSessions(resolvedPath);
1401
+ if (resolvedPath !== projectPath) {
1402
+ deleteProjectTerminalSessions(projectPath);
1403
+ }
355
1404
  }
356
1405
  if (stopped.length === 0) {
357
- return { success: true, error: 'No processes found to stop', stopped };
1406
+ return { success: true, error: 'No terminals found to stop', stopped };
358
1407
  }
359
1408
  return { success: true, stopped };
360
1409
  }
@@ -375,6 +1424,54 @@ function findTemplatePath() {
375
1424
  // escapeHtml, parseJsonBody, isRequestAllowed imported from ../utils/server-utils.js
376
1425
  // Find template path
377
1426
  const templatePath = findTemplatePath();
1427
+ // WebSocket server for terminal connections (Phase 2 - Spec 0090)
1428
+ let terminalWss = null;
1429
+ // React dashboard dist path (for serving directly from tower)
1430
+ // React dashboard dist path (for serving directly from tower)
1431
+ // Phase 4 (Spec 0090): Tower serves everything directly, no dashboard-server
1432
+ const reactDashboardPath = path.resolve(__dirname, '../../../dashboard/dist');
1433
+ const hasReactDashboard = fs.existsSync(reactDashboardPath);
1434
+ if (hasReactDashboard) {
1435
+ log('INFO', `React dashboard found at: ${reactDashboardPath}`);
1436
+ }
1437
+ else {
1438
+ log('WARN', 'React dashboard not found - project dashboards will not work');
1439
+ }
1440
+ // MIME types for static file serving
1441
+ const MIME_TYPES = {
1442
+ '.html': 'text/html',
1443
+ '.js': 'application/javascript',
1444
+ '.css': 'text/css',
1445
+ '.json': 'application/json',
1446
+ '.png': 'image/png',
1447
+ '.jpg': 'image/jpeg',
1448
+ '.gif': 'image/gif',
1449
+ '.svg': 'image/svg+xml',
1450
+ '.ico': 'image/x-icon',
1451
+ '.woff': 'font/woff',
1452
+ '.woff2': 'font/woff2',
1453
+ '.ttf': 'font/ttf',
1454
+ '.map': 'application/json',
1455
+ };
1456
+ /**
1457
+ * Serve a static file from the React dashboard dist
1458
+ */
1459
+ function serveStaticFile(filePath, res) {
1460
+ if (!fs.existsSync(filePath)) {
1461
+ return false;
1462
+ }
1463
+ const ext = path.extname(filePath);
1464
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
1465
+ try {
1466
+ const content = fs.readFileSync(filePath);
1467
+ res.writeHead(200, { 'Content-Type': contentType });
1468
+ res.end(content);
1469
+ return true;
1470
+ }
1471
+ catch {
1472
+ return false;
1473
+ }
1474
+ }
378
1475
  // Create server
379
1476
  const server = http.createServer(async (req, res) => {
380
1477
  // Security: Validate Host and Origin headers
@@ -398,13 +1495,320 @@ const server = http.createServer(async (req, res) => {
398
1495
  }
399
1496
  const url = new URL(req.url || '/', `http://localhost:${port}`);
400
1497
  try {
401
- // API: Get status of all instances
1498
+ // =========================================================================
1499
+ // NEW API ENDPOINTS (Spec 0090 - Tower as Single Daemon)
1500
+ // =========================================================================
1501
+ // Health check endpoint (Spec 0090 Phase 1)
1502
+ if (req.method === 'GET' && url.pathname === '/health') {
1503
+ const instances = await getInstances();
1504
+ const activeCount = instances.filter((i) => i.running).length;
1505
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1506
+ res.end(JSON.stringify({
1507
+ status: 'healthy',
1508
+ uptime: process.uptime(),
1509
+ activeProjects: activeCount,
1510
+ totalProjects: instances.length,
1511
+ memoryUsage: process.memoryUsage().heapUsed,
1512
+ timestamp: new Date().toISOString(),
1513
+ }));
1514
+ return;
1515
+ }
1516
+ // API: List all projects (Spec 0090 Phase 1)
1517
+ if (req.method === 'GET' && url.pathname === '/api/projects') {
1518
+ const instances = await getInstances();
1519
+ const projects = instances.map((i) => ({
1520
+ path: i.projectPath,
1521
+ name: i.projectName,
1522
+ basePort: i.basePort,
1523
+ active: i.running,
1524
+ proxyUrl: i.proxyUrl,
1525
+ terminals: i.terminals.length,
1526
+ lastUsed: i.lastUsed,
1527
+ }));
1528
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1529
+ res.end(JSON.stringify({ projects }));
1530
+ return;
1531
+ }
1532
+ // API: Project-specific endpoints (Spec 0090 Phase 1)
1533
+ // Routes: /api/projects/:encodedPath/activate, /deactivate, /status
1534
+ const projectApiMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/(activate|deactivate|status)$/);
1535
+ if (projectApiMatch) {
1536
+ const [, encodedPath, action] = projectApiMatch;
1537
+ let projectPath;
1538
+ try {
1539
+ projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
1540
+ if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
1541
+ throw new Error('Invalid path');
1542
+ }
1543
+ // Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
1544
+ projectPath = normalizeProjectPath(projectPath);
1545
+ }
1546
+ catch {
1547
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1548
+ res.end(JSON.stringify({ error: 'Invalid project path encoding' }));
1549
+ return;
1550
+ }
1551
+ // GET /api/projects/:path/status
1552
+ if (req.method === 'GET' && action === 'status') {
1553
+ const instances = await getInstances();
1554
+ const instance = instances.find((i) => i.projectPath === projectPath);
1555
+ if (!instance) {
1556
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1557
+ res.end(JSON.stringify({ error: 'Project not found' }));
1558
+ return;
1559
+ }
1560
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1561
+ res.end(JSON.stringify({
1562
+ path: instance.projectPath,
1563
+ name: instance.projectName,
1564
+ active: instance.running,
1565
+ basePort: instance.basePort,
1566
+ terminals: instance.terminals,
1567
+ gateStatus: instance.gateStatus,
1568
+ }));
1569
+ return;
1570
+ }
1571
+ // POST /api/projects/:path/activate
1572
+ if (req.method === 'POST' && action === 'activate') {
1573
+ // Rate limiting: 10 activations per minute per client
1574
+ const clientIp = req.socket.remoteAddress || '127.0.0.1';
1575
+ if (isRateLimited(clientIp)) {
1576
+ res.writeHead(429, { 'Content-Type': 'application/json' });
1577
+ res.end(JSON.stringify({ error: 'Too many activations, try again later' }));
1578
+ return;
1579
+ }
1580
+ const result = await launchInstance(projectPath);
1581
+ if (result.success) {
1582
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1583
+ res.end(JSON.stringify({ success: true, adopted: result.adopted }));
1584
+ }
1585
+ else {
1586
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1587
+ res.end(JSON.stringify({ success: false, error: result.error }));
1588
+ }
1589
+ return;
1590
+ }
1591
+ // POST /api/projects/:path/deactivate
1592
+ if (req.method === 'POST' && action === 'deactivate') {
1593
+ // Check if project exists in port allocations
1594
+ const allocations = loadPortAllocations();
1595
+ const resolvedPath = fs.existsSync(projectPath) ? fs.realpathSync(projectPath) : projectPath;
1596
+ const allocation = allocations.find((a) => a.project_path === projectPath || a.project_path === resolvedPath);
1597
+ if (!allocation) {
1598
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1599
+ res.end(JSON.stringify({ ok: false, error: 'Project not found' }));
1600
+ return;
1601
+ }
1602
+ // Phase 4: Stop terminals directly via tower
1603
+ const result = await stopInstance(projectPath);
1604
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1605
+ res.end(JSON.stringify(result));
1606
+ return;
1607
+ }
1608
+ }
1609
+ // =========================================================================
1610
+ // TERMINAL API (Phase 2 - Spec 0090)
1611
+ // =========================================================================
1612
+ // POST /api/terminals - Create a new terminal
1613
+ if (req.method === 'POST' && url.pathname === '/api/terminals') {
1614
+ try {
1615
+ const body = await parseJsonBody(req);
1616
+ const manager = getTerminalManager();
1617
+ // Parse request fields
1618
+ let command = typeof body.command === 'string' ? body.command : undefined;
1619
+ let args = Array.isArray(body.args) ? body.args : undefined;
1620
+ const cols = typeof body.cols === 'number' ? body.cols : undefined;
1621
+ const rows = typeof body.rows === 'number' ? body.rows : undefined;
1622
+ const cwd = typeof body.cwd === 'string' ? body.cwd : undefined;
1623
+ const env = typeof body.env === 'object' && body.env !== null ? body.env : undefined;
1624
+ const label = typeof body.label === 'string' ? body.label : undefined;
1625
+ // Optional tmux wrapping: create tmux session, then node-pty attaches to it
1626
+ const tmuxSession = typeof body.tmuxSession === 'string' ? body.tmuxSession : null;
1627
+ let activeTmuxSession = null;
1628
+ if (tmuxSession && tmuxAvailable && command && cwd) {
1629
+ const sanitizedName = createTmuxSession(tmuxSession, command, args || [], cwd, cols || 200, rows || 50);
1630
+ if (sanitizedName) {
1631
+ // Override: node-pty attaches to the tmux session (use sanitized name)
1632
+ command = 'tmux';
1633
+ args = ['attach-session', '-t', sanitizedName];
1634
+ activeTmuxSession = sanitizedName;
1635
+ log('INFO', `Created tmux session "${sanitizedName}" for terminal`);
1636
+ }
1637
+ // If tmux creation failed, fall through to bare node-pty
1638
+ }
1639
+ let info;
1640
+ try {
1641
+ info = await manager.createSession({ command, args, cols, rows, cwd, env, label });
1642
+ }
1643
+ catch (createErr) {
1644
+ // Clean up orphaned tmux session if node-pty creation failed
1645
+ if (activeTmuxSession) {
1646
+ killTmuxSession(activeTmuxSession);
1647
+ log('WARN', `Cleaned up orphaned tmux session "${activeTmuxSession}" after node-pty failure`);
1648
+ }
1649
+ throw createErr;
1650
+ }
1651
+ // Optional project association: register terminal with project state
1652
+ const projectPath = typeof body.projectPath === 'string' ? body.projectPath : null;
1653
+ const termType = typeof body.type === 'string' && ['builder', 'shell'].includes(body.type) ? body.type : null;
1654
+ const roleId = typeof body.roleId === 'string' ? body.roleId : null;
1655
+ if (projectPath && termType && roleId) {
1656
+ const entry = getProjectTerminalsEntry(normalizeProjectPath(projectPath));
1657
+ if (termType === 'builder') {
1658
+ entry.builders.set(roleId, info.id);
1659
+ }
1660
+ else {
1661
+ entry.shells.set(roleId, info.id);
1662
+ }
1663
+ saveTerminalSession(info.id, projectPath, termType, roleId, info.pid, activeTmuxSession);
1664
+ log('INFO', `Registered terminal ${info.id} as ${termType} "${roleId}" for project ${projectPath}${activeTmuxSession ? ` (tmux: ${activeTmuxSession})` : ''}`);
1665
+ }
1666
+ // Return tmuxSession so caller knows whether tmux is backing this terminal
1667
+ res.writeHead(201, { 'Content-Type': 'application/json' });
1668
+ res.end(JSON.stringify({ ...info, wsPath: `/ws/terminal/${info.id}`, tmuxSession: activeTmuxSession }));
1669
+ }
1670
+ catch (err) {
1671
+ const message = err instanceof Error ? err.message : 'Unknown error';
1672
+ log('ERROR', `Failed to create terminal: ${message}`);
1673
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1674
+ res.end(JSON.stringify({ error: 'INTERNAL_ERROR', message }));
1675
+ }
1676
+ return;
1677
+ }
1678
+ // GET /api/terminals - List all terminals
1679
+ if (req.method === 'GET' && url.pathname === '/api/terminals') {
1680
+ const manager = getTerminalManager();
1681
+ const terminals = manager.listSessions();
1682
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1683
+ res.end(JSON.stringify({ terminals }));
1684
+ return;
1685
+ }
1686
+ // Terminal-specific routes: /api/terminals/:id/*
1687
+ const terminalRouteMatch = url.pathname.match(/^\/api\/terminals\/([^/]+)(\/.*)?$/);
1688
+ if (terminalRouteMatch) {
1689
+ const [, terminalId, subpath] = terminalRouteMatch;
1690
+ const manager = getTerminalManager();
1691
+ // GET /api/terminals/:id - Get terminal info
1692
+ if (req.method === 'GET' && (!subpath || subpath === '')) {
1693
+ const session = manager.getSession(terminalId);
1694
+ if (!session) {
1695
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1696
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1697
+ return;
1698
+ }
1699
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1700
+ res.end(JSON.stringify(session.info));
1701
+ return;
1702
+ }
1703
+ // DELETE /api/terminals/:id - Kill terminal
1704
+ if (req.method === 'DELETE' && (!subpath || subpath === '')) {
1705
+ if (!manager.killSession(terminalId)) {
1706
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1707
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1708
+ return;
1709
+ }
1710
+ // TICK-001: Delete from SQLite
1711
+ deleteTerminalSession(terminalId);
1712
+ res.writeHead(204);
1713
+ res.end();
1714
+ return;
1715
+ }
1716
+ // POST /api/terminals/:id/resize - Resize terminal
1717
+ if (req.method === 'POST' && subpath === '/resize') {
1718
+ try {
1719
+ const body = await parseJsonBody(req);
1720
+ if (typeof body.cols !== 'number' || typeof body.rows !== 'number') {
1721
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1722
+ res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'cols and rows must be numbers' }));
1723
+ return;
1724
+ }
1725
+ const info = manager.resizeSession(terminalId, body.cols, body.rows);
1726
+ if (!info) {
1727
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1728
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1729
+ return;
1730
+ }
1731
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1732
+ res.end(JSON.stringify(info));
1733
+ }
1734
+ catch {
1735
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1736
+ res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'Invalid JSON body' }));
1737
+ }
1738
+ return;
1739
+ }
1740
+ // GET /api/terminals/:id/output - Get terminal output
1741
+ if (req.method === 'GET' && subpath === '/output') {
1742
+ const lines = parseInt(url.searchParams.get('lines') ?? '100', 10);
1743
+ const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
1744
+ const output = manager.getOutput(terminalId, lines, offset);
1745
+ if (!output) {
1746
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1747
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1748
+ return;
1749
+ }
1750
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1751
+ res.end(JSON.stringify(output));
1752
+ return;
1753
+ }
1754
+ }
1755
+ // =========================================================================
1756
+ // EXISTING API ENDPOINTS
1757
+ // =========================================================================
1758
+ // API: Get status of all instances (legacy - kept for backward compat)
402
1759
  if (req.method === 'GET' && url.pathname === '/api/status') {
403
1760
  const instances = await getInstances();
404
1761
  res.writeHead(200, { 'Content-Type': 'application/json' });
405
1762
  res.end(JSON.stringify({ instances }));
406
1763
  return;
407
1764
  }
1765
+ // API: Server-Sent Events for push notifications
1766
+ if (req.method === 'GET' && url.pathname === '/api/events') {
1767
+ const clientId = crypto.randomBytes(8).toString('hex');
1768
+ res.writeHead(200, {
1769
+ 'Content-Type': 'text/event-stream',
1770
+ 'Cache-Control': 'no-cache',
1771
+ Connection: 'keep-alive',
1772
+ });
1773
+ // Send initial connection event
1774
+ res.write(`data: ${JSON.stringify({ type: 'connected', id: clientId })}\n\n`);
1775
+ const client = { res, id: clientId };
1776
+ sseClients.push(client);
1777
+ log('INFO', `SSE client connected: ${clientId} (total: ${sseClients.length})`);
1778
+ // Clean up on disconnect
1779
+ req.on('close', () => {
1780
+ const index = sseClients.findIndex((c) => c.id === clientId);
1781
+ if (index !== -1) {
1782
+ sseClients.splice(index, 1);
1783
+ }
1784
+ log('INFO', `SSE client disconnected: ${clientId} (total: ${sseClients.length})`);
1785
+ });
1786
+ return;
1787
+ }
1788
+ // API: Receive notification from builder
1789
+ if (req.method === 'POST' && url.pathname === '/api/notify') {
1790
+ const body = await parseJsonBody(req);
1791
+ const type = typeof body.type === 'string' ? body.type : 'info';
1792
+ const title = typeof body.title === 'string' ? body.title : '';
1793
+ const messageBody = typeof body.body === 'string' ? body.body : '';
1794
+ const project = typeof body.project === 'string' ? body.project : undefined;
1795
+ if (!title || !messageBody) {
1796
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1797
+ res.end(JSON.stringify({ success: false, error: 'Missing title or body' }));
1798
+ return;
1799
+ }
1800
+ // Broadcast to all connected SSE clients
1801
+ broadcastNotification({
1802
+ type,
1803
+ title,
1804
+ body: messageBody,
1805
+ project,
1806
+ });
1807
+ log('INFO', `Notification broadcast: ${title}`);
1808
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1809
+ res.end(JSON.stringify({ success: true }));
1810
+ return;
1811
+ }
408
1812
  // API: Browse directories for autocomplete
409
1813
  if (req.method === 'GET' && url.pathname === '/api/browse') {
410
1814
  const inputPath = url.searchParams.get('path') || '';
@@ -488,27 +1892,70 @@ const server = http.createServer(async (req, res) => {
488
1892
  // API: Launch new instance
489
1893
  if (req.method === 'POST' && url.pathname === '/api/launch') {
490
1894
  const body = await parseJsonBody(req);
491
- const projectPath = body.projectPath;
1895
+ let projectPath = body.projectPath;
492
1896
  if (!projectPath) {
493
1897
  res.writeHead(400, { 'Content-Type': 'application/json' });
494
1898
  res.end(JSON.stringify({ success: false, error: 'Missing projectPath' }));
495
1899
  return;
496
1900
  }
1901
+ // Expand ~ to home directory
1902
+ if (projectPath.startsWith('~')) {
1903
+ projectPath = projectPath.replace('~', homedir());
1904
+ }
1905
+ // Reject relative paths — tower daemon CWD is unpredictable
1906
+ if (!path.isAbsolute(projectPath)) {
1907
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1908
+ res.end(JSON.stringify({
1909
+ success: false,
1910
+ error: `Relative paths are not supported. Use an absolute path (e.g., /Users/.../project or ~/Development/project).`,
1911
+ }));
1912
+ return;
1913
+ }
1914
+ // Normalize path (resolve .. segments, trailing slashes)
1915
+ projectPath = path.resolve(projectPath);
497
1916
  const result = await launchInstance(projectPath);
498
1917
  res.writeHead(result.success ? 200 : 400, { 'Content-Type': 'application/json' });
499
1918
  res.end(JSON.stringify(result));
500
1919
  return;
501
1920
  }
1921
+ // API: Get tunnel status (cloudflared availability and running tunnel)
1922
+ if (req.method === 'GET' && url.pathname === '/api/tunnel/status') {
1923
+ const status = getTunnelStatus();
1924
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1925
+ res.end(JSON.stringify(status));
1926
+ return;
1927
+ }
1928
+ // API: Start cloudflared tunnel
1929
+ if (req.method === 'POST' && url.pathname === '/api/tunnel/start') {
1930
+ const result = await startTunnel(port);
1931
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1932
+ res.end(JSON.stringify(result));
1933
+ return;
1934
+ }
1935
+ // API: Stop cloudflared tunnel
1936
+ if (req.method === 'POST' && url.pathname === '/api/tunnel/stop') {
1937
+ const result = stopTunnel();
1938
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1939
+ res.end(JSON.stringify(result));
1940
+ return;
1941
+ }
502
1942
  // API: Stop an instance
1943
+ // Phase 4 (Spec 0090): Accept projectPath or basePort for backwards compat
503
1944
  if (req.method === 'POST' && url.pathname === '/api/stop') {
504
1945
  const body = await parseJsonBody(req);
505
- const basePort = body.basePort;
506
- if (!basePort) {
1946
+ let targetPath = body.projectPath;
1947
+ // Backwards compat: if basePort provided, find the project path
1948
+ if (!targetPath && body.basePort) {
1949
+ const allocations = loadPortAllocations();
1950
+ const allocation = allocations.find((a) => a.base_port === body.basePort);
1951
+ targetPath = allocation?.project_path || '';
1952
+ }
1953
+ if (!targetPath) {
507
1954
  res.writeHead(400, { 'Content-Type': 'application/json' });
508
- res.end(JSON.stringify({ success: false, error: 'Missing basePort' }));
1955
+ res.end(JSON.stringify({ success: false, error: 'Missing projectPath or basePort' }));
509
1956
  return;
510
1957
  }
511
- const result = await stopInstance(basePort);
1958
+ const result = await stopInstance(targetPath);
512
1959
  res.writeHead(200, { 'Content-Type': 'application/json' });
513
1960
  res.end(JSON.stringify(result));
514
1961
  return;
@@ -531,6 +1978,712 @@ const server = http.createServer(async (req, res) => {
531
1978
  }
532
1979
  return;
533
1980
  }
1981
+ // Project routes: /project/:base64urlPath/*
1982
+ // Phase 4 (Spec 0090): Tower serves React dashboard and handles APIs directly
1983
+ // Uses Base64URL (RFC 4648) encoding to avoid issues with slashes in paths
1984
+ if (url.pathname.startsWith('/project/')) {
1985
+ const pathParts = url.pathname.split('/');
1986
+ // ['', 'project', base64urlPath, ...rest]
1987
+ const encodedPath = pathParts[2];
1988
+ const subPath = pathParts.slice(3).join('/');
1989
+ if (!encodedPath) {
1990
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
1991
+ res.end('Missing project path');
1992
+ return;
1993
+ }
1994
+ // Decode Base64URL (RFC 4648)
1995
+ let projectPath;
1996
+ try {
1997
+ projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
1998
+ // Support both POSIX (/) and Windows (C:\) paths
1999
+ if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
2000
+ throw new Error('Invalid project path');
2001
+ }
2002
+ // Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
2003
+ projectPath = normalizeProjectPath(projectPath);
2004
+ }
2005
+ catch {
2006
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
2007
+ res.end('Invalid project path encoding');
2008
+ return;
2009
+ }
2010
+ const basePort = await getBasePortForProject(projectPath);
2011
+ // Phase 4 (Spec 0090): Tower handles everything directly
2012
+ const isApiCall = subPath.startsWith('api/') || subPath === 'api';
2013
+ const isWsPath = subPath.startsWith('ws/') || subPath === 'ws';
2014
+ // GET /file?path=<relative-path> — Read project file by path (for StatusPanel project list)
2015
+ if (req.method === 'GET' && subPath === 'file' && url.searchParams.has('path')) {
2016
+ const relPath = url.searchParams.get('path');
2017
+ const fullPath = path.resolve(projectPath, relPath);
2018
+ // Security: ensure resolved path stays within project directory
2019
+ if (!fullPath.startsWith(projectPath + path.sep) && fullPath !== projectPath) {
2020
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
2021
+ res.end('Forbidden');
2022
+ return;
2023
+ }
2024
+ try {
2025
+ const content = fs.readFileSync(fullPath, 'utf-8');
2026
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
2027
+ res.end(content);
2028
+ }
2029
+ catch {
2030
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
2031
+ res.end('Not found');
2032
+ }
2033
+ return;
2034
+ }
2035
+ // Serve React dashboard static files directly if:
2036
+ // 1. Not an API call
2037
+ // 2. Not a WebSocket path
2038
+ // 3. React dashboard is available
2039
+ // 4. Project doesn't need to be running for static files
2040
+ if (!isApiCall && !isWsPath && hasReactDashboard) {
2041
+ // Determine which static file to serve
2042
+ let staticPath;
2043
+ if (!subPath || subPath === '' || subPath === 'index.html') {
2044
+ staticPath = path.join(reactDashboardPath, 'index.html');
2045
+ }
2046
+ else {
2047
+ // Check if it's a static asset
2048
+ staticPath = path.join(reactDashboardPath, subPath);
2049
+ }
2050
+ // Try to serve the static file
2051
+ if (serveStaticFile(staticPath, res)) {
2052
+ return;
2053
+ }
2054
+ // SPA fallback: serve index.html for client-side routing
2055
+ const indexPath = path.join(reactDashboardPath, 'index.html');
2056
+ if (serveStaticFile(indexPath, res)) {
2057
+ return;
2058
+ }
2059
+ }
2060
+ // Phase 4 (Spec 0090): Handle project APIs directly instead of proxying to dashboard-server
2061
+ if (isApiCall) {
2062
+ const apiPath = subPath.replace(/^api\/?/, '');
2063
+ // GET /api/state - Return project state (architect, builders, shells)
2064
+ if (req.method === 'GET' && (apiPath === 'state' || apiPath === '')) {
2065
+ // Refresh cache via getTerminalsForProject (handles SQLite sync,
2066
+ // tmux reconnection, and tmux discovery in one place)
2067
+ const encodedPath = Buffer.from(projectPath).toString('base64url');
2068
+ const proxyUrl = `/project/${encodedPath}/`;
2069
+ await getTerminalsForProject(projectPath, proxyUrl);
2070
+ // Now read from the refreshed cache
2071
+ const entry = getProjectTerminalsEntry(projectPath);
2072
+ const manager = getTerminalManager();
2073
+ // Build state response compatible with React dashboard
2074
+ const state = {
2075
+ architect: null,
2076
+ builders: [],
2077
+ utils: [],
2078
+ annotations: [],
2079
+ projectName: path.basename(projectPath),
2080
+ };
2081
+ // Add architect if exists
2082
+ if (entry.architect) {
2083
+ const session = manager.getSession(entry.architect);
2084
+ if (session) {
2085
+ state.architect = {
2086
+ port: basePort || 0,
2087
+ pid: session.pid || 0,
2088
+ terminalId: entry.architect,
2089
+ };
2090
+ }
2091
+ }
2092
+ // Add shells from refreshed cache
2093
+ for (const [shellId, terminalId] of entry.shells) {
2094
+ const session = manager.getSession(terminalId);
2095
+ if (session) {
2096
+ state.utils.push({
2097
+ id: shellId,
2098
+ name: `Shell ${shellId.replace('shell-', '')}`,
2099
+ port: basePort || 0,
2100
+ pid: session.pid || 0,
2101
+ terminalId,
2102
+ });
2103
+ }
2104
+ }
2105
+ // Add builders from refreshed cache
2106
+ for (const [builderId, terminalId] of entry.builders) {
2107
+ const session = manager.getSession(terminalId);
2108
+ if (session) {
2109
+ state.builders.push({
2110
+ id: builderId,
2111
+ name: `Builder ${builderId}`,
2112
+ port: basePort || 0,
2113
+ pid: session.pid || 0,
2114
+ status: 'running',
2115
+ phase: '',
2116
+ worktree: '',
2117
+ branch: '',
2118
+ type: 'spec',
2119
+ terminalId,
2120
+ });
2121
+ }
2122
+ }
2123
+ // Add file tabs (Spec 0092 - served through Tower, no separate ports)
2124
+ for (const [tabId, tab] of entry.fileTabs) {
2125
+ state.annotations.push({
2126
+ id: tabId,
2127
+ file: tab.path,
2128
+ port: 0, // No separate port - served through Tower
2129
+ pid: 0, // No separate process
2130
+ });
2131
+ }
2132
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2133
+ res.end(JSON.stringify(state));
2134
+ return;
2135
+ }
2136
+ // POST /api/tabs/shell - Create a new shell terminal
2137
+ if (req.method === 'POST' && apiPath === 'tabs/shell') {
2138
+ try {
2139
+ const manager = getTerminalManager();
2140
+ const shellId = getNextShellId(projectPath);
2141
+ // Wrap in tmux for session persistence
2142
+ let shellCmd = process.env.SHELL || '/bin/bash';
2143
+ let shellArgs = [];
2144
+ const tmuxName = `shell-${path.basename(projectPath)}-${shellId}`;
2145
+ let activeTmuxSession = null;
2146
+ if (tmuxAvailable) {
2147
+ const sanitizedName = createTmuxSession(tmuxName, shellCmd, shellArgs, projectPath, 200, 50);
2148
+ if (sanitizedName) {
2149
+ shellCmd = 'tmux';
2150
+ shellArgs = ['attach-session', '-t', sanitizedName];
2151
+ activeTmuxSession = sanitizedName;
2152
+ }
2153
+ }
2154
+ // Create terminal session
2155
+ const session = await manager.createSession({
2156
+ command: shellCmd,
2157
+ args: shellArgs,
2158
+ cwd: projectPath,
2159
+ label: `Shell ${shellId.replace('shell-', '')}`,
2160
+ env: process.env,
2161
+ });
2162
+ // Register terminal with project
2163
+ const entry = getProjectTerminalsEntry(projectPath);
2164
+ entry.shells.set(shellId, session.id);
2165
+ // TICK-001: Save to SQLite for persistence
2166
+ saveTerminalSession(session.id, projectPath, 'shell', shellId, session.pid, activeTmuxSession);
2167
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2168
+ res.end(JSON.stringify({
2169
+ id: shellId,
2170
+ port: basePort || 0,
2171
+ name: `Shell ${shellId.replace('shell-', '')}`,
2172
+ terminalId: session.id,
2173
+ }));
2174
+ }
2175
+ catch (err) {
2176
+ log('ERROR', `Failed to create shell: ${err.message}`);
2177
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2178
+ res.end(JSON.stringify({ error: err.message }));
2179
+ }
2180
+ return;
2181
+ }
2182
+ // POST /api/tabs/file - Create a file tab (Spec 0092)
2183
+ if (req.method === 'POST' && apiPath === 'tabs/file') {
2184
+ try {
2185
+ const body = await new Promise((resolve) => {
2186
+ let data = '';
2187
+ req.on('data', (chunk) => data += chunk.toString());
2188
+ req.on('end', () => resolve(data));
2189
+ });
2190
+ const { path: filePath, line } = JSON.parse(body || '{}');
2191
+ if (!filePath || typeof filePath !== 'string') {
2192
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2193
+ res.end(JSON.stringify({ error: 'Missing path parameter' }));
2194
+ return;
2195
+ }
2196
+ // Resolve path relative to project
2197
+ const fullPath = path.isAbsolute(filePath)
2198
+ ? filePath
2199
+ : path.join(projectPath, filePath);
2200
+ // Security: ensure path is within project or is absolute path user provided
2201
+ const normalizedFull = path.normalize(fullPath);
2202
+ const normalizedProject = path.normalize(projectPath);
2203
+ if (!normalizedFull.startsWith(normalizedProject) && !path.isAbsolute(filePath)) {
2204
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2205
+ res.end(JSON.stringify({ error: 'Path outside project' }));
2206
+ return;
2207
+ }
2208
+ // Check file exists
2209
+ if (!fs.existsSync(fullPath)) {
2210
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2211
+ res.end(JSON.stringify({ error: 'File not found' }));
2212
+ return;
2213
+ }
2214
+ const entry = getProjectTerminalsEntry(projectPath);
2215
+ // Check if already open
2216
+ for (const [id, tab] of entry.fileTabs) {
2217
+ if (tab.path === fullPath) {
2218
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2219
+ res.end(JSON.stringify({ id, existing: true, line }));
2220
+ return;
2221
+ }
2222
+ }
2223
+ // Create new file tab
2224
+ const id = `file-${Date.now().toString(36)}`;
2225
+ entry.fileTabs.set(id, { id, path: fullPath, createdAt: Date.now() });
2226
+ log('INFO', `Created file tab: ${id} for ${path.basename(fullPath)}`);
2227
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2228
+ res.end(JSON.stringify({ id, existing: false, line }));
2229
+ }
2230
+ catch (err) {
2231
+ log('ERROR', `Failed to create file tab: ${err.message}`);
2232
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2233
+ res.end(JSON.stringify({ error: err.message }));
2234
+ }
2235
+ return;
2236
+ }
2237
+ // GET /api/file/:id - Get file content as JSON (Spec 0092)
2238
+ const fileGetMatch = apiPath.match(/^file\/([^/]+)$/);
2239
+ if (req.method === 'GET' && fileGetMatch) {
2240
+ const tabId = fileGetMatch[1];
2241
+ const entry = getProjectTerminalsEntry(projectPath);
2242
+ const tab = entry.fileTabs.get(tabId);
2243
+ if (!tab) {
2244
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2245
+ res.end(JSON.stringify({ error: 'File tab not found' }));
2246
+ return;
2247
+ }
2248
+ try {
2249
+ const ext = path.extname(tab.path).slice(1).toLowerCase();
2250
+ const isText = !['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'mp4', 'webm', 'mov', 'pdf'].includes(ext);
2251
+ if (isText) {
2252
+ const content = fs.readFileSync(tab.path, 'utf-8');
2253
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2254
+ res.end(JSON.stringify({
2255
+ path: tab.path,
2256
+ name: path.basename(tab.path),
2257
+ content,
2258
+ language: getLanguageForExt(ext),
2259
+ isMarkdown: ext === 'md',
2260
+ isImage: false,
2261
+ isVideo: false,
2262
+ }));
2263
+ }
2264
+ else {
2265
+ // For binary files, just return metadata
2266
+ const stat = fs.statSync(tab.path);
2267
+ const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext);
2268
+ const isVideo = ['mp4', 'webm', 'mov'].includes(ext);
2269
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2270
+ res.end(JSON.stringify({
2271
+ path: tab.path,
2272
+ name: path.basename(tab.path),
2273
+ content: null,
2274
+ language: ext,
2275
+ isMarkdown: false,
2276
+ isImage,
2277
+ isVideo,
2278
+ size: stat.size,
2279
+ }));
2280
+ }
2281
+ }
2282
+ catch (err) {
2283
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2284
+ res.end(JSON.stringify({ error: err.message }));
2285
+ }
2286
+ return;
2287
+ }
2288
+ // GET /api/file/:id/raw - Get raw file content (for images/video) (Spec 0092)
2289
+ const fileRawMatch = apiPath.match(/^file\/([^/]+)\/raw$/);
2290
+ if (req.method === 'GET' && fileRawMatch) {
2291
+ const tabId = fileRawMatch[1];
2292
+ const entry = getProjectTerminalsEntry(projectPath);
2293
+ const tab = entry.fileTabs.get(tabId);
2294
+ if (!tab) {
2295
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
2296
+ res.end('File tab not found');
2297
+ return;
2298
+ }
2299
+ try {
2300
+ const data = fs.readFileSync(tab.path);
2301
+ const mimeType = getMimeTypeForFile(tab.path);
2302
+ res.writeHead(200, {
2303
+ 'Content-Type': mimeType,
2304
+ 'Content-Length': data.length,
2305
+ 'Cache-Control': 'no-cache',
2306
+ });
2307
+ res.end(data);
2308
+ }
2309
+ catch (err) {
2310
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
2311
+ res.end(err.message);
2312
+ }
2313
+ return;
2314
+ }
2315
+ // POST /api/file/:id/save - Save file content (Spec 0092)
2316
+ const fileSaveMatch = apiPath.match(/^file\/([^/]+)\/save$/);
2317
+ if (req.method === 'POST' && fileSaveMatch) {
2318
+ const tabId = fileSaveMatch[1];
2319
+ const entry = getProjectTerminalsEntry(projectPath);
2320
+ const tab = entry.fileTabs.get(tabId);
2321
+ if (!tab) {
2322
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2323
+ res.end(JSON.stringify({ error: 'File tab not found' }));
2324
+ return;
2325
+ }
2326
+ try {
2327
+ const body = await new Promise((resolve) => {
2328
+ let data = '';
2329
+ req.on('data', (chunk) => data += chunk.toString());
2330
+ req.on('end', () => resolve(data));
2331
+ });
2332
+ const { content } = JSON.parse(body || '{}');
2333
+ if (typeof content !== 'string') {
2334
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2335
+ res.end(JSON.stringify({ error: 'Missing content parameter' }));
2336
+ return;
2337
+ }
2338
+ fs.writeFileSync(tab.path, content, 'utf-8');
2339
+ log('INFO', `Saved file: ${tab.path}`);
2340
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2341
+ res.end(JSON.stringify({ success: true }));
2342
+ }
2343
+ catch (err) {
2344
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2345
+ res.end(JSON.stringify({ error: err.message }));
2346
+ }
2347
+ return;
2348
+ }
2349
+ // DELETE /api/tabs/:id - Delete a terminal or file tab
2350
+ const deleteMatch = apiPath.match(/^tabs\/(.+)$/);
2351
+ if (req.method === 'DELETE' && deleteMatch) {
2352
+ const tabId = deleteMatch[1];
2353
+ const entry = getProjectTerminalsEntry(projectPath);
2354
+ const manager = getTerminalManager();
2355
+ // Check if it's a file tab first (Spec 0092)
2356
+ if (tabId.startsWith('file-')) {
2357
+ if (entry.fileTabs.has(tabId)) {
2358
+ entry.fileTabs.delete(tabId);
2359
+ log('INFO', `Deleted file tab: ${tabId}`);
2360
+ res.writeHead(204);
2361
+ res.end();
2362
+ }
2363
+ else {
2364
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2365
+ res.end(JSON.stringify({ error: 'File tab not found' }));
2366
+ }
2367
+ return;
2368
+ }
2369
+ // Find and delete the terminal
2370
+ let terminalId;
2371
+ if (tabId.startsWith('shell-')) {
2372
+ terminalId = entry.shells.get(tabId);
2373
+ if (terminalId) {
2374
+ entry.shells.delete(tabId);
2375
+ }
2376
+ }
2377
+ else if (tabId.startsWith('builder-')) {
2378
+ terminalId = entry.builders.get(tabId);
2379
+ if (terminalId) {
2380
+ entry.builders.delete(tabId);
2381
+ }
2382
+ }
2383
+ else if (tabId === 'architect') {
2384
+ terminalId = entry.architect;
2385
+ if (terminalId) {
2386
+ entry.architect = undefined;
2387
+ }
2388
+ }
2389
+ if (terminalId) {
2390
+ manager.killSession(terminalId);
2391
+ // TICK-001: Delete from SQLite
2392
+ deleteTerminalSession(terminalId);
2393
+ res.writeHead(204);
2394
+ res.end();
2395
+ }
2396
+ else {
2397
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2398
+ res.end(JSON.stringify({ error: 'Tab not found' }));
2399
+ }
2400
+ return;
2401
+ }
2402
+ // POST /api/stop - Stop all terminals for project
2403
+ if (req.method === 'POST' && apiPath === 'stop') {
2404
+ const entry = getProjectTerminalsEntry(projectPath);
2405
+ const manager = getTerminalManager();
2406
+ // Kill all terminals
2407
+ if (entry.architect) {
2408
+ manager.killSession(entry.architect);
2409
+ }
2410
+ for (const terminalId of entry.shells.values()) {
2411
+ manager.killSession(terminalId);
2412
+ }
2413
+ for (const terminalId of entry.builders.values()) {
2414
+ manager.killSession(terminalId);
2415
+ }
2416
+ // Clear registry
2417
+ projectTerminals.delete(projectPath);
2418
+ // TICK-001: Delete all terminal sessions from SQLite
2419
+ deleteProjectTerminalSessions(projectPath);
2420
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2421
+ res.end(JSON.stringify({ ok: true }));
2422
+ return;
2423
+ }
2424
+ // GET /api/files - Return project directory tree for file browser (Spec 0092)
2425
+ if (req.method === 'GET' && apiPath === 'files') {
2426
+ const maxDepth = parseInt(url.searchParams.get('depth') || '3', 10);
2427
+ const ignore = new Set(['.git', 'node_modules', '.builders', 'dist', '.agent-farm', '.next', '.cache', '__pycache__']);
2428
+ function readTree(dir, depth) {
2429
+ if (depth <= 0)
2430
+ return [];
2431
+ try {
2432
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
2433
+ return entries
2434
+ .filter(e => !e.name.startsWith('.') || e.name === '.env.example')
2435
+ .filter(e => !ignore.has(e.name))
2436
+ .sort((a, b) => {
2437
+ // Directories first, then alphabetical
2438
+ if (a.isDirectory() && !b.isDirectory())
2439
+ return -1;
2440
+ if (!a.isDirectory() && b.isDirectory())
2441
+ return 1;
2442
+ return a.name.localeCompare(b.name);
2443
+ })
2444
+ .map(e => {
2445
+ const fullPath = path.join(dir, e.name);
2446
+ const relativePath = path.relative(projectPath, fullPath);
2447
+ if (e.isDirectory()) {
2448
+ return { name: e.name, path: relativePath, type: 'directory', children: readTree(fullPath, depth - 1) };
2449
+ }
2450
+ return { name: e.name, path: relativePath, type: 'file' };
2451
+ });
2452
+ }
2453
+ catch {
2454
+ return [];
2455
+ }
2456
+ }
2457
+ const tree = readTree(projectPath, maxDepth);
2458
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2459
+ res.end(JSON.stringify(tree));
2460
+ return;
2461
+ }
2462
+ // GET /api/git/status - Return git status for file browser (Spec 0092)
2463
+ if (req.method === 'GET' && apiPath === 'git/status') {
2464
+ try {
2465
+ // Get git status in porcelain format for parsing
2466
+ const result = execSync('git status --porcelain', {
2467
+ cwd: projectPath,
2468
+ encoding: 'utf-8',
2469
+ timeout: 5000,
2470
+ });
2471
+ // Parse porcelain output: XY filename
2472
+ // X = staging area status, Y = working tree status
2473
+ const modified = [];
2474
+ const staged = [];
2475
+ const untracked = [];
2476
+ for (const line of result.split('\n')) {
2477
+ if (!line)
2478
+ continue;
2479
+ const x = line[0]; // staging area
2480
+ const y = line[1]; // working tree
2481
+ const filepath = line.slice(3);
2482
+ if (x === '?' && y === '?') {
2483
+ untracked.push(filepath);
2484
+ }
2485
+ else {
2486
+ if (x !== ' ' && x !== '?') {
2487
+ staged.push(filepath);
2488
+ }
2489
+ if (y !== ' ' && y !== '?') {
2490
+ modified.push(filepath);
2491
+ }
2492
+ }
2493
+ }
2494
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2495
+ res.end(JSON.stringify({ modified, staged, untracked }));
2496
+ }
2497
+ catch (err) {
2498
+ // Not a git repo or git command failed
2499
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2500
+ res.end(JSON.stringify({ modified: [], staged: [], untracked: [], error: err.message }));
2501
+ }
2502
+ return;
2503
+ }
2504
+ // GET /api/files/recent - Return recently opened file tabs (Spec 0092)
2505
+ if (req.method === 'GET' && apiPath === 'files/recent') {
2506
+ const entry = getProjectTerminalsEntry(projectPath);
2507
+ // Get all file tabs sorted by creation time (most recent first)
2508
+ const recentFiles = Array.from(entry.fileTabs.values())
2509
+ .sort((a, b) => b.createdAt - a.createdAt)
2510
+ .slice(0, 10) // Limit to 10 most recent
2511
+ .map(tab => ({
2512
+ id: tab.id,
2513
+ path: tab.path,
2514
+ name: path.basename(tab.path),
2515
+ relativePath: path.relative(projectPath, tab.path),
2516
+ }));
2517
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2518
+ res.end(JSON.stringify(recentFiles));
2519
+ return;
2520
+ }
2521
+ // GET /api/annotate/:tabId/* — Serve rich annotator template and sub-APIs
2522
+ const annotateMatch = apiPath.match(/^annotate\/([^/]+)(\/(.*))?$/);
2523
+ if (annotateMatch) {
2524
+ const tabId = annotateMatch[1];
2525
+ const subRoute = annotateMatch[3] || '';
2526
+ const entry = getProjectTerminalsEntry(projectPath);
2527
+ const tab = entry.fileTabs.get(tabId);
2528
+ if (!tab) {
2529
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
2530
+ res.end('File tab not found');
2531
+ return;
2532
+ }
2533
+ const filePath = tab.path;
2534
+ const ext = path.extname(filePath).slice(1).toLowerCase();
2535
+ const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext);
2536
+ const isVideo = ['mp4', 'webm', 'mov'].includes(ext);
2537
+ const is3D = ['stl', '3mf'].includes(ext);
2538
+ const isPdf = ext === 'pdf';
2539
+ const isMarkdown = ext === 'md';
2540
+ // Sub-route: GET /file — re-read file content from disk
2541
+ if (req.method === 'GET' && subRoute === 'file') {
2542
+ try {
2543
+ const content = fs.readFileSync(filePath, 'utf-8');
2544
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
2545
+ res.end(content);
2546
+ }
2547
+ catch (err) {
2548
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
2549
+ res.end(err.message);
2550
+ }
2551
+ return;
2552
+ }
2553
+ // Sub-route: POST /save — save file content
2554
+ if (req.method === 'POST' && subRoute === 'save') {
2555
+ try {
2556
+ const body = await new Promise((resolve) => {
2557
+ let data = '';
2558
+ req.on('data', (chunk) => data += chunk.toString());
2559
+ req.on('end', () => resolve(data));
2560
+ });
2561
+ const parsed = JSON.parse(body || '{}');
2562
+ const fileContent = parsed.content;
2563
+ if (typeof fileContent !== 'string') {
2564
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
2565
+ res.end('Missing content');
2566
+ return;
2567
+ }
2568
+ fs.writeFileSync(filePath, fileContent, 'utf-8');
2569
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2570
+ res.end(JSON.stringify({ ok: true }));
2571
+ }
2572
+ catch (err) {
2573
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
2574
+ res.end(err.message);
2575
+ }
2576
+ return;
2577
+ }
2578
+ // Sub-route: GET /api/mtime — file modification time
2579
+ if (req.method === 'GET' && subRoute === 'api/mtime') {
2580
+ try {
2581
+ const stat = fs.statSync(filePath);
2582
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2583
+ res.end(JSON.stringify({ mtime: stat.mtimeMs }));
2584
+ }
2585
+ catch (err) {
2586
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
2587
+ res.end(err.message);
2588
+ }
2589
+ return;
2590
+ }
2591
+ // Sub-route: GET /api/image, /api/video, /api/model, /api/pdf — raw binary content
2592
+ if (req.method === 'GET' && (subRoute === 'api/image' || subRoute === 'api/video' || subRoute === 'api/model' || subRoute === 'api/pdf')) {
2593
+ try {
2594
+ const data = fs.readFileSync(filePath);
2595
+ const mimeType = getMimeTypeForFile(filePath);
2596
+ res.writeHead(200, {
2597
+ 'Content-Type': mimeType,
2598
+ 'Content-Length': data.length,
2599
+ 'Cache-Control': 'no-cache',
2600
+ });
2601
+ res.end(data);
2602
+ }
2603
+ catch (err) {
2604
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
2605
+ res.end(err.message);
2606
+ }
2607
+ return;
2608
+ }
2609
+ // Default: serve the annotator HTML template
2610
+ if (req.method === 'GET' && (subRoute === '' || subRoute === undefined)) {
2611
+ try {
2612
+ const templateFile = is3D ? '3d-viewer.html' : 'open.html';
2613
+ const tplPath = path.resolve(__dirname, `../../../templates/${templateFile}`);
2614
+ let html = fs.readFileSync(tplPath, 'utf-8');
2615
+ const fileName = path.basename(filePath);
2616
+ const fileSize = fs.statSync(filePath).size;
2617
+ if (is3D) {
2618
+ html = html.replace(/\{\{FILE\}\}/g, fileName);
2619
+ html = html.replace(/\{\{FILE_PATH_JSON\}\}/g, JSON.stringify(filePath));
2620
+ html = html.replace(/\{\{FORMAT\}\}/g, ext);
2621
+ }
2622
+ else {
2623
+ html = html.replace(/\{\{FILE\}\}/g, fileName);
2624
+ html = html.replace(/\{\{FILE_PATH\}\}/g, filePath);
2625
+ html = html.replace(/\{\{BUILDER_ID\}\}/g, '');
2626
+ html = html.replace(/\{\{LANG\}\}/g, getLanguageForExt(ext));
2627
+ html = html.replace(/\{\{IS_MARKDOWN\}\}/g, String(isMarkdown));
2628
+ html = html.replace(/\{\{IS_IMAGE\}\}/g, String(isImage));
2629
+ html = html.replace(/\{\{IS_VIDEO\}\}/g, String(isVideo));
2630
+ html = html.replace(/\{\{IS_PDF\}\}/g, String(isPdf));
2631
+ html = html.replace(/\{\{FILE_SIZE\}\}/g, String(fileSize));
2632
+ // Inject initialization script (template loads content via fetch)
2633
+ let initScript;
2634
+ if (isImage) {
2635
+ initScript = `initImage(${fileSize});`;
2636
+ }
2637
+ else if (isVideo) {
2638
+ initScript = `initVideo(${fileSize});`;
2639
+ }
2640
+ else if (isPdf) {
2641
+ initScript = `initPdf(${fileSize});`;
2642
+ }
2643
+ else {
2644
+ initScript = `fetch('file').then(r=>r.text()).then(init);`;
2645
+ }
2646
+ html = html.replace('// FILE_CONTENT will be injected by the server', initScript);
2647
+ }
2648
+ // Handle ?line= query param for scroll-to-line
2649
+ const lineParam = url.searchParams.get('line');
2650
+ if (lineParam) {
2651
+ 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>`;
2652
+ html = html.replace('</body>', `${scrollScript}</body>`);
2653
+ }
2654
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
2655
+ res.end(html);
2656
+ }
2657
+ catch (err) {
2658
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
2659
+ res.end(`Failed to serve annotator: ${err.message}`);
2660
+ }
2661
+ return;
2662
+ }
2663
+ }
2664
+ // Unhandled API route
2665
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2666
+ res.end(JSON.stringify({ error: 'API endpoint not found', path: apiPath }));
2667
+ return;
2668
+ }
2669
+ // For WebSocket paths, let the upgrade handler deal with it
2670
+ if (isWsPath) {
2671
+ // WebSocket paths are handled by the upgrade handler
2672
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
2673
+ res.end('WebSocket connections should use ws:// protocol');
2674
+ return;
2675
+ }
2676
+ // If we get here for non-API, non-WS paths and React dashboard is not available
2677
+ if (!hasReactDashboard) {
2678
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
2679
+ res.end('Dashboard not available');
2680
+ return;
2681
+ }
2682
+ // Fallback for unmatched paths
2683
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
2684
+ res.end('Not found');
2685
+ return;
2686
+ }
534
2687
  // 404 for everything else
535
2688
  res.writeHead(404, { 'Content-Type': 'text/plain' });
536
2689
  res.end('Not found');
@@ -542,8 +2695,86 @@ const server = http.createServer(async (req, res) => {
542
2695
  }
543
2696
  });
544
2697
  // SECURITY: Bind to localhost only to prevent network exposure
545
- server.listen(port, '127.0.0.1', () => {
2698
+ server.listen(port, '127.0.0.1', async () => {
546
2699
  log('INFO', `Tower server listening at http://localhost:${port}`);
2700
+ // Check tmux availability once at startup
2701
+ tmuxAvailable = checkTmux();
2702
+ log('INFO', `tmux available: ${tmuxAvailable}${tmuxAvailable ? '' : ' (terminals will not persist across restarts)'}`);
2703
+ // TICK-001: Reconcile terminal sessions from previous run
2704
+ await reconcileTerminalSessions();
2705
+ });
2706
+ // Initialize terminal WebSocket server (Phase 2 - Spec 0090)
2707
+ terminalWss = new WebSocketServer({ noServer: true });
2708
+ // WebSocket upgrade handler for terminal connections and proxying
2709
+ server.on('upgrade', async (req, socket, head) => {
2710
+ const reqUrl = new URL(req.url || '/', `http://localhost:${port}`);
2711
+ // Phase 2: Handle /ws/terminal/:id routes directly
2712
+ const terminalMatch = reqUrl.pathname.match(/^\/ws\/terminal\/([^/]+)$/);
2713
+ if (terminalMatch) {
2714
+ const terminalId = terminalMatch[1];
2715
+ const manager = getTerminalManager();
2716
+ const session = manager.getSession(terminalId);
2717
+ if (!session) {
2718
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
2719
+ socket.destroy();
2720
+ return;
2721
+ }
2722
+ terminalWss.handleUpgrade(req, socket, head, (ws) => {
2723
+ handleTerminalWebSocket(ws, session, req);
2724
+ });
2725
+ return;
2726
+ }
2727
+ // Phase 4 (Spec 0090): Handle project WebSocket routes directly
2728
+ // Route: /project/:encodedPath/ws/terminal/:terminalId
2729
+ if (!reqUrl.pathname.startsWith('/project/')) {
2730
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
2731
+ socket.destroy();
2732
+ return;
2733
+ }
2734
+ const pathParts = reqUrl.pathname.split('/');
2735
+ // ['', 'project', base64urlPath, 'ws', 'terminal', terminalId]
2736
+ const encodedPath = pathParts[2];
2737
+ if (!encodedPath) {
2738
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
2739
+ socket.destroy();
2740
+ return;
2741
+ }
2742
+ // Decode Base64URL (RFC 4648) - NOT URL encoding
2743
+ // Wrap in try/catch to handle malformed Base64 input gracefully
2744
+ let projectPath;
2745
+ try {
2746
+ projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
2747
+ // Support both POSIX (/) and Windows (C:\) paths
2748
+ if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
2749
+ throw new Error('Invalid project path');
2750
+ }
2751
+ // Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
2752
+ projectPath = normalizeProjectPath(projectPath);
2753
+ }
2754
+ catch {
2755
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
2756
+ socket.destroy();
2757
+ return;
2758
+ }
2759
+ // Check for terminal WebSocket route: /project/:path/ws/terminal/:id
2760
+ const wsMatch = reqUrl.pathname.match(/^\/project\/[^/]+\/ws\/terminal\/([^/]+)$/);
2761
+ if (wsMatch) {
2762
+ const terminalId = wsMatch[1];
2763
+ const manager = getTerminalManager();
2764
+ const session = manager.getSession(terminalId);
2765
+ if (!session) {
2766
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
2767
+ socket.destroy();
2768
+ return;
2769
+ }
2770
+ terminalWss.handleUpgrade(req, socket, head, (ws) => {
2771
+ handleTerminalWebSocket(ws, session, req);
2772
+ });
2773
+ return;
2774
+ }
2775
+ // Unhandled WebSocket route
2776
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
2777
+ socket.destroy();
547
2778
  });
548
2779
  // Handle uncaught errors
549
2780
  process.on('uncaughtException', (err) => {