@cluesmith/codev 2.0.0-rc.7 → 2.0.0-rc.70

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 (456) 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-C7FtNK6Y.css +32 -0
  5. package/dashboard/dist/assets/index-CDAINZKT.js +131 -0
  6. package/dashboard/dist/assets/index-CDAINZKT.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 +173 -118
  10. package/dist/agent-farm/cli.js.map +1 -1
  11. package/dist/agent-farm/commands/architect.d.ts +3 -3
  12. package/dist/agent-farm/commands/architect.d.ts.map +1 -1
  13. package/dist/agent-farm/commands/architect.js +20 -147
  14. package/dist/agent-farm/commands/architect.js.map +1 -1
  15. package/dist/agent-farm/commands/attach.d.ts +13 -0
  16. package/dist/agent-farm/commands/attach.d.ts.map +1 -0
  17. package/dist/agent-farm/commands/attach.js +144 -0
  18. package/dist/agent-farm/commands/attach.js.map +1 -0
  19. package/dist/agent-farm/commands/cleanup.d.ts.map +1 -1
  20. package/dist/agent-farm/commands/cleanup.js +35 -19
  21. package/dist/agent-farm/commands/cleanup.js.map +1 -1
  22. package/dist/agent-farm/commands/consult.d.ts +3 -4
  23. package/dist/agent-farm/commands/consult.d.ts.map +1 -1
  24. package/dist/agent-farm/commands/consult.js +27 -37
  25. package/dist/agent-farm/commands/consult.js.map +1 -1
  26. package/dist/agent-farm/commands/index.d.ts +2 -2
  27. package/dist/agent-farm/commands/index.d.ts.map +1 -1
  28. package/dist/agent-farm/commands/index.js +2 -2
  29. package/dist/agent-farm/commands/index.js.map +1 -1
  30. package/dist/agent-farm/commands/open.d.ts +4 -2
  31. package/dist/agent-farm/commands/open.d.ts.map +1 -1
  32. package/dist/agent-farm/commands/open.js +33 -83
  33. package/dist/agent-farm/commands/open.js.map +1 -1
  34. package/dist/agent-farm/commands/send.d.ts +1 -1
  35. package/dist/agent-farm/commands/send.d.ts.map +1 -1
  36. package/dist/agent-farm/commands/send.js +60 -79
  37. package/dist/agent-farm/commands/send.js.map +1 -1
  38. package/dist/agent-farm/commands/shell.d.ts +15 -0
  39. package/dist/agent-farm/commands/shell.d.ts.map +1 -0
  40. package/dist/agent-farm/commands/shell.js +50 -0
  41. package/dist/agent-farm/commands/shell.js.map +1 -0
  42. package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
  43. package/dist/agent-farm/commands/spawn.js +597 -281
  44. package/dist/agent-farm/commands/spawn.js.map +1 -1
  45. package/dist/agent-farm/commands/start.d.ts +10 -20
  46. package/dist/agent-farm/commands/start.d.ts.map +1 -1
  47. package/dist/agent-farm/commands/start.js +45 -491
  48. package/dist/agent-farm/commands/start.js.map +1 -1
  49. package/dist/agent-farm/commands/status.d.ts +2 -0
  50. package/dist/agent-farm/commands/status.d.ts.map +1 -1
  51. package/dist/agent-farm/commands/status.js +75 -24
  52. package/dist/agent-farm/commands/status.js.map +1 -1
  53. package/dist/agent-farm/commands/stop.d.ts +6 -0
  54. package/dist/agent-farm/commands/stop.d.ts.map +1 -1
  55. package/dist/agent-farm/commands/stop.js +49 -109
  56. package/dist/agent-farm/commands/stop.js.map +1 -1
  57. package/dist/agent-farm/commands/tower-cloud.d.ts +48 -0
  58. package/dist/agent-farm/commands/tower-cloud.d.ts.map +1 -0
  59. package/dist/agent-farm/commands/tower-cloud.js +334 -0
  60. package/dist/agent-farm/commands/tower-cloud.js.map +1 -0
  61. package/dist/agent-farm/commands/tower.d.ts +9 -0
  62. package/dist/agent-farm/commands/tower.d.ts.map +1 -1
  63. package/dist/agent-farm/commands/tower.js +59 -19
  64. package/dist/agent-farm/commands/tower.js.map +1 -1
  65. package/dist/agent-farm/db/index.d.ts +6 -2
  66. package/dist/agent-farm/db/index.d.ts.map +1 -1
  67. package/dist/agent-farm/db/index.js +246 -18
  68. package/dist/agent-farm/db/index.js.map +1 -1
  69. package/dist/agent-farm/db/migrate.d.ts +0 -4
  70. package/dist/agent-farm/db/migrate.d.ts.map +1 -1
  71. package/dist/agent-farm/db/migrate.js +6 -55
  72. package/dist/agent-farm/db/migrate.js.map +1 -1
  73. package/dist/agent-farm/db/schema.d.ts +3 -3
  74. package/dist/agent-farm/db/schema.d.ts.map +1 -1
  75. package/dist/agent-farm/db/schema.js +25 -19
  76. package/dist/agent-farm/db/schema.js.map +1 -1
  77. package/dist/agent-farm/db/types.d.ts +3 -13
  78. package/dist/agent-farm/db/types.d.ts.map +1 -1
  79. package/dist/agent-farm/db/types.js +3 -11
  80. package/dist/agent-farm/db/types.js.map +1 -1
  81. package/dist/agent-farm/hq-connector.d.ts +2 -6
  82. package/dist/agent-farm/hq-connector.d.ts.map +1 -1
  83. package/dist/agent-farm/hq-connector.js +2 -17
  84. package/dist/agent-farm/hq-connector.js.map +1 -1
  85. package/dist/agent-farm/lib/cloud-config.d.ts +59 -0
  86. package/dist/agent-farm/lib/cloud-config.d.ts.map +1 -0
  87. package/dist/agent-farm/lib/cloud-config.js +143 -0
  88. package/dist/agent-farm/lib/cloud-config.js.map +1 -0
  89. package/dist/agent-farm/lib/tower-client.d.ts +163 -0
  90. package/dist/agent-farm/lib/tower-client.d.ts.map +1 -0
  91. package/dist/agent-farm/lib/tower-client.js +233 -0
  92. package/dist/agent-farm/lib/tower-client.js.map +1 -0
  93. package/dist/agent-farm/lib/tunnel-client.d.ts +117 -0
  94. package/dist/agent-farm/lib/tunnel-client.d.ts.map +1 -0
  95. package/dist/agent-farm/lib/tunnel-client.js +504 -0
  96. package/dist/agent-farm/lib/tunnel-client.js.map +1 -0
  97. package/dist/agent-farm/servers/tower-server.js +2650 -185
  98. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  99. package/dist/agent-farm/state.d.ts +6 -12
  100. package/dist/agent-farm/state.d.ts.map +1 -1
  101. package/dist/agent-farm/state.js +34 -49
  102. package/dist/agent-farm/state.js.map +1 -1
  103. package/dist/agent-farm/types.d.ts +49 -26
  104. package/dist/agent-farm/types.d.ts.map +1 -1
  105. package/dist/agent-farm/utils/config.d.ts +0 -5
  106. package/dist/agent-farm/utils/config.d.ts.map +1 -1
  107. package/dist/agent-farm/utils/config.js +12 -44
  108. package/dist/agent-farm/utils/config.js.map +1 -1
  109. package/dist/agent-farm/utils/deps.d.ts.map +1 -1
  110. package/dist/agent-farm/utils/deps.js +0 -32
  111. package/dist/agent-farm/utils/deps.js.map +1 -1
  112. package/dist/agent-farm/utils/file-tabs.d.ts +27 -0
  113. package/dist/agent-farm/utils/file-tabs.d.ts.map +1 -0
  114. package/dist/agent-farm/utils/file-tabs.js +46 -0
  115. package/dist/agent-farm/utils/file-tabs.js.map +1 -0
  116. package/dist/agent-farm/utils/gate-status.d.ts +16 -0
  117. package/dist/agent-farm/utils/gate-status.d.ts.map +1 -0
  118. package/dist/agent-farm/utils/gate-status.js +79 -0
  119. package/dist/agent-farm/utils/gate-status.js.map +1 -0
  120. package/dist/agent-farm/utils/gate-watcher.d.ts +38 -0
  121. package/dist/agent-farm/utils/gate-watcher.d.ts.map +1 -0
  122. package/dist/agent-farm/utils/gate-watcher.js +122 -0
  123. package/dist/agent-farm/utils/gate-watcher.js.map +1 -0
  124. package/dist/agent-farm/utils/index.d.ts +0 -1
  125. package/dist/agent-farm/utils/index.d.ts.map +1 -1
  126. package/dist/agent-farm/utils/index.js +0 -1
  127. package/dist/agent-farm/utils/index.js.map +1 -1
  128. package/dist/agent-farm/utils/notifications.d.ts +30 -0
  129. package/dist/agent-farm/utils/notifications.d.ts.map +1 -0
  130. package/dist/agent-farm/utils/notifications.js +121 -0
  131. package/dist/agent-farm/utils/notifications.js.map +1 -0
  132. package/dist/agent-farm/utils/server-utils.d.ts +5 -5
  133. package/dist/agent-farm/utils/server-utils.d.ts.map +1 -1
  134. package/dist/agent-farm/utils/server-utils.js +5 -16
  135. package/dist/agent-farm/utils/server-utils.js.map +1 -1
  136. package/dist/agent-farm/utils/session.d.ts +32 -0
  137. package/dist/agent-farm/utils/session.d.ts.map +1 -0
  138. package/dist/agent-farm/utils/session.js +57 -0
  139. package/dist/agent-farm/utils/session.js.map +1 -0
  140. package/dist/agent-farm/utils/shell.d.ts +9 -22
  141. package/dist/agent-farm/utils/shell.d.ts.map +1 -1
  142. package/dist/agent-farm/utils/shell.js +34 -34
  143. package/dist/agent-farm/utils/shell.js.map +1 -1
  144. package/dist/cli.d.ts.map +1 -1
  145. package/dist/cli.js +11 -54
  146. package/dist/cli.js.map +1 -1
  147. package/dist/commands/adopt.d.ts.map +1 -1
  148. package/dist/commands/adopt.js +49 -4
  149. package/dist/commands/adopt.js.map +1 -1
  150. package/dist/commands/consult/index.d.ts +13 -2
  151. package/dist/commands/consult/index.d.ts.map +1 -1
  152. package/dist/commands/consult/index.js +245 -29
  153. package/dist/commands/consult/index.js.map +1 -1
  154. package/dist/commands/doctor.d.ts.map +1 -1
  155. package/dist/commands/doctor.js +96 -79
  156. package/dist/commands/doctor.js.map +1 -1
  157. package/dist/commands/init.d.ts.map +1 -1
  158. package/dist/commands/init.js +41 -2
  159. package/dist/commands/init.js.map +1 -1
  160. package/dist/commands/porch/build-counter.d.ts +5 -0
  161. package/dist/commands/porch/build-counter.d.ts.map +1 -0
  162. package/dist/commands/porch/build-counter.js +5 -0
  163. package/dist/commands/porch/build-counter.js.map +1 -0
  164. package/dist/commands/porch/checks.d.ts +17 -29
  165. package/dist/commands/porch/checks.d.ts.map +1 -1
  166. package/dist/commands/porch/checks.js +96 -144
  167. package/dist/commands/porch/checks.js.map +1 -1
  168. package/dist/commands/porch/index.d.ts +25 -43
  169. package/dist/commands/porch/index.d.ts.map +1 -1
  170. package/dist/commands/porch/index.js +463 -1116
  171. package/dist/commands/porch/index.js.map +1 -1
  172. package/dist/commands/porch/next.d.ts +22 -0
  173. package/dist/commands/porch/next.d.ts.map +1 -0
  174. package/dist/commands/porch/next.js +571 -0
  175. package/dist/commands/porch/next.js.map +1 -0
  176. package/dist/commands/porch/plan.d.ts +70 -0
  177. package/dist/commands/porch/plan.d.ts.map +1 -0
  178. package/dist/commands/porch/plan.js +190 -0
  179. package/dist/commands/porch/plan.js.map +1 -0
  180. package/dist/commands/porch/prompts.d.ts +19 -0
  181. package/dist/commands/porch/prompts.d.ts.map +1 -0
  182. package/dist/commands/porch/prompts.js +277 -0
  183. package/dist/commands/porch/prompts.js.map +1 -0
  184. package/dist/commands/porch/protocol.d.ts +59 -0
  185. package/dist/commands/porch/protocol.d.ts.map +1 -0
  186. package/dist/commands/porch/protocol.js +294 -0
  187. package/dist/commands/porch/protocol.js.map +1 -0
  188. package/dist/commands/porch/state.d.ts +36 -107
  189. package/dist/commands/porch/state.d.ts.map +1 -1
  190. package/dist/commands/porch/state.js +120 -699
  191. package/dist/commands/porch/state.js.map +1 -1
  192. package/dist/commands/porch/types.d.ts +99 -164
  193. package/dist/commands/porch/types.d.ts.map +1 -1
  194. package/dist/commands/porch/types.js +2 -1
  195. package/dist/commands/porch/types.js.map +1 -1
  196. package/dist/commands/porch/verdict.d.ts +31 -0
  197. package/dist/commands/porch/verdict.d.ts.map +1 -0
  198. package/dist/commands/porch/verdict.js +59 -0
  199. package/dist/commands/porch/verdict.js.map +1 -0
  200. package/dist/commands/update.d.ts.map +1 -1
  201. package/dist/commands/update.js +31 -0
  202. package/dist/commands/update.js.map +1 -1
  203. package/dist/lib/scaffold.d.ts +37 -0
  204. package/dist/lib/scaffold.d.ts.map +1 -1
  205. package/dist/lib/scaffold.js +114 -0
  206. package/dist/lib/scaffold.js.map +1 -1
  207. package/dist/terminal/index.d.ts +8 -0
  208. package/dist/terminal/index.d.ts.map +1 -0
  209. package/dist/terminal/index.js +5 -0
  210. package/dist/terminal/index.js.map +1 -0
  211. package/dist/terminal/pty-manager.d.ts +69 -0
  212. package/dist/terminal/pty-manager.d.ts.map +1 -0
  213. package/dist/terminal/pty-manager.js +377 -0
  214. package/dist/terminal/pty-manager.js.map +1 -0
  215. package/dist/terminal/pty-session.d.ts +104 -0
  216. package/dist/terminal/pty-session.d.ts.map +1 -0
  217. package/dist/terminal/pty-session.js +327 -0
  218. package/dist/terminal/pty-session.js.map +1 -0
  219. package/dist/terminal/ring-buffer.d.ts +34 -0
  220. package/dist/terminal/ring-buffer.d.ts.map +1 -0
  221. package/dist/terminal/ring-buffer.js +94 -0
  222. package/dist/terminal/ring-buffer.js.map +1 -0
  223. package/dist/terminal/session-manager.d.ts +115 -0
  224. package/dist/terminal/session-manager.d.ts.map +1 -0
  225. package/dist/terminal/session-manager.js +582 -0
  226. package/dist/terminal/session-manager.js.map +1 -0
  227. package/dist/terminal/shepherd-client.d.ts +58 -0
  228. package/dist/terminal/shepherd-client.d.ts.map +1 -0
  229. package/dist/terminal/shepherd-client.js +212 -0
  230. package/dist/terminal/shepherd-client.js.map +1 -0
  231. package/dist/terminal/shepherd-main.d.ts +19 -0
  232. package/dist/terminal/shepherd-main.d.ts.map +1 -0
  233. package/dist/terminal/shepherd-main.js +153 -0
  234. package/dist/terminal/shepherd-main.js.map +1 -0
  235. package/dist/terminal/shepherd-process.d.ts +75 -0
  236. package/dist/terminal/shepherd-process.d.ts.map +1 -0
  237. package/dist/terminal/shepherd-process.js +279 -0
  238. package/dist/terminal/shepherd-process.js.map +1 -0
  239. package/dist/terminal/shepherd-protocol.d.ts +115 -0
  240. package/dist/terminal/shepherd-protocol.d.ts.map +1 -0
  241. package/dist/terminal/shepherd-protocol.js +214 -0
  242. package/dist/terminal/shepherd-protocol.js.map +1 -0
  243. package/dist/terminal/shepherd-replay-buffer.d.ts +38 -0
  244. package/dist/terminal/shepherd-replay-buffer.d.ts.map +1 -0
  245. package/dist/terminal/shepherd-replay-buffer.js +94 -0
  246. package/dist/terminal/shepherd-replay-buffer.js.map +1 -0
  247. package/dist/terminal/ws-protocol.d.ts +27 -0
  248. package/dist/terminal/ws-protocol.d.ts.map +1 -0
  249. package/dist/terminal/ws-protocol.js +44 -0
  250. package/dist/terminal/ws-protocol.js.map +1 -0
  251. package/package.json +19 -5
  252. package/skeleton/.claude/skills/af/SKILL.md +89 -0
  253. package/skeleton/.claude/skills/codev/SKILL.md +41 -0
  254. package/skeleton/.claude/skills/consult/SKILL.md +81 -0
  255. package/skeleton/.claude/skills/generate-image/SKILL.md +56 -0
  256. package/skeleton/DEPENDENCIES.md +4 -62
  257. package/skeleton/builders.md +1 -1
  258. package/skeleton/consult-types/impl-review.md +18 -9
  259. package/skeleton/consult-types/integration-review.md +1 -1
  260. package/skeleton/consult-types/plan-review.md +1 -1
  261. package/skeleton/consult-types/pr-ready.md +1 -1
  262. package/skeleton/consult-types/spec-review.md +1 -1
  263. package/skeleton/porch/prompts/defend.md +1 -1
  264. package/skeleton/porch/prompts/evaluate.md +2 -2
  265. package/skeleton/porch/prompts/implement.md +1 -1
  266. package/skeleton/porch/prompts/plan.md +1 -1
  267. package/skeleton/porch/prompts/review.md +4 -4
  268. package/skeleton/porch/prompts/specify.md +1 -1
  269. package/skeleton/porch/prompts/understand.md +2 -2
  270. package/skeleton/protocol-schema.json +282 -0
  271. package/skeleton/protocols/bugfix/builder-prompt.md +54 -0
  272. package/skeleton/protocols/bugfix/prompts/fix.md +77 -0
  273. package/skeleton/protocols/bugfix/prompts/investigate.md +77 -0
  274. package/skeleton/protocols/bugfix/prompts/pr.md +84 -0
  275. package/skeleton/protocols/bugfix/protocol.json +20 -33
  276. package/skeleton/protocols/experiment/builder-prompt.md +52 -0
  277. package/skeleton/protocols/experiment/protocol.json +101 -0
  278. package/skeleton/protocols/experiment/protocol.md +3 -3
  279. package/skeleton/protocols/experiment/templates/notes.md +1 -1
  280. package/skeleton/protocols/maintain/builder-prompt.md +46 -0
  281. package/skeleton/protocols/maintain/prompts/audit.md +111 -0
  282. package/skeleton/protocols/maintain/prompts/clean.md +91 -0
  283. package/skeleton/protocols/maintain/prompts/sync.md +113 -0
  284. package/skeleton/protocols/maintain/prompts/verify.md +110 -0
  285. package/skeleton/protocols/maintain/protocol.json +141 -0
  286. package/skeleton/protocols/maintain/protocol.md +17 -11
  287. package/skeleton/protocols/protocol-schema.json +54 -1
  288. package/skeleton/protocols/spir/builder-prompt.md +59 -0
  289. package/skeleton/protocols/spir/prompts/implement.md +208 -0
  290. package/skeleton/protocols/{spider → spir}/prompts/plan.md +6 -70
  291. package/skeleton/protocols/{spider → spir}/prompts/review.md +20 -39
  292. package/skeleton/protocols/{spider → spir}/prompts/specify.md +33 -61
  293. package/skeleton/protocols/spir/protocol.json +156 -0
  294. package/skeleton/protocols/{spider → spir}/protocol.md +35 -21
  295. package/skeleton/protocols/{spider → spir}/templates/plan.md +14 -0
  296. package/skeleton/protocols/spir/templates/review.md +89 -0
  297. package/skeleton/protocols/tick/builder-prompt.md +56 -0
  298. package/skeleton/protocols/tick/protocol.json +7 -2
  299. package/skeleton/protocols/tick/protocol.md +18 -18
  300. package/skeleton/protocols/tick/templates/review.md +1 -1
  301. package/skeleton/resources/commands/agent-farm.md +63 -46
  302. package/skeleton/resources/commands/codev.md +0 -2
  303. package/skeleton/resources/commands/overview.md +7 -17
  304. package/skeleton/resources/workflow-reference.md +4 -4
  305. package/skeleton/roles/architect.md +152 -315
  306. package/skeleton/roles/builder.md +110 -218
  307. package/skeleton/roles/consultant.md +6 -6
  308. package/skeleton/templates/AGENTS.md +2 -2
  309. package/skeleton/templates/CLAUDE.md +2 -2
  310. package/skeleton/templates/cheatsheet.md +7 -5
  311. package/skeleton/templates/projectlist.md +1 -1
  312. package/templates/dashboard/index.html +17 -43
  313. package/templates/dashboard/js/dialogs.js +7 -7
  314. package/templates/dashboard/js/files.js +2 -2
  315. package/templates/dashboard/js/main.js +4 -4
  316. package/templates/dashboard/js/projects.js +3 -3
  317. package/templates/dashboard/js/tabs.js +1 -1
  318. package/templates/dashboard/js/utils.js +22 -87
  319. package/templates/open.html +26 -0
  320. package/templates/tower.html +642 -36
  321. package/dist/agent-farm/commands/kickoff.d.ts +0 -20
  322. package/dist/agent-farm/commands/kickoff.d.ts.map +0 -1
  323. package/dist/agent-farm/commands/kickoff.js +0 -337
  324. package/dist/agent-farm/commands/kickoff.js.map +0 -1
  325. package/dist/agent-farm/commands/rename.d.ts +0 -13
  326. package/dist/agent-farm/commands/rename.d.ts.map +0 -1
  327. package/dist/agent-farm/commands/rename.js +0 -33
  328. package/dist/agent-farm/commands/rename.js.map +0 -1
  329. package/dist/agent-farm/commands/tutorial.d.ts +0 -10
  330. package/dist/agent-farm/commands/tutorial.d.ts.map +0 -1
  331. package/dist/agent-farm/commands/tutorial.js +0 -49
  332. package/dist/agent-farm/commands/tutorial.js.map +0 -1
  333. package/dist/agent-farm/commands/util.d.ts +0 -15
  334. package/dist/agent-farm/commands/util.d.ts.map +0 -1
  335. package/dist/agent-farm/commands/util.js +0 -108
  336. package/dist/agent-farm/commands/util.js.map +0 -1
  337. package/dist/agent-farm/servers/dashboard-server.d.ts +0 -7
  338. package/dist/agent-farm/servers/dashboard-server.d.ts.map +0 -1
  339. package/dist/agent-farm/servers/dashboard-server.js +0 -1872
  340. package/dist/agent-farm/servers/dashboard-server.js.map +0 -1
  341. package/dist/agent-farm/servers/open-server.d.ts +0 -7
  342. package/dist/agent-farm/servers/open-server.d.ts.map +0 -1
  343. package/dist/agent-farm/servers/open-server.js +0 -315
  344. package/dist/agent-farm/servers/open-server.js.map +0 -1
  345. package/dist/agent-farm/tutorial/index.d.ts +0 -8
  346. package/dist/agent-farm/tutorial/index.d.ts.map +0 -1
  347. package/dist/agent-farm/tutorial/index.js +0 -8
  348. package/dist/agent-farm/tutorial/index.js.map +0 -1
  349. package/dist/agent-farm/tutorial/prompts.d.ts +0 -57
  350. package/dist/agent-farm/tutorial/prompts.d.ts.map +0 -1
  351. package/dist/agent-farm/tutorial/prompts.js +0 -147
  352. package/dist/agent-farm/tutorial/prompts.js.map +0 -1
  353. package/dist/agent-farm/tutorial/runner.d.ts +0 -52
  354. package/dist/agent-farm/tutorial/runner.d.ts.map +0 -1
  355. package/dist/agent-farm/tutorial/runner.js +0 -204
  356. package/dist/agent-farm/tutorial/runner.js.map +0 -1
  357. package/dist/agent-farm/tutorial/state.d.ts +0 -26
  358. package/dist/agent-farm/tutorial/state.d.ts.map +0 -1
  359. package/dist/agent-farm/tutorial/state.js +0 -89
  360. package/dist/agent-farm/tutorial/state.js.map +0 -1
  361. package/dist/agent-farm/tutorial/steps/first-spec.d.ts +0 -7
  362. package/dist/agent-farm/tutorial/steps/first-spec.d.ts.map +0 -1
  363. package/dist/agent-farm/tutorial/steps/first-spec.js +0 -136
  364. package/dist/agent-farm/tutorial/steps/first-spec.js.map +0 -1
  365. package/dist/agent-farm/tutorial/steps/implementation.d.ts +0 -7
  366. package/dist/agent-farm/tutorial/steps/implementation.d.ts.map +0 -1
  367. package/dist/agent-farm/tutorial/steps/implementation.js +0 -76
  368. package/dist/agent-farm/tutorial/steps/implementation.js.map +0 -1
  369. package/dist/agent-farm/tutorial/steps/index.d.ts +0 -10
  370. package/dist/agent-farm/tutorial/steps/index.d.ts.map +0 -1
  371. package/dist/agent-farm/tutorial/steps/index.js +0 -10
  372. package/dist/agent-farm/tutorial/steps/index.js.map +0 -1
  373. package/dist/agent-farm/tutorial/steps/planning.d.ts +0 -7
  374. package/dist/agent-farm/tutorial/steps/planning.d.ts.map +0 -1
  375. package/dist/agent-farm/tutorial/steps/planning.js +0 -143
  376. package/dist/agent-farm/tutorial/steps/planning.js.map +0 -1
  377. package/dist/agent-farm/tutorial/steps/review.d.ts +0 -7
  378. package/dist/agent-farm/tutorial/steps/review.d.ts.map +0 -1
  379. package/dist/agent-farm/tutorial/steps/review.js +0 -78
  380. package/dist/agent-farm/tutorial/steps/review.js.map +0 -1
  381. package/dist/agent-farm/tutorial/steps/setup.d.ts +0 -7
  382. package/dist/agent-farm/tutorial/steps/setup.d.ts.map +0 -1
  383. package/dist/agent-farm/tutorial/steps/setup.js +0 -126
  384. package/dist/agent-farm/tutorial/steps/setup.js.map +0 -1
  385. package/dist/agent-farm/tutorial/steps/welcome.d.ts +0 -7
  386. package/dist/agent-farm/tutorial/steps/welcome.d.ts.map +0 -1
  387. package/dist/agent-farm/tutorial/steps/welcome.js +0 -50
  388. package/dist/agent-farm/tutorial/steps/welcome.js.map +0 -1
  389. package/dist/agent-farm/utils/orphan-handler.d.ts +0 -27
  390. package/dist/agent-farm/utils/orphan-handler.d.ts.map +0 -1
  391. package/dist/agent-farm/utils/orphan-handler.js +0 -149
  392. package/dist/agent-farm/utils/orphan-handler.js.map +0 -1
  393. package/dist/agent-farm/utils/port-registry.d.ts +0 -58
  394. package/dist/agent-farm/utils/port-registry.d.ts.map +0 -1
  395. package/dist/agent-farm/utils/port-registry.js +0 -166
  396. package/dist/agent-farm/utils/port-registry.js.map +0 -1
  397. package/dist/agent-farm/utils/terminal-ports.d.ts +0 -18
  398. package/dist/agent-farm/utils/terminal-ports.d.ts.map +0 -1
  399. package/dist/agent-farm/utils/terminal-ports.js +0 -35
  400. package/dist/agent-farm/utils/terminal-ports.js.map +0 -1
  401. package/dist/commands/pcheck/cache.d.ts +0 -48
  402. package/dist/commands/pcheck/cache.d.ts.map +0 -1
  403. package/dist/commands/pcheck/cache.js +0 -170
  404. package/dist/commands/pcheck/cache.js.map +0 -1
  405. package/dist/commands/pcheck/evaluator.d.ts +0 -15
  406. package/dist/commands/pcheck/evaluator.d.ts.map +0 -1
  407. package/dist/commands/pcheck/evaluator.js +0 -246
  408. package/dist/commands/pcheck/evaluator.js.map +0 -1
  409. package/dist/commands/pcheck/index.d.ts +0 -12
  410. package/dist/commands/pcheck/index.d.ts.map +0 -1
  411. package/dist/commands/pcheck/index.js +0 -249
  412. package/dist/commands/pcheck/index.js.map +0 -1
  413. package/dist/commands/pcheck/parser.d.ts +0 -39
  414. package/dist/commands/pcheck/parser.d.ts.map +0 -1
  415. package/dist/commands/pcheck/parser.js +0 -155
  416. package/dist/commands/pcheck/parser.js.map +0 -1
  417. package/dist/commands/pcheck/types.d.ts +0 -82
  418. package/dist/commands/pcheck/types.d.ts.map +0 -1
  419. package/dist/commands/pcheck/types.js +0 -5
  420. package/dist/commands/pcheck/types.js.map +0 -1
  421. package/dist/commands/porch/consultation.d.ts +0 -56
  422. package/dist/commands/porch/consultation.d.ts.map +0 -1
  423. package/dist/commands/porch/consultation.js +0 -330
  424. package/dist/commands/porch/consultation.js.map +0 -1
  425. package/dist/commands/porch/notifications.d.ts +0 -99
  426. package/dist/commands/porch/notifications.d.ts.map +0 -1
  427. package/dist/commands/porch/notifications.js +0 -223
  428. package/dist/commands/porch/notifications.js.map +0 -1
  429. package/dist/commands/porch/plan-parser.d.ts +0 -38
  430. package/dist/commands/porch/plan-parser.d.ts.map +0 -1
  431. package/dist/commands/porch/plan-parser.js +0 -166
  432. package/dist/commands/porch/plan-parser.js.map +0 -1
  433. package/dist/commands/porch/protocol-loader.d.ts +0 -46
  434. package/dist/commands/porch/protocol-loader.d.ts.map +0 -1
  435. package/dist/commands/porch/protocol-loader.js +0 -253
  436. package/dist/commands/porch/protocol-loader.js.map +0 -1
  437. package/dist/commands/porch/signal-parser.d.ts +0 -88
  438. package/dist/commands/porch/signal-parser.d.ts.map +0 -1
  439. package/dist/commands/porch/signal-parser.js +0 -148
  440. package/dist/commands/porch/signal-parser.js.map +0 -1
  441. package/dist/commands/tower.d.ts +0 -16
  442. package/dist/commands/tower.d.ts.map +0 -1
  443. package/dist/commands/tower.js +0 -21
  444. package/dist/commands/tower.js.map +0 -1
  445. package/skeleton/config.json +0 -7
  446. package/skeleton/porch/protocols/bugfix.json +0 -85
  447. package/skeleton/porch/protocols/spider.json +0 -135
  448. package/skeleton/porch/protocols/tick.json +0 -76
  449. package/skeleton/protocols/spider/prompts/defend.md +0 -215
  450. package/skeleton/protocols/spider/prompts/evaluate.md +0 -241
  451. package/skeleton/protocols/spider/prompts/implement.md +0 -149
  452. package/skeleton/protocols/spider/protocol.json +0 -210
  453. package/skeleton/protocols/spider/templates/review.md +0 -207
  454. package/templates/dashboard/css/activity.css +0 -151
  455. package/templates/dashboard/js/activity.js +0 -112
  456. /package/skeleton/protocols/{spider → spir}/templates/spec.md +0 -0
@@ -6,18 +6,705 @@
6
6
  import http from 'node:http';
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
- import net from 'node:net';
10
- import { spawn, execSync } from 'node:child_process';
11
- import { homedir } from 'node:os';
9
+ import crypto from 'node:crypto';
10
+ import { execSync } from 'node:child_process';
11
+ import { homedir, tmpdir } from 'node:os';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { Command } from 'commander';
14
+ import { WebSocketServer, WebSocket } from 'ws';
14
15
  import { getGlobalDb } from '../db/index.js';
15
- import { cleanupStaleEntries } from '../utils/port-registry.js';
16
16
  import { parseJsonBody, isRequestAllowed } from '../utils/server-utils.js';
17
+ import { getGateStatusForProject } from '../utils/gate-status.js';
18
+ import { GateWatcher } from '../utils/gate-watcher.js';
19
+ import { saveFileTab as saveFileTabToDb, deleteFileTab as deleteFileTabFromDb, loadFileTabsForProject as loadFileTabsFromDb, } from '../utils/file-tabs.js';
20
+ import { TerminalManager } from '../../terminal/pty-manager.js';
21
+ import { encodeData, encodeControl, decodeFrame } from '../../terminal/ws-protocol.js';
22
+ import { TunnelClient } from '../lib/tunnel-client.js';
23
+ import { readCloudConfig, getCloudConfigPath, maskApiKey } from '../lib/cloud-config.js';
24
+ import { SessionManager } from '../../terminal/session-manager.js';
17
25
  const __filename = fileURLToPath(import.meta.url);
18
26
  const __dirname = path.dirname(__filename);
19
27
  // Default port for tower dashboard
20
28
  const DEFAULT_PORT = 4100;
29
+ // Rate limiting for activation requests (Spec 0090 Phase 1)
30
+ // Simple in-memory rate limiter: 10 activations per minute per client
31
+ const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
32
+ const RATE_LIMIT_MAX = 10;
33
+ const activationRateLimits = new Map();
34
+ /**
35
+ * Check if a client has exceeded the rate limit for activations
36
+ * Returns true if rate limit exceeded, false if allowed
37
+ */
38
+ function isRateLimited(clientIp) {
39
+ const now = Date.now();
40
+ const entry = activationRateLimits.get(clientIp);
41
+ if (!entry || now - entry.windowStart >= RATE_LIMIT_WINDOW_MS) {
42
+ // New window
43
+ activationRateLimits.set(clientIp, { count: 1, windowStart: now });
44
+ return false;
45
+ }
46
+ if (entry.count >= RATE_LIMIT_MAX) {
47
+ return true;
48
+ }
49
+ entry.count++;
50
+ return false;
51
+ }
52
+ /**
53
+ * Clean up old rate limit entries periodically
54
+ */
55
+ function cleanupRateLimits() {
56
+ const now = Date.now();
57
+ for (const [ip, entry] of activationRateLimits.entries()) {
58
+ if (now - entry.windowStart >= RATE_LIMIT_WINDOW_MS * 2) {
59
+ activationRateLimits.delete(ip);
60
+ }
61
+ }
62
+ }
63
+ // Cleanup stale rate limit entries every 5 minutes
64
+ setInterval(cleanupRateLimits, 5 * 60 * 1000);
65
+ // ============================================================================
66
+ // Cloud Tunnel Client (Spec 0097 Phase 4)
67
+ // ============================================================================
68
+ /** Tunnel client instance — created on startup or via POST /api/tunnel/connect */
69
+ let tunnelClient = null;
70
+ /** Config file watcher — watches cloud-config.json for changes */
71
+ let configWatcher = null;
72
+ /** Debounce timer for config file watcher events */
73
+ let configWatchDebounce = null;
74
+ /** Default tunnel port for codevos.ai */
75
+ // TICK-001: tunnelPort is no longer needed — WebSocket connects on the same port
76
+ /** Periodic metadata refresh interval (re-sends metadata to codevos.ai) */
77
+ let metadataRefreshInterval = null;
78
+ /** Metadata refresh period in milliseconds (30 seconds) */
79
+ const METADATA_REFRESH_MS = 30_000;
80
+ /**
81
+ * Gather current tower metadata (projects + terminals) for codevos.ai.
82
+ */
83
+ async function gatherMetadata() {
84
+ const instances = await getInstances();
85
+ const projects = instances.map((i) => ({
86
+ path: i.projectPath,
87
+ name: i.projectName,
88
+ }));
89
+ // Build reverse mapping: terminal ID → project path
90
+ const terminalToProject = new Map();
91
+ for (const [projectPath, entry] of projectTerminals) {
92
+ if (entry.architect)
93
+ terminalToProject.set(entry.architect, projectPath);
94
+ for (const termId of entry.builders.values())
95
+ terminalToProject.set(termId, projectPath);
96
+ for (const termId of entry.shells.values())
97
+ terminalToProject.set(termId, projectPath);
98
+ }
99
+ const manager = terminalManager;
100
+ const terminals = [];
101
+ if (manager) {
102
+ for (const session of manager.listSessions()) {
103
+ terminals.push({
104
+ id: session.id,
105
+ projectPath: terminalToProject.get(session.id) ?? '',
106
+ });
107
+ }
108
+ }
109
+ return { projects, terminals };
110
+ }
111
+ /**
112
+ * Start periodic metadata refresh — re-gathers metadata and pushes to codevos.ai
113
+ * every METADATA_REFRESH_MS while the tunnel is connected.
114
+ */
115
+ function startMetadataRefresh() {
116
+ stopMetadataRefresh();
117
+ metadataRefreshInterval = setInterval(async () => {
118
+ try {
119
+ if (tunnelClient && tunnelClient.getState() === 'connected') {
120
+ const metadata = await gatherMetadata();
121
+ tunnelClient.sendMetadata(metadata);
122
+ }
123
+ }
124
+ catch (err) {
125
+ log('WARN', `Metadata refresh failed: ${err.message}`);
126
+ }
127
+ }, METADATA_REFRESH_MS);
128
+ }
129
+ /**
130
+ * Stop the periodic metadata refresh.
131
+ */
132
+ function stopMetadataRefresh() {
133
+ if (metadataRefreshInterval) {
134
+ clearInterval(metadataRefreshInterval);
135
+ metadataRefreshInterval = null;
136
+ }
137
+ }
138
+ /**
139
+ * Create or reconnect the tunnel client using the given config.
140
+ * Sets up state change listeners and sends initial metadata.
141
+ */
142
+ async function connectTunnel(config) {
143
+ // Disconnect existing client if any
144
+ if (tunnelClient) {
145
+ tunnelClient.disconnect();
146
+ }
147
+ const client = new TunnelClient({
148
+ serverUrl: config.server_url,
149
+ apiKey: config.api_key,
150
+ towerId: config.tower_id,
151
+ localPort: port,
152
+ });
153
+ client.onStateChange((state, prev) => {
154
+ log('INFO', `Tunnel: ${prev} → ${state}`);
155
+ if (state === 'connected') {
156
+ startMetadataRefresh();
157
+ }
158
+ else if (prev === 'connected') {
159
+ stopMetadataRefresh();
160
+ }
161
+ if (state === 'auth_failed') {
162
+ log('ERROR', 'Cloud connection failed: API key is invalid or revoked. Run \'af tower register --reauth\' to update credentials.');
163
+ }
164
+ });
165
+ // Gather and set initial metadata before connecting
166
+ const metadata = await gatherMetadata();
167
+ client.sendMetadata(metadata);
168
+ tunnelClient = client;
169
+ client.connect();
170
+ // Ensure config watcher is running — the config directory now exists.
171
+ // Handles the case where Tower booted before registration (directory didn't
172
+ // exist, so startConfigWatcher() silently failed at boot time).
173
+ startConfigWatcher();
174
+ return client;
175
+ }
176
+ /**
177
+ * Start watching cloud-config.json for changes.
178
+ * On change: reconnect with new credentials.
179
+ * On delete: disconnect tunnel.
180
+ */
181
+ function startConfigWatcher() {
182
+ stopConfigWatcher();
183
+ const configPath = getCloudConfigPath();
184
+ const configDir = path.dirname(configPath);
185
+ const configFile = path.basename(configPath);
186
+ // Watch the directory (more reliable than watching the file directly)
187
+ try {
188
+ configWatcher = fs.watch(configDir, (eventType, filename) => {
189
+ if (filename !== configFile)
190
+ return;
191
+ // Debounce: multiple events fire for a single write
192
+ if (configWatchDebounce)
193
+ clearTimeout(configWatchDebounce);
194
+ configWatchDebounce = setTimeout(async () => {
195
+ configWatchDebounce = null;
196
+ try {
197
+ const config = readCloudConfig();
198
+ if (config) {
199
+ log('INFO', `Cloud config changed, reconnecting tunnel (key: ${maskApiKey(config.api_key)})`);
200
+ // Reset circuit breaker in case previous key was invalid
201
+ if (tunnelClient)
202
+ tunnelClient.resetCircuitBreaker();
203
+ await connectTunnel(config);
204
+ }
205
+ else {
206
+ // Config deleted or invalid
207
+ log('INFO', 'Cloud config removed or invalid, disconnecting tunnel');
208
+ if (tunnelClient) {
209
+ tunnelClient.disconnect();
210
+ tunnelClient = null;
211
+ }
212
+ }
213
+ }
214
+ catch (err) {
215
+ log('WARN', `Error handling config change: ${err.message}`);
216
+ }
217
+ }, 500);
218
+ });
219
+ }
220
+ catch {
221
+ // Directory doesn't exist yet — that's fine, user hasn't registered
222
+ }
223
+ }
224
+ /**
225
+ * Stop watching cloud-config.json.
226
+ */
227
+ function stopConfigWatcher() {
228
+ if (configWatcher) {
229
+ configWatcher.close();
230
+ configWatcher = null;
231
+ }
232
+ if (configWatchDebounce) {
233
+ clearTimeout(configWatchDebounce);
234
+ configWatchDebounce = null;
235
+ }
236
+ }
237
+ // ============================================================================
238
+ // PHASE 2 & 4: Terminal Management (Spec 0090)
239
+ // ============================================================================
240
+ // Global TerminalManager instance for tower-managed terminals
241
+ // Uses a temporary directory as projectRoot since terminals can be for any project
242
+ let terminalManager = null;
243
+ const projectTerminals = new Map();
244
+ /**
245
+ * Get or create project terminal registry entry.
246
+ * On first access for a project, hydrates file tabs from SQLite so
247
+ * persisted tabs are available immediately (not just after /api/state).
248
+ */
249
+ function getProjectTerminalsEntry(projectPath) {
250
+ let entry = projectTerminals.get(projectPath);
251
+ if (!entry) {
252
+ entry = { builders: new Map(), shells: new Map(), fileTabs: loadFileTabsForProject(projectPath) };
253
+ projectTerminals.set(projectPath, entry);
254
+ }
255
+ // Migration: ensure fileTabs exists for older entries
256
+ if (!entry.fileTabs) {
257
+ entry.fileTabs = new Map();
258
+ }
259
+ return entry;
260
+ }
261
+ /**
262
+ * Get language identifier for syntax highlighting
263
+ */
264
+ function getLanguageForExt(ext) {
265
+ const langMap = {
266
+ js: 'javascript', ts: 'typescript', jsx: 'javascript', tsx: 'typescript',
267
+ py: 'python', sh: 'bash', bash: 'bash', md: 'markdown',
268
+ html: 'markup', css: 'css', json: 'json', yaml: 'yaml', yml: 'yaml',
269
+ rs: 'rust', go: 'go', java: 'java', c: 'c', cpp: 'cpp', h: 'c',
270
+ };
271
+ return langMap[ext] || ext || 'plaintext';
272
+ }
273
+ /**
274
+ * Get MIME type for file
275
+ */
276
+ function getMimeTypeForFile(filePath) {
277
+ const ext = path.extname(filePath).slice(1).toLowerCase();
278
+ const mimeTypes = {
279
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
280
+ gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
281
+ mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
282
+ pdf: 'application/pdf', txt: 'text/plain',
283
+ };
284
+ return mimeTypes[ext] || 'application/octet-stream';
285
+ }
286
+ /**
287
+ * Generate next shell ID for a project
288
+ */
289
+ function getNextShellId(projectPath) {
290
+ const entry = getProjectTerminalsEntry(projectPath);
291
+ let maxId = 0;
292
+ for (const id of entry.shells.keys()) {
293
+ const num = parseInt(id.replace('shell-', ''), 10);
294
+ if (!isNaN(num) && num > maxId)
295
+ maxId = num;
296
+ }
297
+ return `shell-${maxId + 1}`;
298
+ }
299
+ /**
300
+ * Get or create the global TerminalManager instance
301
+ */
302
+ function getTerminalManager() {
303
+ if (!terminalManager) {
304
+ // Use a neutral projectRoot - terminals specify their own cwd
305
+ const projectRoot = process.env.HOME || '/tmp';
306
+ terminalManager = new TerminalManager({
307
+ projectRoot,
308
+ logDir: path.join(homedir(), '.agent-farm', 'logs'),
309
+ maxSessions: 100,
310
+ ringBufferLines: 10000,
311
+ diskLogEnabled: true,
312
+ diskLogMaxBytes: 50 * 1024 * 1024,
313
+ reconnectTimeoutMs: 300_000,
314
+ });
315
+ }
316
+ return terminalManager;
317
+ }
318
+ /**
319
+ * Normalize a project path to its canonical form for consistent SQLite storage.
320
+ * Uses realpath to resolve symlinks and relative paths.
321
+ */
322
+ function normalizeProjectPath(projectPath) {
323
+ try {
324
+ return fs.realpathSync(projectPath);
325
+ }
326
+ catch {
327
+ // Path doesn't exist yet, normalize without realpath
328
+ return path.resolve(projectPath);
329
+ }
330
+ }
331
+ /**
332
+ * Save a terminal session to SQLite.
333
+ * Guards against race conditions by checking if project is still active.
334
+ */
335
+ function saveTerminalSession(terminalId, projectPath, type, roleId, pid, shepherdSocket = null, shepherdPid = null, shepherdStartTime = null) {
336
+ try {
337
+ const normalizedPath = normalizeProjectPath(projectPath);
338
+ // Race condition guard: only save if project is still in the active registry
339
+ // This prevents zombie rows when stop races with session creation
340
+ if (!projectTerminals.has(normalizedPath) && !projectTerminals.has(projectPath)) {
341
+ log('INFO', `Skipping session save - project no longer active: ${projectPath}`);
342
+ return;
343
+ }
344
+ const db = getGlobalDb();
345
+ db.prepare(`
346
+ INSERT OR REPLACE INTO terminal_sessions (id, project_path, type, role_id, pid, shepherd_socket, shepherd_pid, shepherd_start_time)
347
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
348
+ `).run(terminalId, normalizedPath, type, roleId, pid, shepherdSocket, shepherdPid, shepherdStartTime);
349
+ log('INFO', `Saved terminal session to SQLite: ${terminalId} (${type}) for ${path.basename(normalizedPath)}`);
350
+ }
351
+ catch (err) {
352
+ log('WARN', `Failed to save terminal session: ${err.message}`);
353
+ }
354
+ }
355
+ /**
356
+ * Check if a terminal session is persistent (shepherd-backed).
357
+ * A session is persistent if it can survive a Tower restart.
358
+ */
359
+ function isSessionPersistent(_terminalId, session) {
360
+ return session.shepherdBacked;
361
+ }
362
+ /**
363
+ * Delete a terminal session from SQLite
364
+ */
365
+ function deleteTerminalSession(terminalId) {
366
+ try {
367
+ const db = getGlobalDb();
368
+ db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(terminalId);
369
+ }
370
+ catch (err) {
371
+ log('WARN', `Failed to delete terminal session: ${err.message}`);
372
+ }
373
+ }
374
+ /**
375
+ * Delete all terminal sessions for a project from SQLite.
376
+ * Normalizes path to ensure consistent cleanup regardless of how path was provided.
377
+ */
378
+ function deleteProjectTerminalSessions(projectPath) {
379
+ try {
380
+ const normalizedPath = normalizeProjectPath(projectPath);
381
+ const db = getGlobalDb();
382
+ // Delete both normalized and raw path to handle any inconsistencies
383
+ db.prepare('DELETE FROM terminal_sessions WHERE project_path = ?').run(normalizedPath);
384
+ if (normalizedPath !== projectPath) {
385
+ db.prepare('DELETE FROM terminal_sessions WHERE project_path = ?').run(projectPath);
386
+ }
387
+ }
388
+ catch (err) {
389
+ log('WARN', `Failed to delete project terminal sessions: ${err.message}`);
390
+ }
391
+ }
392
+ /**
393
+ * Save a file tab to SQLite for persistence across Tower restarts.
394
+ * Thin wrapper around utils/file-tabs.ts with error handling and path normalization.
395
+ */
396
+ function saveFileTab(id, projectPath, filePath, createdAt) {
397
+ try {
398
+ const normalizedPath = normalizeProjectPath(projectPath);
399
+ saveFileTabToDb(getGlobalDb(), id, normalizedPath, filePath, createdAt);
400
+ }
401
+ catch (err) {
402
+ log('WARN', `Failed to save file tab: ${err.message}`);
403
+ }
404
+ }
405
+ /**
406
+ * Delete a file tab from SQLite.
407
+ * Thin wrapper around utils/file-tabs.ts with error handling.
408
+ */
409
+ function deleteFileTab(id) {
410
+ try {
411
+ deleteFileTabFromDb(getGlobalDb(), id);
412
+ }
413
+ catch (err) {
414
+ log('WARN', `Failed to delete file tab: ${err.message}`);
415
+ }
416
+ }
417
+ /**
418
+ * Load file tabs for a project from SQLite.
419
+ * Thin wrapper around utils/file-tabs.ts with error handling and path normalization.
420
+ */
421
+ function loadFileTabsForProject(projectPath) {
422
+ try {
423
+ const normalizedPath = normalizeProjectPath(projectPath);
424
+ return loadFileTabsFromDb(getGlobalDb(), normalizedPath);
425
+ }
426
+ catch (err) {
427
+ log('WARN', `Failed to load file tabs: ${err.message}`);
428
+ }
429
+ return new Map();
430
+ }
431
+ // Shepherd session manager (initialized at startup)
432
+ let shepherdManager = null;
433
+ /**
434
+ * Check if a process is running
435
+ */
436
+ function processExists(pid) {
437
+ try {
438
+ process.kill(pid, 0);
439
+ return true;
440
+ }
441
+ catch {
442
+ return false;
443
+ }
444
+ }
445
+ /**
446
+ * Reconcile terminal sessions on startup.
447
+ *
448
+ * DUAL-SOURCE STRATEGY (shepherd + SQLite):
449
+ *
450
+ * Phase 1 — Shepherd reconnection:
451
+ * For SQLite rows with shepherd_socket IS NOT NULL, attempt to reconnect
452
+ * via SessionManager.reconnectSession(). Shepherd processes survive Tower
453
+ * restarts as detached OS processes.
454
+ *
455
+ * Phase 2 — SQLite sweep:
456
+ * Any rows not matched in Phase 1 are stale → clean up.
457
+ *
458
+ * File tabs are the exception: they have no backing process, so SQLite is
459
+ * the sole source of truth for their persistence (see file_tabs table).
460
+ */
461
+ async function reconcileTerminalSessions() {
462
+ const manager = getTerminalManager();
463
+ const db = getGlobalDb();
464
+ let shepherdReconnected = 0;
465
+ let orphanReconnected = 0;
466
+ let killed = 0;
467
+ let cleaned = 0;
468
+ // Track matched session IDs across all phases
469
+ const matchedSessionIds = new Set();
470
+ // ---- Phase 1: Shepherd reconnection ----
471
+ let allDbSessions;
472
+ try {
473
+ allDbSessions = db.prepare('SELECT * FROM terminal_sessions').all();
474
+ }
475
+ catch (err) {
476
+ log('WARN', `Failed to read terminal sessions: ${err.message}`);
477
+ allDbSessions = [];
478
+ }
479
+ const shepherdSessions = allDbSessions.filter(s => s.shepherd_socket !== null);
480
+ if (shepherdSessions.length > 0) {
481
+ log('INFO', `Found ${shepherdSessions.length} shepherd session(s) in SQLite — reconnecting...`);
482
+ }
483
+ for (const dbSession of shepherdSessions) {
484
+ const projectPath = dbSession.project_path;
485
+ // Skip sessions whose project path doesn't exist or is in temp directory
486
+ if (!fs.existsSync(projectPath)) {
487
+ log('INFO', `Skipping shepherd session ${dbSession.id} — project path no longer exists: ${projectPath}`);
488
+ // Kill orphaned shepherd process before removing row
489
+ if (dbSession.shepherd_pid && processExists(dbSession.shepherd_pid)) {
490
+ try {
491
+ process.kill(dbSession.shepherd_pid, 'SIGTERM');
492
+ killed++;
493
+ }
494
+ catch { /* not killable */ }
495
+ }
496
+ db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbSession.id);
497
+ cleaned++;
498
+ continue;
499
+ }
500
+ const tmpDirs = ['/tmp', '/private/tmp', '/var/folders', '/private/var/folders'];
501
+ if (tmpDirs.some(d => projectPath === d || projectPath.startsWith(d + '/'))) {
502
+ log('INFO', `Skipping shepherd session ${dbSession.id} — project is in temp directory: ${projectPath}`);
503
+ // Kill orphaned shepherd process before removing row
504
+ if (dbSession.shepherd_pid && processExists(dbSession.shepherd_pid)) {
505
+ try {
506
+ process.kill(dbSession.shepherd_pid, 'SIGTERM');
507
+ killed++;
508
+ }
509
+ catch { /* not killable */ }
510
+ }
511
+ db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbSession.id);
512
+ cleaned++;
513
+ continue;
514
+ }
515
+ if (!shepherdManager) {
516
+ log('WARN', `Shepherd manager not initialized — cannot reconnect ${dbSession.id}`);
517
+ continue;
518
+ }
519
+ try {
520
+ // For architect sessions, restore auto-restart behavior after reconnection
521
+ let restartOptions;
522
+ if (dbSession.type === 'architect') {
523
+ let architectCmd = 'claude';
524
+ const configPath = path.join(projectPath, 'af-config.json');
525
+ if (fs.existsSync(configPath)) {
526
+ try {
527
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
528
+ if (config.shell?.architect) {
529
+ architectCmd = config.shell.architect;
530
+ }
531
+ }
532
+ catch { /* use default */ }
533
+ }
534
+ const cmdParts = architectCmd.split(/\s+/);
535
+ const cleanEnv = { ...process.env };
536
+ delete cleanEnv['CLAUDECODE'];
537
+ restartOptions = {
538
+ command: cmdParts[0],
539
+ args: cmdParts.slice(1),
540
+ cwd: projectPath,
541
+ env: cleanEnv,
542
+ restartDelay: 2000,
543
+ maxRestarts: 50,
544
+ };
545
+ }
546
+ const client = await shepherdManager.reconnectSession(dbSession.id, dbSession.shepherd_socket, dbSession.shepherd_pid, dbSession.shepherd_start_time, restartOptions);
547
+ if (!client) {
548
+ log('INFO', `Shepherd session ${dbSession.id} is stale (PID/socket dead) — will clean up`);
549
+ continue; // Will be cleaned up in Phase 3
550
+ }
551
+ const replayData = client.getReplayData() ?? Buffer.alloc(0);
552
+ const label = dbSession.type === 'architect' ? 'Architect' : `${dbSession.type} ${dbSession.role_id || 'unknown'}`;
553
+ // Create a PtySession backed by the reconnected shepherd client
554
+ const session = manager.createSessionRaw({ label, cwd: projectPath });
555
+ const ptySession = manager.getSession(session.id);
556
+ if (ptySession) {
557
+ ptySession.attachShepherd(client, replayData, dbSession.shepherd_pid, dbSession.id);
558
+ }
559
+ // Register in projectTerminals Map
560
+ const entry = getProjectTerminalsEntry(projectPath);
561
+ if (dbSession.type === 'architect') {
562
+ entry.architect = session.id;
563
+ }
564
+ else if (dbSession.type === 'builder') {
565
+ entry.builders.set(dbSession.role_id || dbSession.id, session.id);
566
+ }
567
+ else if (dbSession.type === 'shell') {
568
+ entry.shells.set(dbSession.role_id || dbSession.id, session.id);
569
+ }
570
+ // Update SQLite with new terminal ID
571
+ db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbSession.id);
572
+ saveTerminalSession(session.id, projectPath, dbSession.type, dbSession.role_id, dbSession.shepherd_pid, dbSession.shepherd_socket, dbSession.shepherd_pid, dbSession.shepherd_start_time);
573
+ registerKnownProject(projectPath);
574
+ // Clean up on exit
575
+ if (ptySession) {
576
+ ptySession.on('exit', () => {
577
+ const currentEntry = getProjectTerminalsEntry(projectPath);
578
+ if (dbSession.type === 'architect' && currentEntry.architect === session.id) {
579
+ currentEntry.architect = undefined;
580
+ }
581
+ deleteTerminalSession(session.id);
582
+ });
583
+ }
584
+ matchedSessionIds.add(dbSession.id);
585
+ shepherdReconnected++;
586
+ log('INFO', `Reconnected shepherd session → ${session.id} (${dbSession.type} for ${path.basename(projectPath)})`);
587
+ }
588
+ catch (err) {
589
+ log('WARN', `Failed to reconnect shepherd session ${dbSession.id}: ${err.message}`);
590
+ }
591
+ }
592
+ // ---- Phase 2: Sweep stale SQLite rows ----
593
+ for (const session of allDbSessions) {
594
+ if (matchedSessionIds.has(session.id))
595
+ continue;
596
+ const existing = manager.getSession(session.id);
597
+ if (existing && existing.status !== 'exited')
598
+ continue;
599
+ // Stale row — kill orphaned process if any, then delete
600
+ if (session.pid && processExists(session.pid)) {
601
+ log('INFO', `Killing orphaned process: PID ${session.pid} (${session.type} for ${path.basename(session.project_path)})`);
602
+ try {
603
+ process.kill(session.pid, 'SIGTERM');
604
+ killed++;
605
+ }
606
+ catch { /* process not killable */ }
607
+ }
608
+ db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(session.id);
609
+ cleaned++;
610
+ }
611
+ const total = shepherdReconnected + orphanReconnected;
612
+ if (total > 0 || killed > 0 || cleaned > 0) {
613
+ log('INFO', `Reconciliation complete: ${shepherdReconnected} shepherd, ${orphanReconnected} orphan, ${killed} killed, ${cleaned} stale rows cleaned`);
614
+ }
615
+ else {
616
+ log('INFO', 'No terminal sessions to reconcile');
617
+ }
618
+ }
619
+ /**
620
+ * Get terminal sessions from SQLite for a project.
621
+ * Normalizes path for consistent lookup.
622
+ */
623
+ function getTerminalSessionsForProject(projectPath) {
624
+ try {
625
+ const normalizedPath = normalizeProjectPath(projectPath);
626
+ const db = getGlobalDb();
627
+ return db.prepare('SELECT * FROM terminal_sessions WHERE project_path = ?').all(normalizedPath);
628
+ }
629
+ catch {
630
+ return [];
631
+ }
632
+ }
633
+ /**
634
+ * Handle WebSocket connection to a terminal session
635
+ * Uses hybrid binary protocol (Spec 0085):
636
+ * - 0x00 prefix: Control frame (JSON)
637
+ * - 0x01 prefix: Data frame (raw PTY bytes)
638
+ */
639
+ function handleTerminalWebSocket(ws, session, req) {
640
+ const resumeSeq = req.headers['x-session-resume'];
641
+ // Create a client adapter for the PTY session
642
+ // Uses binary protocol for data frames
643
+ const client = {
644
+ send: (data) => {
645
+ if (ws.readyState === WebSocket.OPEN) {
646
+ // Encode as binary data frame (0x01 prefix)
647
+ ws.send(encodeData(data));
648
+ }
649
+ },
650
+ };
651
+ // Attach client to session and get replay data
652
+ let replayLines;
653
+ if (resumeSeq && typeof resumeSeq === 'string') {
654
+ replayLines = session.attachResume(client, parseInt(resumeSeq, 10));
655
+ }
656
+ else {
657
+ replayLines = session.attach(client);
658
+ }
659
+ // Send replay data as binary data frame
660
+ if (replayLines.length > 0) {
661
+ const replayData = replayLines.join('\n');
662
+ if (ws.readyState === WebSocket.OPEN) {
663
+ ws.send(encodeData(replayData));
664
+ }
665
+ }
666
+ // Handle incoming messages from client (binary protocol)
667
+ ws.on('message', (rawData) => {
668
+ try {
669
+ const frame = decodeFrame(Buffer.from(rawData));
670
+ if (frame.type === 'data') {
671
+ // Write raw input to terminal
672
+ session.write(frame.data.toString('utf-8'));
673
+ }
674
+ else if (frame.type === 'control') {
675
+ // Handle control messages
676
+ const msg = frame.message;
677
+ if (msg.type === 'resize') {
678
+ const cols = msg.payload.cols;
679
+ const rows = msg.payload.rows;
680
+ if (typeof cols === 'number' && typeof rows === 'number') {
681
+ session.resize(cols, rows);
682
+ }
683
+ }
684
+ else if (msg.type === 'ping') {
685
+ if (ws.readyState === WebSocket.OPEN) {
686
+ ws.send(encodeControl({ type: 'pong', payload: {} }));
687
+ }
688
+ }
689
+ }
690
+ }
691
+ catch {
692
+ // If decode fails, try treating as raw UTF-8 input (for simpler clients)
693
+ try {
694
+ session.write(rawData.toString('utf-8'));
695
+ }
696
+ catch {
697
+ // Ignore malformed input
698
+ }
699
+ }
700
+ });
701
+ ws.on('close', () => {
702
+ session.detach(client);
703
+ });
704
+ ws.on('error', () => {
705
+ session.detach(client);
706
+ });
707
+ }
21
708
  // Parse arguments with Commander
22
709
  const program = new Command()
23
710
  .name('tower-server')
@@ -52,44 +739,115 @@ function log(level, message) {
52
739
  }
53
740
  }
54
741
  }
742
+ // Global exception handlers to catch uncaught errors
743
+ process.on('uncaughtException', (err) => {
744
+ log('ERROR', `Uncaught exception: ${err.message}\n${err.stack}`);
745
+ process.exit(1);
746
+ });
747
+ process.on('unhandledRejection', (reason) => {
748
+ const message = reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason);
749
+ log('ERROR', `Unhandled rejection: ${message}`);
750
+ process.exit(1);
751
+ });
752
+ // Graceful shutdown handler (Phase 2 - Spec 0090)
753
+ async function gracefulShutdown(signal) {
754
+ log('INFO', `Received ${signal}, starting graceful shutdown...`);
755
+ // 1. Stop accepting new connections
756
+ server?.close();
757
+ // 2. Close all WebSocket connections
758
+ if (terminalWss) {
759
+ for (const client of terminalWss.clients) {
760
+ client.close(1001, 'Server shutting down');
761
+ }
762
+ terminalWss.close();
763
+ }
764
+ // 3. Kill all PTY sessions
765
+ if (terminalManager) {
766
+ log('INFO', 'Shutting down terminal manager...');
767
+ terminalManager.shutdown();
768
+ }
769
+ // 3b. Shepherd clients: do NOT call shepherdManager.shutdown() here.
770
+ // SessionManager.shutdown() disconnects sockets, which triggers ShepherdClient
771
+ // 'close' events → PtySession exit(-1) → SQLite row deletion. This would erase
772
+ // the rows that reconcileTerminalSessions() needs on restart.
773
+ // Instead, let the process exit naturally — OS closes all sockets, and shepherds
774
+ // detect the disconnection and keep running. SQLite rows are preserved.
775
+ if (shepherdManager) {
776
+ log('INFO', 'Shepherd sessions will continue running (sockets close on process exit)');
777
+ }
778
+ // 4. Stop gate watcher
779
+ if (gateWatcherInterval) {
780
+ clearInterval(gateWatcherInterval);
781
+ gateWatcherInterval = null;
782
+ }
783
+ // 5. Disconnect tunnel (Spec 0097 Phase 4)
784
+ stopMetadataRefresh();
785
+ stopConfigWatcher();
786
+ if (tunnelClient) {
787
+ log('INFO', 'Disconnecting tunnel...');
788
+ tunnelClient.disconnect();
789
+ tunnelClient = null;
790
+ }
791
+ log('INFO', 'Graceful shutdown complete');
792
+ process.exit(0);
793
+ }
794
+ // Catch signals for clean shutdown
795
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
796
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
55
797
  if (isNaN(port) || port < 1 || port > 65535) {
56
798
  log('ERROR', `Invalid port "${portArg}". Must be a number between 1 and 65535.`);
57
799
  process.exit(1);
58
800
  }
59
801
  log('INFO', `Tower server starting on port ${port}`);
60
802
  /**
61
- * Load port allocations from SQLite database
803
+ * Register a project in the known_projects table so it persists across restarts
804
+ * even when all terminal sessions are gone.
62
805
  */
63
- function loadPortAllocations() {
806
+ function registerKnownProject(projectPath) {
64
807
  try {
65
808
  const db = getGlobalDb();
66
- return db.prepare('SELECT * FROM port_allocations ORDER BY last_used_at DESC').all();
809
+ db.prepare(`
810
+ INSERT INTO known_projects (project_path, name, last_launched_at)
811
+ VALUES (?, ?, datetime('now'))
812
+ ON CONFLICT(project_path) DO UPDATE SET last_launched_at = datetime('now')
813
+ `).run(projectPath, path.basename(projectPath));
67
814
  }
68
- catch (err) {
69
- log('ERROR', `Error loading port allocations: ${err.message}`);
70
- return [];
815
+ catch {
816
+ // Table may not exist yet (pre-migration)
71
817
  }
72
818
  }
73
819
  /**
74
- * Check if a port is listening
820
+ * Get all known project paths from known_projects, terminal_sessions, and in-memory cache
75
821
  */
76
- async function isPortListening(port) {
77
- return new Promise((resolve) => {
78
- const socket = new net.Socket();
79
- socket.setTimeout(1000);
80
- socket.on('connect', () => {
81
- socket.destroy();
82
- resolve(true);
83
- });
84
- socket.on('timeout', () => {
85
- socket.destroy();
86
- resolve(false);
87
- });
88
- socket.on('error', () => {
89
- resolve(false);
90
- });
91
- socket.connect(port, '127.0.0.1');
92
- });
822
+ function getKnownProjectPaths() {
823
+ const projectPaths = new Set();
824
+ // From known_projects table (persists even after all terminals are killed)
825
+ try {
826
+ const db = getGlobalDb();
827
+ const projects = db.prepare('SELECT project_path FROM known_projects').all();
828
+ for (const p of projects) {
829
+ projectPaths.add(p.project_path);
830
+ }
831
+ }
832
+ catch {
833
+ // Table may not exist yet
834
+ }
835
+ // From terminal_sessions table (catches any missed by known_projects)
836
+ try {
837
+ const db = getGlobalDb();
838
+ const sessions = db.prepare('SELECT DISTINCT project_path FROM terminal_sessions').all();
839
+ for (const s of sessions) {
840
+ projectPaths.add(s.project_path);
841
+ }
842
+ }
843
+ catch {
844
+ // Table may not exist yet
845
+ }
846
+ // From in-memory cache (includes projects activated this session)
847
+ for (const [projectPath] of projectTerminals) {
848
+ projectPaths.add(projectPath);
849
+ }
850
+ return Array.from(projectPaths);
93
851
  }
94
852
  /**
95
853
  * Get project name from path
@@ -97,58 +855,278 @@ async function isPortListening(port) {
97
855
  function getProjectName(projectPath) {
98
856
  return path.basename(projectPath);
99
857
  }
858
+ // Spec 0100: Gate watcher for af send notifications
859
+ const gateWatcher = new GateWatcher(log);
860
+ let gateWatcherInterval = null;
861
+ function startGateWatcher() {
862
+ gateWatcherInterval = setInterval(async () => {
863
+ const projectPaths = getKnownProjectPaths();
864
+ for (const projectPath of projectPaths) {
865
+ try {
866
+ const gateStatus = getGateStatusForProject(projectPath);
867
+ await gateWatcher.checkAndNotify(gateStatus, projectPath);
868
+ }
869
+ catch (err) {
870
+ log('WARN', `Gate watcher error for ${projectPath}: ${err instanceof Error ? err.message : String(err)}`);
871
+ }
872
+ }
873
+ }, 10_000);
874
+ }
875
+ const sseClients = [];
876
+ let notificationIdCounter = 0;
877
+ /**
878
+ * Broadcast a notification to all connected SSE clients
879
+ */
880
+ function broadcastNotification(notification) {
881
+ const id = ++notificationIdCounter;
882
+ const data = JSON.stringify({ ...notification, id });
883
+ const message = `id: ${id}\ndata: ${data}\n\n`;
884
+ for (const client of sseClients) {
885
+ try {
886
+ client.res.write(message);
887
+ }
888
+ catch {
889
+ // Client disconnected, will be cleaned up on next iteration
890
+ }
891
+ }
892
+ }
893
+ /**
894
+ * Get terminal list for a project from tower's registry.
895
+ * Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server fetch.
896
+ * Returns architect, builders, and shells with their URLs.
897
+ */
898
+ async function getTerminalsForProject(projectPath, proxyUrl) {
899
+ const manager = getTerminalManager();
900
+ const terminals = [];
901
+ // Query SQLite first, then augment with shepherd reconnection
902
+ const dbSessions = getTerminalSessionsForProject(projectPath);
903
+ // Use normalized path for cache consistency
904
+ const normalizedPath = normalizeProjectPath(projectPath);
905
+ // Build a fresh entry from SQLite, then replace atomically to avoid
906
+ // destroying in-memory state that was registered via POST /api/terminals.
907
+ // Previous approach cleared the cache then rebuilt, which lost terminals
908
+ // if their SQLite rows were deleted by external interference (e.g., tests).
909
+ const freshEntry = { builders: new Map(), shells: new Map(), fileTabs: new Map() };
910
+ // Load file tabs from SQLite (persisted across restarts)
911
+ const existingEntry = projectTerminals.get(normalizedPath);
912
+ if (existingEntry && existingEntry.fileTabs.size > 0) {
913
+ // Use in-memory state if already populated (avoids redundant DB reads)
914
+ freshEntry.fileTabs = existingEntry.fileTabs;
915
+ }
916
+ else {
917
+ freshEntry.fileTabs = loadFileTabsForProject(projectPath);
918
+ }
919
+ for (const dbSession of dbSessions) {
920
+ // Verify session still exists in TerminalManager (runtime state)
921
+ let session = manager.getSession(dbSession.id);
922
+ if (!session && dbSession.shepherd_socket && shepherdManager) {
923
+ // PTY session gone but shepherd may still be alive — reconnect on-the-fly
924
+ try {
925
+ // Restore auto-restart for architect sessions (same as startup reconciliation)
926
+ let restartOptions;
927
+ if (dbSession.type === 'architect') {
928
+ let architectCmd = 'claude';
929
+ const configPath = path.join(dbSession.project_path, 'af-config.json');
930
+ if (fs.existsSync(configPath)) {
931
+ try {
932
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
933
+ if (config.shell?.architect) {
934
+ architectCmd = config.shell.architect;
935
+ }
936
+ }
937
+ catch { /* use default */ }
938
+ }
939
+ const cmdParts = architectCmd.split(/\s+/);
940
+ const cleanEnv = { ...process.env };
941
+ delete cleanEnv['CLAUDECODE'];
942
+ restartOptions = {
943
+ command: cmdParts[0],
944
+ args: cmdParts.slice(1),
945
+ cwd: dbSession.project_path,
946
+ env: cleanEnv,
947
+ restartDelay: 2000,
948
+ maxRestarts: 50,
949
+ };
950
+ }
951
+ const client = await shepherdManager.reconnectSession(dbSession.id, dbSession.shepherd_socket, dbSession.shepherd_pid, dbSession.shepherd_start_time, restartOptions);
952
+ if (client) {
953
+ const replayData = client.getReplayData() ?? Buffer.alloc(0);
954
+ const label = dbSession.type === 'architect' ? 'Architect' : `${dbSession.type} ${dbSession.role_id || dbSession.id}`;
955
+ const newSession = manager.createSessionRaw({ label, cwd: dbSession.project_path });
956
+ const ptySession = manager.getSession(newSession.id);
957
+ if (ptySession) {
958
+ ptySession.attachShepherd(client, replayData, dbSession.shepherd_pid, dbSession.id);
959
+ // Clean up on exit (same as startup reconciliation path)
960
+ ptySession.on('exit', () => {
961
+ const currentEntry = getProjectTerminalsEntry(dbSession.project_path);
962
+ if (dbSession.type === 'architect' && currentEntry.architect === newSession.id) {
963
+ currentEntry.architect = undefined;
964
+ }
965
+ deleteTerminalSession(newSession.id);
966
+ });
967
+ }
968
+ deleteTerminalSession(dbSession.id);
969
+ saveTerminalSession(newSession.id, dbSession.project_path, dbSession.type, dbSession.role_id, dbSession.shepherd_pid, dbSession.shepherd_socket, dbSession.shepherd_pid, dbSession.shepherd_start_time);
970
+ dbSession.id = newSession.id;
971
+ session = manager.getSession(newSession.id);
972
+ log('INFO', `Reconnected to shepherd on-the-fly → ${newSession.id}`);
973
+ }
974
+ }
975
+ catch (err) {
976
+ log('WARN', `Failed shepherd on-the-fly reconnect for ${dbSession.id}: ${err.message}`);
977
+ }
978
+ }
979
+ if (!session) {
980
+ // Stale row, nothing to reconnect — clean up
981
+ deleteTerminalSession(dbSession.id);
982
+ continue;
983
+ }
984
+ if (dbSession.type === 'architect') {
985
+ freshEntry.architect = dbSession.id;
986
+ terminals.push({
987
+ type: 'architect',
988
+ id: 'architect',
989
+ label: 'Architect',
990
+ url: `${proxyUrl}?tab=architect`,
991
+ active: true,
992
+ });
993
+ }
994
+ else if (dbSession.type === 'builder') {
995
+ const builderId = dbSession.role_id || dbSession.id;
996
+ freshEntry.builders.set(builderId, dbSession.id);
997
+ terminals.push({
998
+ type: 'builder',
999
+ id: builderId,
1000
+ label: `Builder ${builderId}`,
1001
+ url: `${proxyUrl}?tab=builder-${builderId}`,
1002
+ active: true,
1003
+ });
1004
+ }
1005
+ else if (dbSession.type === 'shell') {
1006
+ const shellId = dbSession.role_id || dbSession.id;
1007
+ freshEntry.shells.set(shellId, dbSession.id);
1008
+ terminals.push({
1009
+ type: 'shell',
1010
+ id: shellId,
1011
+ label: `Shell ${shellId.replace('shell-', '')}`,
1012
+ url: `${proxyUrl}?tab=shell-${shellId}`,
1013
+ active: true,
1014
+ });
1015
+ }
1016
+ }
1017
+ // Also merge in-memory entries that may not be in SQLite yet
1018
+ // (e.g., registered via POST /api/terminals but SQLite row was lost)
1019
+ if (existingEntry) {
1020
+ if (existingEntry.architect && !freshEntry.architect) {
1021
+ const session = manager.getSession(existingEntry.architect);
1022
+ if (session && session.status === 'running') {
1023
+ freshEntry.architect = existingEntry.architect;
1024
+ terminals.push({
1025
+ type: 'architect',
1026
+ id: 'architect',
1027
+ label: 'Architect',
1028
+ url: `${proxyUrl}?tab=architect`,
1029
+ active: true,
1030
+ });
1031
+ }
1032
+ }
1033
+ for (const [builderId, terminalId] of existingEntry.builders) {
1034
+ if (!freshEntry.builders.has(builderId)) {
1035
+ const session = manager.getSession(terminalId);
1036
+ if (session && session.status === 'running') {
1037
+ freshEntry.builders.set(builderId, terminalId);
1038
+ terminals.push({
1039
+ type: 'builder',
1040
+ id: builderId,
1041
+ label: `Builder ${builderId}`,
1042
+ url: `${proxyUrl}?tab=builder-${builderId}`,
1043
+ active: true,
1044
+ });
1045
+ }
1046
+ }
1047
+ }
1048
+ for (const [shellId, terminalId] of existingEntry.shells) {
1049
+ if (!freshEntry.shells.has(shellId)) {
1050
+ const session = manager.getSession(terminalId);
1051
+ if (session && session.status === 'running') {
1052
+ freshEntry.shells.set(shellId, terminalId);
1053
+ terminals.push({
1054
+ type: 'shell',
1055
+ id: shellId,
1056
+ label: `Shell ${shellId.replace('shell-', '')}`,
1057
+ url: `${proxyUrl}?tab=shell-${shellId}`,
1058
+ active: true,
1059
+ });
1060
+ }
1061
+ }
1062
+ }
1063
+ }
1064
+ // Atomically replace the cache entry
1065
+ projectTerminals.set(normalizedPath, freshEntry);
1066
+ // Read gate status from porch YAML files
1067
+ const gateStatus = getGateStatusForProject(projectPath);
1068
+ return { terminals, gateStatus };
1069
+ }
1070
+ // Resolve once at module load: both symlinked and real temp dir paths
1071
+ const _tmpDir = tmpdir();
1072
+ const _tmpDirResolved = (() => {
1073
+ try {
1074
+ return fs.realpathSync(_tmpDir);
1075
+ }
1076
+ catch {
1077
+ return _tmpDir;
1078
+ }
1079
+ })();
1080
+ function isTempDirectory(projectPath) {
1081
+ return (projectPath.startsWith(_tmpDir + '/') ||
1082
+ projectPath.startsWith(_tmpDirResolved + '/') ||
1083
+ projectPath.startsWith('/tmp/') ||
1084
+ projectPath.startsWith('/private/tmp/'));
1085
+ }
100
1086
  /**
101
1087
  * Get all instances with their status
102
1088
  */
103
1089
  async function getInstances() {
104
- const allocations = loadPortAllocations();
1090
+ const knownPaths = getKnownProjectPaths();
105
1091
  const instances = [];
106
- for (const allocation of allocations) {
1092
+ for (const projectPath of knownPaths) {
107
1093
  // Skip builder worktrees - they're managed by their parent project
108
- if (allocation.project_path.includes('/.builders/')) {
1094
+ if (projectPath.includes('/.builders/')) {
109
1095
  continue;
110
1096
  }
111
- const basePort = allocation.base_port;
112
- 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;
118
- const ports = [
119
- {
120
- type: 'Dashboard',
121
- 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,
130
- },
131
- ];
1097
+ // Skip projects in temp directories (e.g. test artifacts) or whose directories no longer exist
1098
+ if (!projectPath.startsWith('remote:')) {
1099
+ if (!fs.existsSync(projectPath)) {
1100
+ continue;
1101
+ }
1102
+ if (isTempDirectory(projectPath)) {
1103
+ continue;
1104
+ }
1105
+ }
1106
+ // Encode project path for proxy URL
1107
+ const encodedPath = Buffer.from(projectPath).toString('base64url');
1108
+ const proxyUrl = `/project/${encodedPath}/`;
1109
+ // Get terminals and gate status from tower's registry
1110
+ // Phase 4 (Spec 0090): Tower manages terminals directly - no separate dashboard server
1111
+ const { terminals, gateStatus } = await getTerminalsForProject(projectPath, proxyUrl);
1112
+ // Project is active if it has any terminals (Phase 4: no port check needed)
1113
+ const isActive = terminals.length > 0;
132
1114
  instances.push({
133
- projectPath: allocation.project_path,
134
- projectName: getProjectName(allocation.project_path),
135
- basePort,
136
- dashboardPort,
137
- architectPort,
138
- registered: allocation.registered_at,
139
- lastUsed: allocation.last_used_at,
140
- running: dashboardActive,
141
- ports,
1115
+ projectPath,
1116
+ projectName: getProjectName(projectPath),
1117
+ running: isActive,
1118
+ proxyUrl,
1119
+ architectUrl: `${proxyUrl}?tab=architect`,
1120
+ terminals,
1121
+ gateStatus,
142
1122
  });
143
1123
  }
144
- // Sort: running first, then by last used (most recent first)
1124
+ // Sort: running first, then by project name
145
1125
  instances.sort((a, b) => {
146
1126
  if (a.running !== b.running) {
147
1127
  return a.running ? -1 : 1;
148
1128
  }
149
- const aTime = a.lastUsed ? new Date(a.lastUsed).getTime() : 0;
150
- const bTime = b.lastUsed ? new Date(b.lastUsed).getTime() : 0;
151
- return bTime - aTime;
1129
+ return a.projectName.localeCompare(b.projectName);
152
1130
  });
153
1131
  return instances;
154
1132
  }
@@ -164,6 +1142,10 @@ async function getDirectorySuggestions(inputPath) {
164
1142
  if (inputPath.startsWith('~')) {
165
1143
  inputPath = inputPath.replace('~', homedir());
166
1144
  }
1145
+ // Relative paths are meaningless for the tower daemon — only absolute paths
1146
+ if (!path.isAbsolute(inputPath)) {
1147
+ return [];
1148
+ }
167
1149
  // Determine the directory to list and the prefix to filter by
168
1150
  let dirToList;
169
1151
  let prefix;
@@ -213,12 +1195,10 @@ async function getDirectorySuggestions(inputPath) {
213
1195
  }
214
1196
  /**
215
1197
  * Launch a new agent-farm instance
216
- * First stops any stale state, then starts fresh
217
- * Auto-adopts non-codev directories
1198
+ * Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server
1199
+ * Auto-adopts non-codev directories and creates architect terminal
218
1200
  */
219
1201
  async function launchInstance(projectPath) {
220
- // Clean up stale port allocations before launching (handles machine restarts)
221
- cleanupStaleEntries();
222
1202
  // Validate path exists
223
1203
  if (!fs.existsSync(projectPath)) {
224
1204
  return { success: false, error: `Path does not exist: ${projectPath}` };
@@ -246,74 +1226,122 @@ async function launchInstance(projectPath) {
246
1226
  return { success: false, error: `Failed to adopt codev: ${err.message}` };
247
1227
  }
248
1228
  }
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
1229
+ // Phase 4 (Spec 0090): Tower manages terminals directly
1230
+ // No dashboard-server spawning - tower handles everything
253
1231
  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)
1232
+ // Ensure project has port allocation
287
1233
  const resolvedPath = fs.realpathSync(projectPath);
288
- const db = getGlobalDb();
289
- const allocation = db
290
- .prepare('SELECT base_port FROM port_allocations WHERE project_path = ? OR project_path = ?')
291
- .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
- };
1234
+ // Persist in known_projects so the project survives terminal cleanup
1235
+ registerKnownProject(resolvedPath);
1236
+ // Initialize project terminal entry
1237
+ const entry = getProjectTerminalsEntry(resolvedPath);
1238
+ // Create architect terminal if not already present
1239
+ if (!entry.architect) {
1240
+ const manager = getTerminalManager();
1241
+ // Read af-config.json to get the architect command
1242
+ let architectCmd = 'claude';
1243
+ const configPath = path.join(projectPath, 'af-config.json');
1244
+ if (fs.existsSync(configPath)) {
1245
+ try {
1246
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1247
+ if (config.shell?.architect) {
1248
+ architectCmd = config.shell.architect;
1249
+ }
1250
+ }
1251
+ catch {
1252
+ // Ignore config read errors, use default
1253
+ }
303
1254
  }
304
- }
305
- else {
306
- // No allocation found - process might have failed before registering
307
- if (stderr || stdout) {
308
- const errorInfo = stderr || stdout;
309
- child.unref();
310
- return {
311
- success: false,
312
- error: `Failed to start: ${errorInfo.trim().split('\n')[0]}`,
313
- };
1255
+ try {
1256
+ // Parse command string to separate command and args
1257
+ const cmdParts = architectCmd.split(/\s+/);
1258
+ const cmd = cmdParts[0];
1259
+ const cmdArgs = cmdParts.slice(1);
1260
+ // Build env with CLAUDECODE removed so spawned Claude processes
1261
+ // don't detect a nested session
1262
+ const cleanEnv = { ...process.env };
1263
+ delete cleanEnv['CLAUDECODE'];
1264
+ // Try shepherd first for persistent session with auto-restart
1265
+ let shepherdCreated = false;
1266
+ if (shepherdManager) {
1267
+ try {
1268
+ const sessionId = crypto.randomUUID();
1269
+ const client = await shepherdManager.createSession({
1270
+ sessionId,
1271
+ command: cmd,
1272
+ args: cmdArgs,
1273
+ cwd: projectPath,
1274
+ env: cleanEnv,
1275
+ cols: 200,
1276
+ rows: 50,
1277
+ restartOnExit: true,
1278
+ restartDelay: 2000,
1279
+ maxRestarts: 50,
1280
+ });
1281
+ // Get replay data and shepherd info
1282
+ const replayData = client.getReplayData() ?? Buffer.alloc(0);
1283
+ const shepherdInfo = shepherdManager.getSessionInfo(sessionId);
1284
+ // Create a PtySession backed by the shepherd client
1285
+ const session = manager.createSessionRaw({
1286
+ label: 'Architect',
1287
+ cwd: projectPath,
1288
+ });
1289
+ const ptySession = manager.getSession(session.id);
1290
+ if (ptySession) {
1291
+ ptySession.attachShepherd(client, replayData, shepherdInfo.pid, sessionId);
1292
+ }
1293
+ entry.architect = session.id;
1294
+ saveTerminalSession(session.id, resolvedPath, 'architect', null, shepherdInfo.pid, shepherdInfo.socketPath, shepherdInfo.pid, shepherdInfo.startTime);
1295
+ // Clean up cache/SQLite when the shepherd session exits
1296
+ if (ptySession) {
1297
+ ptySession.on('exit', () => {
1298
+ const currentEntry = getProjectTerminalsEntry(resolvedPath);
1299
+ if (currentEntry.architect === session.id) {
1300
+ currentEntry.architect = undefined;
1301
+ }
1302
+ deleteTerminalSession(session.id);
1303
+ log('INFO', `Architect shepherd session exited for ${projectPath}`);
1304
+ });
1305
+ }
1306
+ shepherdCreated = true;
1307
+ log('INFO', `Created shepherd-backed architect session for project: ${projectPath}`);
1308
+ }
1309
+ catch (shepherdErr) {
1310
+ log('WARN', `Shepherd creation failed for architect, falling back: ${shepherdErr.message}`);
1311
+ }
1312
+ }
1313
+ // Fallback: non-persistent session (graceful degradation per plan)
1314
+ // Shepherd is the only persistence backend for new sessions.
1315
+ if (!shepherdCreated) {
1316
+ const session = await manager.createSession({
1317
+ command: cmd,
1318
+ args: cmdArgs,
1319
+ cwd: projectPath,
1320
+ label: 'Architect',
1321
+ env: cleanEnv,
1322
+ });
1323
+ entry.architect = session.id;
1324
+ saveTerminalSession(session.id, resolvedPath, 'architect', null, session.pid);
1325
+ const ptySession = manager.getSession(session.id);
1326
+ if (ptySession) {
1327
+ ptySession.on('exit', () => {
1328
+ const currentEntry = getProjectTerminalsEntry(resolvedPath);
1329
+ if (currentEntry.architect === session.id) {
1330
+ currentEntry.architect = undefined;
1331
+ }
1332
+ deleteTerminalSession(session.id);
1333
+ log('INFO', `Architect pty exited for ${projectPath}`);
1334
+ });
1335
+ }
1336
+ log('WARN', `Architect terminal for ${projectPath} is non-persistent (shepherd unavailable)`);
1337
+ }
1338
+ log('INFO', `Created architect terminal for project: ${projectPath}`);
1339
+ }
1340
+ catch (err) {
1341
+ log('WARN', `Failed to create architect terminal: ${err.message}`);
1342
+ // Don't fail the launch - project is still active, just without architect
314
1343
  }
315
1344
  }
316
- child.unref();
317
1345
  return { success: true, adopted };
318
1346
  }
319
1347
  catch (err) {
@@ -321,60 +1349,210 @@ async function launchInstance(projectPath) {
321
1349
  }
322
1350
  }
323
1351
  /**
324
- * Get PID of process listening on a port
1352
+ * Kill a terminal session, including its shepherd auto-restart if applicable.
1353
+ * For shepherd-backed sessions, calls SessionManager.killSession() which clears
1354
+ * the restart timer and removes the session before sending SIGTERM, preventing
1355
+ * the shepherd from auto-restarting the process.
1356
+ */
1357
+ async function killTerminalWithShepherd(manager, terminalId) {
1358
+ const session = manager.getSession(terminalId);
1359
+ if (!session)
1360
+ return false;
1361
+ // If shepherd-backed, disable auto-restart via SessionManager before killing the PtySession
1362
+ if (session.shepherdBacked && session.shepherdSessionId && shepherdManager) {
1363
+ await shepherdManager.killSession(session.shepherdSessionId);
1364
+ }
1365
+ return manager.killSession(terminalId);
1366
+ }
1367
+ /**
1368
+ * Stop an agent-farm instance by killing all its terminals
1369
+ * Phase 4 (Spec 0090): Tower manages terminals directly
325
1370
  */
326
- function getProcessOnPort(targetPort) {
1371
+ async function stopInstance(projectPath) {
1372
+ const stopped = [];
1373
+ const manager = getTerminalManager();
1374
+ // Resolve symlinks for consistent lookup
1375
+ let resolvedPath = projectPath;
327
1376
  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;
1377
+ if (fs.existsSync(projectPath)) {
1378
+ resolvedPath = fs.realpathSync(projectPath);
1379
+ }
331
1380
  }
332
1381
  catch {
333
- return null;
1382
+ // Ignore - use original path
1383
+ }
1384
+ // Get project terminals
1385
+ const entry = projectTerminals.get(resolvedPath) || projectTerminals.get(projectPath);
1386
+ if (entry) {
1387
+ // Kill architect (disable shepherd auto-restart if applicable)
1388
+ if (entry.architect) {
1389
+ const session = manager.getSession(entry.architect);
1390
+ if (session) {
1391
+ await killTerminalWithShepherd(manager, entry.architect);
1392
+ stopped.push(session.pid);
1393
+ }
1394
+ }
1395
+ // Kill all shells (disable shepherd auto-restart if applicable)
1396
+ for (const terminalId of entry.shells.values()) {
1397
+ const session = manager.getSession(terminalId);
1398
+ if (session) {
1399
+ await killTerminalWithShepherd(manager, terminalId);
1400
+ stopped.push(session.pid);
1401
+ }
1402
+ }
1403
+ // Kill all builders (disable shepherd auto-restart if applicable)
1404
+ for (const terminalId of entry.builders.values()) {
1405
+ const session = manager.getSession(terminalId);
1406
+ if (session) {
1407
+ await killTerminalWithShepherd(manager, terminalId);
1408
+ stopped.push(session.pid);
1409
+ }
1410
+ }
1411
+ // Clear project from registry
1412
+ projectTerminals.delete(resolvedPath);
1413
+ projectTerminals.delete(projectPath);
1414
+ // TICK-001: Delete all terminal sessions from SQLite
1415
+ deleteProjectTerminalSessions(resolvedPath);
1416
+ if (resolvedPath !== projectPath) {
1417
+ deleteProjectTerminalSessions(projectPath);
1418
+ }
334
1419
  }
1420
+ if (stopped.length === 0) {
1421
+ return { success: true, error: 'No terminals found to stop', stopped };
1422
+ }
1423
+ return { success: true, stopped };
335
1424
  }
336
1425
  /**
337
- * Stop an agent-farm instance by killing processes on its ports
1426
+ * Find the tower template
1427
+ * Template is bundled with agent-farm package in templates/ directory
338
1428
  */
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);
350
- }
351
- catch {
352
- // Process may have already exited
1429
+ function findTemplatePath() {
1430
+ // Templates are at package root: packages/codev/templates/
1431
+ // From compiled: dist/agent-farm/servers/ -> ../../../templates/
1432
+ // From source: src/agent-farm/servers/ -> ../../../templates/
1433
+ const pkgPath = path.resolve(__dirname, '../../../templates/tower.html');
1434
+ if (fs.existsSync(pkgPath)) {
1435
+ return pkgPath;
1436
+ }
1437
+ return null;
1438
+ }
1439
+ // escapeHtml, parseJsonBody, isRequestAllowed imported from ../utils/server-utils.js
1440
+ // Find template path
1441
+ const templatePath = findTemplatePath();
1442
+ // WebSocket server for terminal connections (Phase 2 - Spec 0090)
1443
+ let terminalWss = null;
1444
+ // React dashboard dist path (for serving directly from tower)
1445
+ // Phase 4 (Spec 0090): Tower serves everything directly, no dashboard-server
1446
+ const reactDashboardPath = path.resolve(__dirname, '../../../dashboard/dist');
1447
+ const hasReactDashboard = fs.existsSync(reactDashboardPath);
1448
+ if (hasReactDashboard) {
1449
+ log('INFO', `React dashboard found at: ${reactDashboardPath}`);
1450
+ }
1451
+ else {
1452
+ log('WARN', 'React dashboard not found - project dashboards will not work');
1453
+ }
1454
+ // MIME types for static file serving
1455
+ const MIME_TYPES = {
1456
+ '.html': 'text/html',
1457
+ '.js': 'application/javascript',
1458
+ '.css': 'text/css',
1459
+ '.json': 'application/json',
1460
+ '.png': 'image/png',
1461
+ '.jpg': 'image/jpeg',
1462
+ '.gif': 'image/gif',
1463
+ '.svg': 'image/svg+xml',
1464
+ '.ico': 'image/x-icon',
1465
+ '.woff': 'font/woff',
1466
+ '.woff2': 'font/woff2',
1467
+ '.ttf': 'font/ttf',
1468
+ '.map': 'application/json',
1469
+ };
1470
+ /**
1471
+ * Serve a static file from the React dashboard dist
1472
+ */
1473
+ function serveStaticFile(filePath, res) {
1474
+ if (!fs.existsSync(filePath)) {
1475
+ return false;
1476
+ }
1477
+ const ext = path.extname(filePath);
1478
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
1479
+ try {
1480
+ const content = fs.readFileSync(filePath);
1481
+ res.writeHead(200, { 'Content-Type': contentType });
1482
+ res.end(content);
1483
+ return true;
1484
+ }
1485
+ catch {
1486
+ return false;
1487
+ }
1488
+ }
1489
+ /**
1490
+ * Handle tunnel management endpoints (Spec 0097 Phase 4).
1491
+ * Extracted so both /api/tunnel/* and /project/<encoded>/api/tunnel/* can use it.
1492
+ */
1493
+ async function handleTunnelEndpoint(req, res, tunnelSub) {
1494
+ // POST connect
1495
+ if (req.method === 'POST' && tunnelSub === 'connect') {
1496
+ try {
1497
+ const config = readCloudConfig();
1498
+ if (!config) {
1499
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1500
+ res.end(JSON.stringify({ success: false, error: 'Not registered. Run \'af tower register\' first.' }));
1501
+ return;
353
1502
  }
1503
+ if (tunnelClient)
1504
+ tunnelClient.resetCircuitBreaker();
1505
+ const client = await connectTunnel(config);
1506
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1507
+ res.end(JSON.stringify({ success: true, state: client.getState() }));
1508
+ }
1509
+ catch (err) {
1510
+ log('ERROR', `Tunnel connect failed: ${err.message}`);
1511
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1512
+ res.end(JSON.stringify({ success: false, error: err.message }));
354
1513
  }
1514
+ return;
355
1515
  }
356
- if (stopped.length === 0) {
357
- return { success: true, error: 'No processes found to stop', stopped };
1516
+ // POST disconnect
1517
+ if (req.method === 'POST' && tunnelSub === 'disconnect') {
1518
+ if (tunnelClient) {
1519
+ tunnelClient.disconnect();
1520
+ tunnelClient = null;
1521
+ }
1522
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1523
+ res.end(JSON.stringify({ success: true }));
1524
+ return;
358
1525
  }
359
- return { success: true, stopped };
360
- }
361
- /**
362
- * Find the tower template
363
- * Template is bundled with agent-farm package in templates/ directory
364
- */
365
- function findTemplatePath() {
366
- // Templates are at package root: packages/codev/templates/
367
- // From compiled: dist/agent-farm/servers/ -> ../../../templates/
368
- // From source: src/agent-farm/servers/ -> ../../../templates/
369
- const pkgPath = path.resolve(__dirname, '../../../templates/tower.html');
370
- if (fs.existsSync(pkgPath)) {
371
- return pkgPath;
1526
+ // GET status
1527
+ if (req.method === 'GET' && tunnelSub === 'status') {
1528
+ let config = null;
1529
+ try {
1530
+ config = readCloudConfig();
1531
+ }
1532
+ catch {
1533
+ // Config file may be corrupted — treat as unregistered
1534
+ }
1535
+ const state = tunnelClient?.getState() ?? 'disconnected';
1536
+ const uptime = tunnelClient?.getUptime() ?? null;
1537
+ const response = {
1538
+ registered: config !== null,
1539
+ state,
1540
+ uptime,
1541
+ };
1542
+ if (config) {
1543
+ response.towerId = config.tower_id;
1544
+ response.towerName = config.tower_name;
1545
+ response.serverUrl = config.server_url;
1546
+ response.accessUrl = `${config.server_url}/t/${config.tower_name}/`;
1547
+ }
1548
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1549
+ res.end(JSON.stringify(response));
1550
+ return;
372
1551
  }
373
- return null;
1552
+ // Unknown tunnel endpoint
1553
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1554
+ res.end(JSON.stringify({ error: 'Not found' }));
374
1555
  }
375
- // escapeHtml, parseJsonBody, isRequestAllowed imported from ../utils/server-utils.js
376
- // Find template path
377
- const templatePath = findTemplatePath();
378
1556
  // Create server
379
1557
  const server = http.createServer(async (req, res) => {
380
1558
  // Security: Validate Host and Origin headers
@@ -383,13 +1561,15 @@ const server = http.createServer(async (req, res) => {
383
1561
  res.end('Forbidden');
384
1562
  return;
385
1563
  }
386
- // CORS headers
1564
+ // CORS headers — allow localhost and tunnel proxy origins
387
1565
  const origin = req.headers.origin;
388
- if (origin && (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:'))) {
1566
+ if (origin && (origin.startsWith('http://localhost:') ||
1567
+ origin.startsWith('http://127.0.0.1:') ||
1568
+ origin.startsWith('https://'))) {
389
1569
  res.setHeader('Access-Control-Allow-Origin', origin);
390
1570
  }
391
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
392
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
1571
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
1572
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
393
1573
  res.setHeader('Cache-Control', 'no-store');
394
1574
  if (req.method === 'OPTIONS') {
395
1575
  res.writeHead(200);
@@ -398,13 +1578,378 @@ const server = http.createServer(async (req, res) => {
398
1578
  }
399
1579
  const url = new URL(req.url || '/', `http://localhost:${port}`);
400
1580
  try {
401
- // API: Get status of all instances
1581
+ // =========================================================================
1582
+ // NEW API ENDPOINTS (Spec 0090 - Tower as Single Daemon)
1583
+ // =========================================================================
1584
+ // Health check endpoint (Spec 0090 Phase 1)
1585
+ if (req.method === 'GET' && url.pathname === '/health') {
1586
+ const instances = await getInstances();
1587
+ const activeCount = instances.filter((i) => i.running).length;
1588
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1589
+ res.end(JSON.stringify({
1590
+ status: 'healthy',
1591
+ uptime: process.uptime(),
1592
+ activeProjects: activeCount,
1593
+ totalProjects: instances.length,
1594
+ memoryUsage: process.memoryUsage().heapUsed,
1595
+ timestamp: new Date().toISOString(),
1596
+ }));
1597
+ return;
1598
+ }
1599
+ // =========================================================================
1600
+ // Tunnel Management Endpoints (Spec 0097 Phase 4)
1601
+ // Also reachable from /project/<encoded>/api/tunnel/* (see project router)
1602
+ // =========================================================================
1603
+ if (url.pathname.startsWith('/api/tunnel/')) {
1604
+ const tunnelSub = url.pathname.slice('/api/tunnel/'.length);
1605
+ await handleTunnelEndpoint(req, res, tunnelSub);
1606
+ return;
1607
+ }
1608
+ // API: List all projects (Spec 0090 Phase 1)
1609
+ if (req.method === 'GET' && url.pathname === '/api/projects') {
1610
+ const instances = await getInstances();
1611
+ const projects = instances.map((i) => ({
1612
+ path: i.projectPath,
1613
+ name: i.projectName,
1614
+ active: i.running,
1615
+ proxyUrl: i.proxyUrl,
1616
+ terminals: i.terminals.length,
1617
+ }));
1618
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1619
+ res.end(JSON.stringify({ projects }));
1620
+ return;
1621
+ }
1622
+ // API: Project-specific endpoints (Spec 0090 Phase 1)
1623
+ // Routes: /api/projects/:encodedPath/activate, /deactivate, /status
1624
+ const projectApiMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/(activate|deactivate|status)$/);
1625
+ if (projectApiMatch) {
1626
+ const [, encodedPath, action] = projectApiMatch;
1627
+ let projectPath;
1628
+ try {
1629
+ projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
1630
+ if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
1631
+ throw new Error('Invalid path');
1632
+ }
1633
+ // Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
1634
+ projectPath = normalizeProjectPath(projectPath);
1635
+ }
1636
+ catch {
1637
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1638
+ res.end(JSON.stringify({ error: 'Invalid project path encoding' }));
1639
+ return;
1640
+ }
1641
+ // GET /api/projects/:path/status
1642
+ if (req.method === 'GET' && action === 'status') {
1643
+ const instances = await getInstances();
1644
+ const instance = instances.find((i) => i.projectPath === projectPath);
1645
+ if (!instance) {
1646
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1647
+ res.end(JSON.stringify({ error: 'Project not found' }));
1648
+ return;
1649
+ }
1650
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1651
+ res.end(JSON.stringify({
1652
+ path: instance.projectPath,
1653
+ name: instance.projectName,
1654
+ active: instance.running,
1655
+ terminals: instance.terminals,
1656
+ gateStatus: instance.gateStatus,
1657
+ }));
1658
+ return;
1659
+ }
1660
+ // POST /api/projects/:path/activate
1661
+ if (req.method === 'POST' && action === 'activate') {
1662
+ // Rate limiting: 10 activations per minute per client
1663
+ const clientIp = req.socket.remoteAddress || '127.0.0.1';
1664
+ if (isRateLimited(clientIp)) {
1665
+ res.writeHead(429, { 'Content-Type': 'application/json' });
1666
+ res.end(JSON.stringify({ error: 'Too many activations, try again later' }));
1667
+ return;
1668
+ }
1669
+ const result = await launchInstance(projectPath);
1670
+ if (result.success) {
1671
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1672
+ res.end(JSON.stringify({ success: true, adopted: result.adopted }));
1673
+ }
1674
+ else {
1675
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1676
+ res.end(JSON.stringify({ success: false, error: result.error }));
1677
+ }
1678
+ return;
1679
+ }
1680
+ // POST /api/projects/:path/deactivate
1681
+ if (req.method === 'POST' && action === 'deactivate') {
1682
+ // Check if project is known (has terminals or sessions)
1683
+ const knownPaths = getKnownProjectPaths();
1684
+ const resolvedPath = fs.existsSync(projectPath) ? fs.realpathSync(projectPath) : projectPath;
1685
+ const isKnown = knownPaths.some((p) => p === projectPath || p === resolvedPath);
1686
+ if (!isKnown) {
1687
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1688
+ res.end(JSON.stringify({ ok: false, error: 'Project not found' }));
1689
+ return;
1690
+ }
1691
+ // Phase 4: Stop terminals directly via tower
1692
+ const result = await stopInstance(projectPath);
1693
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1694
+ res.end(JSON.stringify(result));
1695
+ return;
1696
+ }
1697
+ }
1698
+ // =========================================================================
1699
+ // TERMINAL API (Phase 2 - Spec 0090)
1700
+ // =========================================================================
1701
+ // POST /api/terminals - Create a new terminal
1702
+ if (req.method === 'POST' && url.pathname === '/api/terminals') {
1703
+ try {
1704
+ const body = await parseJsonBody(req);
1705
+ const manager = getTerminalManager();
1706
+ // Parse request fields
1707
+ let command = typeof body.command === 'string' ? body.command : undefined;
1708
+ let args = Array.isArray(body.args) ? body.args : undefined;
1709
+ const cols = typeof body.cols === 'number' ? body.cols : undefined;
1710
+ const rows = typeof body.rows === 'number' ? body.rows : undefined;
1711
+ const cwd = typeof body.cwd === 'string' ? body.cwd : undefined;
1712
+ const env = typeof body.env === 'object' && body.env !== null ? body.env : undefined;
1713
+ const label = typeof body.label === 'string' ? body.label : undefined;
1714
+ // Optional session persistence via shepherd
1715
+ const projectPath = typeof body.projectPath === 'string' ? body.projectPath : null;
1716
+ const termType = typeof body.type === 'string' && ['builder', 'shell'].includes(body.type) ? body.type : null;
1717
+ const roleId = typeof body.roleId === 'string' ? body.roleId : null;
1718
+ const requestPersistence = body.persistent === true;
1719
+ let info;
1720
+ let persistent = false;
1721
+ // Try shepherd if persistence was requested
1722
+ if (requestPersistence && shepherdManager && command && cwd) {
1723
+ try {
1724
+ const sessionId = crypto.randomUUID();
1725
+ // Strip CLAUDECODE so spawned Claude processes don't detect nesting
1726
+ const sessionEnv = { ...(env || process.env) };
1727
+ delete sessionEnv['CLAUDECODE'];
1728
+ const client = await shepherdManager.createSession({
1729
+ sessionId,
1730
+ command,
1731
+ args: args || [],
1732
+ cwd,
1733
+ env: sessionEnv,
1734
+ cols: cols || 200,
1735
+ rows: 50,
1736
+ restartOnExit: false,
1737
+ });
1738
+ const replayData = client.getReplayData() ?? Buffer.alloc(0);
1739
+ const shepherdInfo = shepherdManager.getSessionInfo(sessionId);
1740
+ const session = manager.createSessionRaw({
1741
+ label: label || `terminal-${sessionId.slice(0, 8)}`,
1742
+ cwd,
1743
+ });
1744
+ const ptySession = manager.getSession(session.id);
1745
+ if (ptySession) {
1746
+ ptySession.attachShepherd(client, replayData, shepherdInfo.pid, sessionId);
1747
+ }
1748
+ info = session;
1749
+ persistent = true;
1750
+ if (projectPath && termType && roleId) {
1751
+ const entry = getProjectTerminalsEntry(normalizeProjectPath(projectPath));
1752
+ if (termType === 'builder') {
1753
+ entry.builders.set(roleId, session.id);
1754
+ }
1755
+ else {
1756
+ entry.shells.set(roleId, session.id);
1757
+ }
1758
+ saveTerminalSession(session.id, projectPath, termType, roleId, shepherdInfo.pid, shepherdInfo.socketPath, shepherdInfo.pid, shepherdInfo.startTime);
1759
+ log('INFO', `Registered shepherd terminal ${session.id} as ${termType} "${roleId}" for project ${projectPath}`);
1760
+ }
1761
+ }
1762
+ catch (shepherdErr) {
1763
+ log('WARN', `Shepherd creation failed for terminal, falling back: ${shepherdErr.message}`);
1764
+ }
1765
+ }
1766
+ // Fallback: non-persistent session (graceful degradation per plan)
1767
+ // Shepherd is the only persistence backend for new sessions.
1768
+ if (!info) {
1769
+ info = await manager.createSession({ command, args, cols, rows, cwd, env, label });
1770
+ persistent = false;
1771
+ if (projectPath && termType && roleId) {
1772
+ const entry = getProjectTerminalsEntry(normalizeProjectPath(projectPath));
1773
+ if (termType === 'builder') {
1774
+ entry.builders.set(roleId, info.id);
1775
+ }
1776
+ else {
1777
+ entry.shells.set(roleId, info.id);
1778
+ }
1779
+ saveTerminalSession(info.id, projectPath, termType, roleId, info.pid);
1780
+ log('WARN', `Terminal ${info.id} for ${projectPath} is non-persistent (shepherd unavailable)`);
1781
+ }
1782
+ }
1783
+ res.writeHead(201, { 'Content-Type': 'application/json' });
1784
+ res.end(JSON.stringify({ ...info, wsPath: `/ws/terminal/${info.id}`, persistent }));
1785
+ }
1786
+ catch (err) {
1787
+ const message = err instanceof Error ? err.message : 'Unknown error';
1788
+ log('ERROR', `Failed to create terminal: ${message}`);
1789
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1790
+ res.end(JSON.stringify({ error: 'INTERNAL_ERROR', message }));
1791
+ }
1792
+ return;
1793
+ }
1794
+ // GET /api/terminals - List all terminals
1795
+ if (req.method === 'GET' && url.pathname === '/api/terminals') {
1796
+ const manager = getTerminalManager();
1797
+ const terminals = manager.listSessions();
1798
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1799
+ res.end(JSON.stringify({ terminals }));
1800
+ return;
1801
+ }
1802
+ // Terminal-specific routes: /api/terminals/:id/*
1803
+ const terminalRouteMatch = url.pathname.match(/^\/api\/terminals\/([^/]+)(\/.*)?$/);
1804
+ if (terminalRouteMatch) {
1805
+ const [, terminalId, subpath] = terminalRouteMatch;
1806
+ const manager = getTerminalManager();
1807
+ // GET /api/terminals/:id - Get terminal info
1808
+ if (req.method === 'GET' && (!subpath || subpath === '')) {
1809
+ const session = manager.getSession(terminalId);
1810
+ if (!session) {
1811
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1812
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1813
+ return;
1814
+ }
1815
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1816
+ res.end(JSON.stringify(session.info));
1817
+ return;
1818
+ }
1819
+ // DELETE /api/terminals/:id - Kill terminal (disable shepherd auto-restart if applicable)
1820
+ if (req.method === 'DELETE' && (!subpath || subpath === '')) {
1821
+ if (!(await killTerminalWithShepherd(manager, terminalId))) {
1822
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1823
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1824
+ return;
1825
+ }
1826
+ // TICK-001: Delete from SQLite
1827
+ deleteTerminalSession(terminalId);
1828
+ res.writeHead(204);
1829
+ res.end();
1830
+ return;
1831
+ }
1832
+ // POST /api/terminals/:id/write - Write data to terminal (Spec 0104)
1833
+ if (req.method === 'POST' && subpath === '/write') {
1834
+ try {
1835
+ const body = await parseJsonBody(req);
1836
+ if (typeof body.data !== 'string') {
1837
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1838
+ res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'data must be a string' }));
1839
+ return;
1840
+ }
1841
+ const session = manager.getSession(terminalId);
1842
+ if (!session) {
1843
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1844
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1845
+ return;
1846
+ }
1847
+ session.write(body.data);
1848
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1849
+ res.end(JSON.stringify({ ok: true }));
1850
+ }
1851
+ catch {
1852
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1853
+ res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'Invalid JSON body' }));
1854
+ }
1855
+ return;
1856
+ }
1857
+ // POST /api/terminals/:id/resize - Resize terminal
1858
+ if (req.method === 'POST' && subpath === '/resize') {
1859
+ try {
1860
+ const body = await parseJsonBody(req);
1861
+ if (typeof body.cols !== 'number' || typeof body.rows !== 'number') {
1862
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1863
+ res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'cols and rows must be numbers' }));
1864
+ return;
1865
+ }
1866
+ const info = manager.resizeSession(terminalId, body.cols, body.rows);
1867
+ if (!info) {
1868
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1869
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1870
+ return;
1871
+ }
1872
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1873
+ res.end(JSON.stringify(info));
1874
+ }
1875
+ catch {
1876
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1877
+ res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'Invalid JSON body' }));
1878
+ }
1879
+ return;
1880
+ }
1881
+ // GET /api/terminals/:id/output - Get terminal output
1882
+ if (req.method === 'GET' && subpath === '/output') {
1883
+ const lines = parseInt(url.searchParams.get('lines') ?? '100', 10);
1884
+ const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
1885
+ const output = manager.getOutput(terminalId, lines, offset);
1886
+ if (!output) {
1887
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1888
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1889
+ return;
1890
+ }
1891
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1892
+ res.end(JSON.stringify(output));
1893
+ return;
1894
+ }
1895
+ }
1896
+ // =========================================================================
1897
+ // EXISTING API ENDPOINTS
1898
+ // =========================================================================
1899
+ // API: Get status of all instances (legacy - kept for backward compat)
402
1900
  if (req.method === 'GET' && url.pathname === '/api/status') {
403
1901
  const instances = await getInstances();
404
1902
  res.writeHead(200, { 'Content-Type': 'application/json' });
405
1903
  res.end(JSON.stringify({ instances }));
406
1904
  return;
407
1905
  }
1906
+ // API: Server-Sent Events for push notifications
1907
+ if (req.method === 'GET' && url.pathname === '/api/events') {
1908
+ const clientId = crypto.randomBytes(8).toString('hex');
1909
+ res.writeHead(200, {
1910
+ 'Content-Type': 'text/event-stream',
1911
+ 'Cache-Control': 'no-cache',
1912
+ Connection: 'keep-alive',
1913
+ });
1914
+ // Send initial connection event
1915
+ res.write(`data: ${JSON.stringify({ type: 'connected', id: clientId })}\n\n`);
1916
+ const client = { res, id: clientId };
1917
+ sseClients.push(client);
1918
+ log('INFO', `SSE client connected: ${clientId} (total: ${sseClients.length})`);
1919
+ // Clean up on disconnect
1920
+ req.on('close', () => {
1921
+ const index = sseClients.findIndex((c) => c.id === clientId);
1922
+ if (index !== -1) {
1923
+ sseClients.splice(index, 1);
1924
+ }
1925
+ log('INFO', `SSE client disconnected: ${clientId} (total: ${sseClients.length})`);
1926
+ });
1927
+ return;
1928
+ }
1929
+ // API: Receive notification from builder
1930
+ if (req.method === 'POST' && url.pathname === '/api/notify') {
1931
+ const body = await parseJsonBody(req);
1932
+ const type = typeof body.type === 'string' ? body.type : 'info';
1933
+ const title = typeof body.title === 'string' ? body.title : '';
1934
+ const messageBody = typeof body.body === 'string' ? body.body : '';
1935
+ const project = typeof body.project === 'string' ? body.project : undefined;
1936
+ if (!title || !messageBody) {
1937
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1938
+ res.end(JSON.stringify({ success: false, error: 'Missing title or body' }));
1939
+ return;
1940
+ }
1941
+ // Broadcast to all connected SSE clients
1942
+ broadcastNotification({
1943
+ type,
1944
+ title,
1945
+ body: messageBody,
1946
+ project,
1947
+ });
1948
+ log('INFO', `Notification broadcast: ${title}`);
1949
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1950
+ res.end(JSON.stringify({ success: true }));
1951
+ return;
1952
+ }
408
1953
  // API: Browse directories for autocomplete
409
1954
  if (req.method === 'GET' && url.pathname === '/api/browse') {
410
1955
  const inputPath = url.searchParams.get('path') || '';
@@ -488,12 +2033,27 @@ const server = http.createServer(async (req, res) => {
488
2033
  // API: Launch new instance
489
2034
  if (req.method === 'POST' && url.pathname === '/api/launch') {
490
2035
  const body = await parseJsonBody(req);
491
- const projectPath = body.projectPath;
2036
+ let projectPath = body.projectPath;
492
2037
  if (!projectPath) {
493
2038
  res.writeHead(400, { 'Content-Type': 'application/json' });
494
2039
  res.end(JSON.stringify({ success: false, error: 'Missing projectPath' }));
495
2040
  return;
496
2041
  }
2042
+ // Expand ~ to home directory
2043
+ if (projectPath.startsWith('~')) {
2044
+ projectPath = projectPath.replace('~', homedir());
2045
+ }
2046
+ // Reject relative paths — tower daemon CWD is unpredictable
2047
+ if (!path.isAbsolute(projectPath)) {
2048
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2049
+ res.end(JSON.stringify({
2050
+ success: false,
2051
+ error: `Relative paths are not supported. Use an absolute path (e.g., /Users/.../project or ~/Development/project).`,
2052
+ }));
2053
+ return;
2054
+ }
2055
+ // Normalize path (resolve .. segments, trailing slashes)
2056
+ projectPath = path.resolve(projectPath);
497
2057
  const result = await launchInstance(projectPath);
498
2058
  res.writeHead(result.success ? 200 : 400, { 'Content-Type': 'application/json' });
499
2059
  res.end(JSON.stringify(result));
@@ -502,13 +2062,13 @@ const server = http.createServer(async (req, res) => {
502
2062
  // API: Stop an instance
503
2063
  if (req.method === 'POST' && url.pathname === '/api/stop') {
504
2064
  const body = await parseJsonBody(req);
505
- const basePort = body.basePort;
506
- if (!basePort) {
2065
+ const targetPath = body.projectPath;
2066
+ if (!targetPath) {
507
2067
  res.writeHead(400, { 'Content-Type': 'application/json' });
508
- res.end(JSON.stringify({ success: false, error: 'Missing basePort' }));
2068
+ res.end(JSON.stringify({ success: false, error: 'Missing projectPath' }));
509
2069
  return;
510
2070
  }
511
- const result = await stopInstance(basePort);
2071
+ const result = await stopInstance(targetPath);
512
2072
  res.writeHead(200, { 'Content-Type': 'application/json' });
513
2073
  res.end(JSON.stringify(result));
514
2074
  return;
@@ -531,19 +2091,924 @@ const server = http.createServer(async (req, res) => {
531
2091
  }
532
2092
  return;
533
2093
  }
2094
+ // Project routes: /project/:base64urlPath/*
2095
+ // Phase 4 (Spec 0090): Tower serves React dashboard and handles APIs directly
2096
+ // Uses Base64URL (RFC 4648) encoding to avoid issues with slashes in paths
2097
+ if (url.pathname.startsWith('/project/')) {
2098
+ const pathParts = url.pathname.split('/');
2099
+ // ['', 'project', base64urlPath, ...rest]
2100
+ const encodedPath = pathParts[2];
2101
+ const subPath = pathParts.slice(3).join('/');
2102
+ if (!encodedPath) {
2103
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2104
+ res.end(JSON.stringify({ error: 'Missing project path' }));
2105
+ return;
2106
+ }
2107
+ // Decode Base64URL (RFC 4648)
2108
+ let projectPath;
2109
+ try {
2110
+ projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
2111
+ // Support both POSIX (/) and Windows (C:\) paths
2112
+ if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
2113
+ throw new Error('Invalid project path');
2114
+ }
2115
+ // Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
2116
+ projectPath = normalizeProjectPath(projectPath);
2117
+ }
2118
+ catch {
2119
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2120
+ res.end(JSON.stringify({ error: 'Invalid project path encoding' }));
2121
+ return;
2122
+ }
2123
+ // Phase 4 (Spec 0090): Tower handles everything directly
2124
+ const isApiCall = subPath.startsWith('api/') || subPath === 'api';
2125
+ const isWsPath = subPath.startsWith('ws/') || subPath === 'ws';
2126
+ // Tunnel endpoints are tower-level, not project-scoped, but the React
2127
+ // dashboard uses relative paths (./api/tunnel/...) which resolve to
2128
+ // /project/<encoded>/api/tunnel/... in project context. Handle here by
2129
+ // extracting the tunnel sub-path and dispatching to handleTunnelEndpoint().
2130
+ if (subPath.startsWith('api/tunnel/')) {
2131
+ const tunnelSub = subPath.slice('api/tunnel/'.length); // e.g. "status", "connect", "disconnect"
2132
+ await handleTunnelEndpoint(req, res, tunnelSub);
2133
+ return;
2134
+ }
2135
+ // GET /file?path=<relative-path> — Read project file by path (for StatusPanel project list)
2136
+ if (req.method === 'GET' && subPath === 'file' && url.searchParams.has('path')) {
2137
+ const relPath = url.searchParams.get('path');
2138
+ const fullPath = path.resolve(projectPath, relPath);
2139
+ // Security: ensure resolved path stays within project directory
2140
+ if (!fullPath.startsWith(projectPath + path.sep) && fullPath !== projectPath) {
2141
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
2142
+ res.end('Forbidden');
2143
+ return;
2144
+ }
2145
+ try {
2146
+ const content = fs.readFileSync(fullPath, 'utf-8');
2147
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
2148
+ res.end(content);
2149
+ }
2150
+ catch {
2151
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
2152
+ res.end('Not found');
2153
+ }
2154
+ return;
2155
+ }
2156
+ // Serve React dashboard static files directly if:
2157
+ // 1. Not an API call
2158
+ // 2. Not a WebSocket path
2159
+ // 3. React dashboard is available
2160
+ // 4. Project doesn't need to be running for static files
2161
+ if (!isApiCall && !isWsPath && hasReactDashboard) {
2162
+ // Determine which static file to serve
2163
+ let staticPath;
2164
+ if (!subPath || subPath === '' || subPath === 'index.html') {
2165
+ staticPath = path.join(reactDashboardPath, 'index.html');
2166
+ }
2167
+ else {
2168
+ // Check if it's a static asset
2169
+ staticPath = path.join(reactDashboardPath, subPath);
2170
+ }
2171
+ // Try to serve the static file
2172
+ if (serveStaticFile(staticPath, res)) {
2173
+ return;
2174
+ }
2175
+ // SPA fallback: serve index.html for client-side routing
2176
+ const indexPath = path.join(reactDashboardPath, 'index.html');
2177
+ if (serveStaticFile(indexPath, res)) {
2178
+ return;
2179
+ }
2180
+ }
2181
+ // Phase 4 (Spec 0090): Handle project APIs directly instead of proxying to dashboard-server
2182
+ if (isApiCall) {
2183
+ const apiPath = subPath.replace(/^api\/?/, '');
2184
+ // GET /api/state - Return project state (architect, builders, shells)
2185
+ if (req.method === 'GET' && (apiPath === 'state' || apiPath === '')) {
2186
+ // Refresh cache via getTerminalsForProject (handles SQLite sync
2187
+ // and shepherd reconnection in one place)
2188
+ const encodedPath = Buffer.from(projectPath).toString('base64url');
2189
+ const proxyUrl = `/project/${encodedPath}/`;
2190
+ const { gateStatus } = await getTerminalsForProject(projectPath, proxyUrl);
2191
+ // Now read from the refreshed cache
2192
+ const entry = getProjectTerminalsEntry(projectPath);
2193
+ const manager = getTerminalManager();
2194
+ const state = {
2195
+ architect: null,
2196
+ builders: [],
2197
+ utils: [],
2198
+ annotations: [],
2199
+ projectName: path.basename(projectPath),
2200
+ gateStatus,
2201
+ };
2202
+ // Add architect if exists
2203
+ if (entry.architect) {
2204
+ const session = manager.getSession(entry.architect);
2205
+ if (session) {
2206
+ state.architect = {
2207
+ port: 0,
2208
+ pid: session.pid || 0,
2209
+ terminalId: entry.architect,
2210
+ persistent: isSessionPersistent(entry.architect, session),
2211
+ };
2212
+ }
2213
+ }
2214
+ // Add shells from refreshed cache
2215
+ for (const [shellId, terminalId] of entry.shells) {
2216
+ const session = manager.getSession(terminalId);
2217
+ if (session) {
2218
+ state.utils.push({
2219
+ id: shellId,
2220
+ name: `Shell ${shellId.replace('shell-', '')}`,
2221
+ port: 0,
2222
+ pid: session.pid || 0,
2223
+ terminalId,
2224
+ persistent: isSessionPersistent(terminalId, session),
2225
+ });
2226
+ }
2227
+ }
2228
+ // Add builders from refreshed cache
2229
+ for (const [builderId, terminalId] of entry.builders) {
2230
+ const session = manager.getSession(terminalId);
2231
+ if (session) {
2232
+ state.builders.push({
2233
+ id: builderId,
2234
+ name: `Builder ${builderId}`,
2235
+ port: 0,
2236
+ pid: session.pid || 0,
2237
+ status: 'running',
2238
+ phase: '',
2239
+ worktree: '',
2240
+ branch: '',
2241
+ type: 'spec',
2242
+ terminalId,
2243
+ persistent: isSessionPersistent(terminalId, session),
2244
+ });
2245
+ }
2246
+ }
2247
+ // Add file tabs (Spec 0092 - served through Tower, no separate ports)
2248
+ for (const [tabId, tab] of entry.fileTabs) {
2249
+ state.annotations.push({
2250
+ id: tabId,
2251
+ file: tab.path,
2252
+ port: 0, // No separate port - served through Tower
2253
+ pid: 0, // No separate process
2254
+ });
2255
+ }
2256
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2257
+ res.end(JSON.stringify(state));
2258
+ return;
2259
+ }
2260
+ // POST /api/tabs/shell - Create a new shell terminal
2261
+ if (req.method === 'POST' && apiPath === 'tabs/shell') {
2262
+ try {
2263
+ const manager = getTerminalManager();
2264
+ const shellId = getNextShellId(projectPath);
2265
+ const shellCmd = process.env.SHELL || '/bin/bash';
2266
+ const shellArgs = [];
2267
+ let shellCreated = false;
2268
+ // Try shepherd first for persistent shell session
2269
+ if (shepherdManager) {
2270
+ try {
2271
+ const sessionId = crypto.randomUUID();
2272
+ // Strip CLAUDECODE so spawned Claude processes don't detect nesting
2273
+ const shellEnv = { ...process.env };
2274
+ delete shellEnv['CLAUDECODE'];
2275
+ const client = await shepherdManager.createSession({
2276
+ sessionId,
2277
+ command: shellCmd,
2278
+ args: shellArgs,
2279
+ cwd: projectPath,
2280
+ env: shellEnv,
2281
+ cols: 200,
2282
+ rows: 50,
2283
+ restartOnExit: false,
2284
+ });
2285
+ const replayData = client.getReplayData() ?? Buffer.alloc(0);
2286
+ const shepherdInfo = shepherdManager.getSessionInfo(sessionId);
2287
+ const session = manager.createSessionRaw({
2288
+ label: `Shell ${shellId.replace('shell-', '')}`,
2289
+ cwd: projectPath,
2290
+ });
2291
+ const ptySession = manager.getSession(session.id);
2292
+ if (ptySession) {
2293
+ ptySession.attachShepherd(client, replayData, shepherdInfo.pid, sessionId);
2294
+ }
2295
+ const entry = getProjectTerminalsEntry(projectPath);
2296
+ entry.shells.set(shellId, session.id);
2297
+ saveTerminalSession(session.id, projectPath, 'shell', shellId, shepherdInfo.pid, shepherdInfo.socketPath, shepherdInfo.pid, shepherdInfo.startTime);
2298
+ shellCreated = true;
2299
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2300
+ res.end(JSON.stringify({
2301
+ id: shellId,
2302
+ port: 0,
2303
+ name: `Shell ${shellId.replace('shell-', '')}`,
2304
+ terminalId: session.id,
2305
+ persistent: true,
2306
+ }));
2307
+ }
2308
+ catch (shepherdErr) {
2309
+ log('WARN', `Shepherd creation failed for shell, falling back: ${shepherdErr.message}`);
2310
+ }
2311
+ }
2312
+ // Fallback: non-persistent session (graceful degradation per plan)
2313
+ // Shepherd is the only persistence backend for new sessions.
2314
+ if (!shellCreated) {
2315
+ const session = await manager.createSession({
2316
+ command: shellCmd,
2317
+ args: shellArgs,
2318
+ cwd: projectPath,
2319
+ label: `Shell ${shellId.replace('shell-', '')}`,
2320
+ env: process.env,
2321
+ });
2322
+ const entry = getProjectTerminalsEntry(projectPath);
2323
+ entry.shells.set(shellId, session.id);
2324
+ saveTerminalSession(session.id, projectPath, 'shell', shellId, session.pid);
2325
+ log('WARN', `Shell ${shellId} for ${projectPath} is non-persistent (shepherd unavailable)`);
2326
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2327
+ res.end(JSON.stringify({
2328
+ id: shellId,
2329
+ port: 0,
2330
+ name: `Shell ${shellId.replace('shell-', '')}`,
2331
+ terminalId: session.id,
2332
+ persistent: false,
2333
+ }));
2334
+ }
2335
+ }
2336
+ catch (err) {
2337
+ log('ERROR', `Failed to create shell: ${err.message}`);
2338
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2339
+ res.end(JSON.stringify({ error: err.message }));
2340
+ }
2341
+ return;
2342
+ }
2343
+ // POST /api/tabs/file - Create a file tab (Spec 0092)
2344
+ if (req.method === 'POST' && apiPath === 'tabs/file') {
2345
+ try {
2346
+ const body = await new Promise((resolve) => {
2347
+ let data = '';
2348
+ req.on('data', (chunk) => data += chunk.toString());
2349
+ req.on('end', () => resolve(data));
2350
+ });
2351
+ const { path: filePath, line, terminalId } = JSON.parse(body || '{}');
2352
+ if (!filePath || typeof filePath !== 'string') {
2353
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2354
+ res.end(JSON.stringify({ error: 'Missing path parameter' }));
2355
+ return;
2356
+ }
2357
+ // Resolve path: use terminal's cwd for relative paths when terminalId is provided
2358
+ let fullPath;
2359
+ if (path.isAbsolute(filePath)) {
2360
+ fullPath = filePath;
2361
+ }
2362
+ else if (terminalId) {
2363
+ const manager = getTerminalManager();
2364
+ const session = manager.getSession(terminalId);
2365
+ if (session) {
2366
+ fullPath = path.join(session.cwd, filePath);
2367
+ }
2368
+ else {
2369
+ log('WARN', `Terminal session ${terminalId} not found, falling back to project root`);
2370
+ fullPath = path.join(projectPath, filePath);
2371
+ }
2372
+ }
2373
+ else {
2374
+ fullPath = path.join(projectPath, filePath);
2375
+ }
2376
+ // Security: symlink-aware containment check
2377
+ // For non-existent files, resolve the parent directory to handle
2378
+ // intermediate symlinks (e.g., /tmp -> /private/tmp on macOS).
2379
+ let resolvedPath;
2380
+ try {
2381
+ resolvedPath = fs.realpathSync(fullPath);
2382
+ }
2383
+ catch {
2384
+ try {
2385
+ resolvedPath = path.join(fs.realpathSync(path.dirname(fullPath)), path.basename(fullPath));
2386
+ }
2387
+ catch {
2388
+ resolvedPath = path.resolve(fullPath);
2389
+ }
2390
+ }
2391
+ let normalizedProject;
2392
+ try {
2393
+ normalizedProject = fs.realpathSync(projectPath);
2394
+ }
2395
+ catch {
2396
+ normalizedProject = path.resolve(projectPath);
2397
+ }
2398
+ const isWithinProject = resolvedPath.startsWith(normalizedProject + path.sep)
2399
+ || resolvedPath === normalizedProject;
2400
+ if (!isWithinProject) {
2401
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2402
+ res.end(JSON.stringify({ error: 'Path outside project' }));
2403
+ return;
2404
+ }
2405
+ // Non-existent files still create a tab (spec 0101: file viewer shows "File not found")
2406
+ const fileExists = fs.existsSync(fullPath);
2407
+ const entry = getProjectTerminalsEntry(projectPath);
2408
+ // Check if already open
2409
+ for (const [id, tab] of entry.fileTabs) {
2410
+ if (tab.path === fullPath) {
2411
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2412
+ res.end(JSON.stringify({ id, existing: true, line, notFound: !fileExists }));
2413
+ return;
2414
+ }
2415
+ }
2416
+ // Create new file tab (write-through: in-memory + SQLite)
2417
+ const id = `file-${crypto.randomUUID()}`;
2418
+ const createdAt = Date.now();
2419
+ entry.fileTabs.set(id, { id, path: fullPath, createdAt });
2420
+ saveFileTab(id, projectPath, fullPath, createdAt);
2421
+ log('INFO', `Created file tab: ${id} for ${path.basename(fullPath)}`);
2422
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2423
+ res.end(JSON.stringify({ id, existing: false, line, notFound: !fileExists }));
2424
+ }
2425
+ catch (err) {
2426
+ log('ERROR', `Failed to create file tab: ${err.message}`);
2427
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2428
+ res.end(JSON.stringify({ error: err.message }));
2429
+ }
2430
+ return;
2431
+ }
2432
+ // GET /api/file/:id - Get file content as JSON (Spec 0092)
2433
+ const fileGetMatch = apiPath.match(/^file\/([^/]+)$/);
2434
+ if (req.method === 'GET' && fileGetMatch) {
2435
+ const tabId = fileGetMatch[1];
2436
+ const entry = getProjectTerminalsEntry(projectPath);
2437
+ const tab = entry.fileTabs.get(tabId);
2438
+ if (!tab) {
2439
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2440
+ res.end(JSON.stringify({ error: 'File tab not found' }));
2441
+ return;
2442
+ }
2443
+ try {
2444
+ const ext = path.extname(tab.path).slice(1).toLowerCase();
2445
+ const isText = !['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'mp4', 'webm', 'mov', 'pdf'].includes(ext);
2446
+ if (isText) {
2447
+ const content = fs.readFileSync(tab.path, 'utf-8');
2448
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2449
+ res.end(JSON.stringify({
2450
+ path: tab.path,
2451
+ name: path.basename(tab.path),
2452
+ content,
2453
+ language: getLanguageForExt(ext),
2454
+ isMarkdown: ext === 'md',
2455
+ isImage: false,
2456
+ isVideo: false,
2457
+ }));
2458
+ }
2459
+ else {
2460
+ // For binary files, just return metadata
2461
+ const stat = fs.statSync(tab.path);
2462
+ const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext);
2463
+ const isVideo = ['mp4', 'webm', 'mov'].includes(ext);
2464
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2465
+ res.end(JSON.stringify({
2466
+ path: tab.path,
2467
+ name: path.basename(tab.path),
2468
+ content: null,
2469
+ language: ext,
2470
+ isMarkdown: false,
2471
+ isImage,
2472
+ isVideo,
2473
+ size: stat.size,
2474
+ }));
2475
+ }
2476
+ }
2477
+ catch (err) {
2478
+ log('ERROR', `GET /api/file/:id failed: ${err.message}`);
2479
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2480
+ res.end(JSON.stringify({ error: err.message }));
2481
+ }
2482
+ return;
2483
+ }
2484
+ // GET /api/file/:id/raw - Get raw file content (for images/video) (Spec 0092)
2485
+ const fileRawMatch = apiPath.match(/^file\/([^/]+)\/raw$/);
2486
+ if (req.method === 'GET' && fileRawMatch) {
2487
+ const tabId = fileRawMatch[1];
2488
+ const entry = getProjectTerminalsEntry(projectPath);
2489
+ const tab = entry.fileTabs.get(tabId);
2490
+ if (!tab) {
2491
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2492
+ res.end(JSON.stringify({ error: 'File tab not found' }));
2493
+ return;
2494
+ }
2495
+ try {
2496
+ const data = fs.readFileSync(tab.path);
2497
+ const mimeType = getMimeTypeForFile(tab.path);
2498
+ res.writeHead(200, {
2499
+ 'Content-Type': mimeType,
2500
+ 'Content-Length': data.length,
2501
+ 'Cache-Control': 'no-cache',
2502
+ });
2503
+ res.end(data);
2504
+ }
2505
+ catch (err) {
2506
+ log('ERROR', `GET /api/file/:id/raw failed: ${err.message}`);
2507
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2508
+ res.end(JSON.stringify({ error: err.message }));
2509
+ }
2510
+ return;
2511
+ }
2512
+ // POST /api/file/:id/save - Save file content (Spec 0092)
2513
+ const fileSaveMatch = apiPath.match(/^file\/([^/]+)\/save$/);
2514
+ if (req.method === 'POST' && fileSaveMatch) {
2515
+ const tabId = fileSaveMatch[1];
2516
+ const entry = getProjectTerminalsEntry(projectPath);
2517
+ const tab = entry.fileTabs.get(tabId);
2518
+ if (!tab) {
2519
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2520
+ res.end(JSON.stringify({ error: 'File tab not found' }));
2521
+ return;
2522
+ }
2523
+ try {
2524
+ const body = await new Promise((resolve) => {
2525
+ let data = '';
2526
+ req.on('data', (chunk) => data += chunk.toString());
2527
+ req.on('end', () => resolve(data));
2528
+ });
2529
+ const { content } = JSON.parse(body || '{}');
2530
+ if (typeof content !== 'string') {
2531
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2532
+ res.end(JSON.stringify({ error: 'Missing content parameter' }));
2533
+ return;
2534
+ }
2535
+ fs.writeFileSync(tab.path, content, 'utf-8');
2536
+ log('INFO', `Saved file: ${tab.path}`);
2537
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2538
+ res.end(JSON.stringify({ success: true }));
2539
+ }
2540
+ catch (err) {
2541
+ log('ERROR', `POST /api/file/:id/save failed: ${err.message}`);
2542
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2543
+ res.end(JSON.stringify({ error: err.message }));
2544
+ }
2545
+ return;
2546
+ }
2547
+ // DELETE /api/tabs/:id - Delete a terminal or file tab
2548
+ const deleteMatch = apiPath.match(/^tabs\/(.+)$/);
2549
+ if (req.method === 'DELETE' && deleteMatch) {
2550
+ const tabId = deleteMatch[1];
2551
+ const entry = getProjectTerminalsEntry(projectPath);
2552
+ const manager = getTerminalManager();
2553
+ // Check if it's a file tab first (Spec 0092, write-through: in-memory + SQLite)
2554
+ if (tabId.startsWith('file-')) {
2555
+ if (entry.fileTabs.has(tabId)) {
2556
+ entry.fileTabs.delete(tabId);
2557
+ deleteFileTab(tabId);
2558
+ log('INFO', `Deleted file tab: ${tabId}`);
2559
+ res.writeHead(204);
2560
+ res.end();
2561
+ }
2562
+ else {
2563
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2564
+ res.end(JSON.stringify({ error: 'File tab not found' }));
2565
+ }
2566
+ return;
2567
+ }
2568
+ // Find and delete the terminal
2569
+ let terminalId;
2570
+ if (tabId.startsWith('shell-')) {
2571
+ terminalId = entry.shells.get(tabId);
2572
+ if (terminalId) {
2573
+ entry.shells.delete(tabId);
2574
+ }
2575
+ }
2576
+ else if (tabId.startsWith('builder-')) {
2577
+ terminalId = entry.builders.get(tabId);
2578
+ if (terminalId) {
2579
+ entry.builders.delete(tabId);
2580
+ }
2581
+ }
2582
+ else if (tabId === 'architect') {
2583
+ terminalId = entry.architect;
2584
+ if (terminalId) {
2585
+ entry.architect = undefined;
2586
+ }
2587
+ }
2588
+ if (terminalId) {
2589
+ // Disable shepherd auto-restart if applicable, then kill the PtySession
2590
+ await killTerminalWithShepherd(manager, terminalId);
2591
+ // TICK-001: Delete from SQLite
2592
+ deleteTerminalSession(terminalId);
2593
+ res.writeHead(204);
2594
+ res.end();
2595
+ }
2596
+ else {
2597
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2598
+ res.end(JSON.stringify({ error: 'Tab not found' }));
2599
+ }
2600
+ return;
2601
+ }
2602
+ // POST /api/stop - Stop all terminals for project
2603
+ if (req.method === 'POST' && apiPath === 'stop') {
2604
+ const entry = getProjectTerminalsEntry(projectPath);
2605
+ const manager = getTerminalManager();
2606
+ // Kill all terminals (disable shepherd auto-restart if applicable)
2607
+ if (entry.architect) {
2608
+ await killTerminalWithShepherd(manager, entry.architect);
2609
+ }
2610
+ for (const terminalId of entry.shells.values()) {
2611
+ await killTerminalWithShepherd(manager, terminalId);
2612
+ }
2613
+ for (const terminalId of entry.builders.values()) {
2614
+ await killTerminalWithShepherd(manager, terminalId);
2615
+ }
2616
+ // Clear registry
2617
+ projectTerminals.delete(projectPath);
2618
+ // TICK-001: Delete all terminal sessions from SQLite
2619
+ deleteProjectTerminalSessions(projectPath);
2620
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2621
+ res.end(JSON.stringify({ ok: true }));
2622
+ return;
2623
+ }
2624
+ // GET /api/files - Return project directory tree for file browser (Spec 0092)
2625
+ if (req.method === 'GET' && apiPath === 'files') {
2626
+ const maxDepth = parseInt(url.searchParams.get('depth') || '3', 10);
2627
+ const ignore = new Set(['.git', 'node_modules', '.builders', 'dist', '.agent-farm', '.next', '.cache', '__pycache__']);
2628
+ function readTree(dir, depth) {
2629
+ if (depth <= 0)
2630
+ return [];
2631
+ try {
2632
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
2633
+ return entries
2634
+ .filter(e => !e.name.startsWith('.') || e.name === '.env.example')
2635
+ .filter(e => !ignore.has(e.name))
2636
+ .sort((a, b) => {
2637
+ // Directories first, then alphabetical
2638
+ if (a.isDirectory() && !b.isDirectory())
2639
+ return -1;
2640
+ if (!a.isDirectory() && b.isDirectory())
2641
+ return 1;
2642
+ return a.name.localeCompare(b.name);
2643
+ })
2644
+ .map(e => {
2645
+ const fullPath = path.join(dir, e.name);
2646
+ const relativePath = path.relative(projectPath, fullPath);
2647
+ if (e.isDirectory()) {
2648
+ return { name: e.name, path: relativePath, type: 'directory', children: readTree(fullPath, depth - 1) };
2649
+ }
2650
+ return { name: e.name, path: relativePath, type: 'file' };
2651
+ });
2652
+ }
2653
+ catch {
2654
+ return [];
2655
+ }
2656
+ }
2657
+ const tree = readTree(projectPath, maxDepth);
2658
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2659
+ res.end(JSON.stringify(tree));
2660
+ return;
2661
+ }
2662
+ // GET /api/git/status - Return git status for file browser (Spec 0092)
2663
+ if (req.method === 'GET' && apiPath === 'git/status') {
2664
+ try {
2665
+ // Get git status in porcelain format for parsing
2666
+ const result = execSync('git status --porcelain', {
2667
+ cwd: projectPath,
2668
+ encoding: 'utf-8',
2669
+ timeout: 5000,
2670
+ });
2671
+ // Parse porcelain output: XY filename
2672
+ // X = staging area status, Y = working tree status
2673
+ const modified = [];
2674
+ const staged = [];
2675
+ const untracked = [];
2676
+ for (const line of result.split('\n')) {
2677
+ if (!line)
2678
+ continue;
2679
+ const x = line[0]; // staging area
2680
+ const y = line[1]; // working tree
2681
+ const filepath = line.slice(3);
2682
+ if (x === '?' && y === '?') {
2683
+ untracked.push(filepath);
2684
+ }
2685
+ else {
2686
+ if (x !== ' ' && x !== '?') {
2687
+ staged.push(filepath);
2688
+ }
2689
+ if (y !== ' ' && y !== '?') {
2690
+ modified.push(filepath);
2691
+ }
2692
+ }
2693
+ }
2694
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2695
+ res.end(JSON.stringify({ modified, staged, untracked }));
2696
+ }
2697
+ catch (err) {
2698
+ // Not a git repo or git command failed — return graceful degradation with error field
2699
+ log('WARN', `GET /api/git/status failed: ${err.message}`);
2700
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2701
+ res.end(JSON.stringify({ modified: [], staged: [], untracked: [], error: err.message }));
2702
+ }
2703
+ return;
2704
+ }
2705
+ // GET /api/files/recent - Return recently opened file tabs (Spec 0092)
2706
+ if (req.method === 'GET' && apiPath === 'files/recent') {
2707
+ const entry = getProjectTerminalsEntry(projectPath);
2708
+ // Get all file tabs sorted by creation time (most recent first)
2709
+ const recentFiles = Array.from(entry.fileTabs.values())
2710
+ .sort((a, b) => b.createdAt - a.createdAt)
2711
+ .slice(0, 10) // Limit to 10 most recent
2712
+ .map(tab => ({
2713
+ id: tab.id,
2714
+ path: tab.path,
2715
+ name: path.basename(tab.path),
2716
+ relativePath: path.relative(projectPath, tab.path),
2717
+ }));
2718
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2719
+ res.end(JSON.stringify(recentFiles));
2720
+ return;
2721
+ }
2722
+ // GET /api/annotate/:tabId/* — Serve rich annotator template and sub-APIs
2723
+ const annotateMatch = apiPath.match(/^annotate\/([^/]+)(\/(.*))?$/);
2724
+ if (annotateMatch) {
2725
+ const tabId = annotateMatch[1];
2726
+ const subRoute = annotateMatch[3] || '';
2727
+ const entry = getProjectTerminalsEntry(projectPath);
2728
+ const tab = entry.fileTabs.get(tabId);
2729
+ if (!tab) {
2730
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2731
+ res.end(JSON.stringify({ error: 'File tab not found' }));
2732
+ return;
2733
+ }
2734
+ const filePath = tab.path;
2735
+ const ext = path.extname(filePath).slice(1).toLowerCase();
2736
+ const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext);
2737
+ const isVideo = ['mp4', 'webm', 'mov'].includes(ext);
2738
+ const is3D = ['stl', '3mf'].includes(ext);
2739
+ const isPdf = ext === 'pdf';
2740
+ const isMarkdown = ext === 'md';
2741
+ // Sub-route: GET /file — re-read file content from disk
2742
+ if (req.method === 'GET' && subRoute === 'file') {
2743
+ try {
2744
+ const content = fs.readFileSync(filePath, 'utf-8');
2745
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
2746
+ res.end(content);
2747
+ }
2748
+ catch (err) {
2749
+ log('ERROR', `GET /api/annotate/:id/file failed: ${err.message}`);
2750
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2751
+ res.end(JSON.stringify({ error: err.message }));
2752
+ }
2753
+ return;
2754
+ }
2755
+ // Sub-route: POST /save — save file content
2756
+ if (req.method === 'POST' && subRoute === 'save') {
2757
+ try {
2758
+ const body = await new Promise((resolve) => {
2759
+ let data = '';
2760
+ req.on('data', (chunk) => data += chunk.toString());
2761
+ req.on('end', () => resolve(data));
2762
+ });
2763
+ const parsed = JSON.parse(body || '{}');
2764
+ const fileContent = parsed.content;
2765
+ if (typeof fileContent !== 'string') {
2766
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
2767
+ res.end('Missing content');
2768
+ return;
2769
+ }
2770
+ fs.writeFileSync(filePath, fileContent, 'utf-8');
2771
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2772
+ res.end(JSON.stringify({ ok: true }));
2773
+ }
2774
+ catch (err) {
2775
+ log('ERROR', `POST /api/annotate/:id/save failed: ${err.message}`);
2776
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2777
+ res.end(JSON.stringify({ error: err.message }));
2778
+ }
2779
+ return;
2780
+ }
2781
+ // Sub-route: GET /api/mtime — file modification time
2782
+ if (req.method === 'GET' && subRoute === 'api/mtime') {
2783
+ try {
2784
+ const stat = fs.statSync(filePath);
2785
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2786
+ res.end(JSON.stringify({ mtime: stat.mtimeMs }));
2787
+ }
2788
+ catch (err) {
2789
+ log('ERROR', `GET /api/annotate/:id/api/mtime failed: ${err.message}`);
2790
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2791
+ res.end(JSON.stringify({ error: err.message }));
2792
+ }
2793
+ return;
2794
+ }
2795
+ // Sub-route: GET /api/image, /api/video, /api/model, /api/pdf — raw binary content
2796
+ if (req.method === 'GET' && (subRoute === 'api/image' || subRoute === 'api/video' || subRoute === 'api/model' || subRoute === 'api/pdf')) {
2797
+ try {
2798
+ const data = fs.readFileSync(filePath);
2799
+ const mimeType = getMimeTypeForFile(filePath);
2800
+ res.writeHead(200, {
2801
+ 'Content-Type': mimeType,
2802
+ 'Content-Length': data.length,
2803
+ 'Cache-Control': 'no-cache',
2804
+ });
2805
+ res.end(data);
2806
+ }
2807
+ catch (err) {
2808
+ log('ERROR', `GET /api/annotate/:id/${subRoute} failed: ${err.message}`);
2809
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2810
+ res.end(JSON.stringify({ error: err.message }));
2811
+ }
2812
+ return;
2813
+ }
2814
+ // Default: serve the annotator HTML template
2815
+ if (req.method === 'GET' && (subRoute === '' || subRoute === undefined)) {
2816
+ try {
2817
+ const templateFile = is3D ? '3d-viewer.html' : 'open.html';
2818
+ const tplPath = path.resolve(__dirname, `../../../templates/${templateFile}`);
2819
+ let html = fs.readFileSync(tplPath, 'utf-8');
2820
+ const fileName = path.basename(filePath);
2821
+ const fileSize = fs.statSync(filePath).size;
2822
+ if (is3D) {
2823
+ html = html.replace(/\{\{FILE\}\}/g, fileName);
2824
+ html = html.replace(/\{\{FILE_PATH_JSON\}\}/g, JSON.stringify(filePath));
2825
+ html = html.replace(/\{\{FORMAT\}\}/g, ext);
2826
+ }
2827
+ else {
2828
+ html = html.replace(/\{\{FILE\}\}/g, fileName);
2829
+ html = html.replace(/\{\{FILE_PATH\}\}/g, filePath);
2830
+ html = html.replace(/\{\{BUILDER_ID\}\}/g, '');
2831
+ html = html.replace(/\{\{LANG\}\}/g, getLanguageForExt(ext));
2832
+ html = html.replace(/\{\{IS_MARKDOWN\}\}/g, String(isMarkdown));
2833
+ html = html.replace(/\{\{IS_IMAGE\}\}/g, String(isImage));
2834
+ html = html.replace(/\{\{IS_VIDEO\}\}/g, String(isVideo));
2835
+ html = html.replace(/\{\{IS_PDF\}\}/g, String(isPdf));
2836
+ html = html.replace(/\{\{FILE_SIZE\}\}/g, String(fileSize));
2837
+ // Inject initialization script (template loads content via fetch)
2838
+ let initScript;
2839
+ if (isImage) {
2840
+ initScript = `initImage(${fileSize});`;
2841
+ }
2842
+ else if (isVideo) {
2843
+ initScript = `initVideo(${fileSize});`;
2844
+ }
2845
+ else if (isPdf) {
2846
+ initScript = `initPdf(${fileSize});`;
2847
+ }
2848
+ else {
2849
+ initScript = `fetch('file').then(r=>r.text()).then(init);`;
2850
+ }
2851
+ html = html.replace('// FILE_CONTENT will be injected by the server', initScript);
2852
+ }
2853
+ // Handle ?line= query param for scroll-to-line
2854
+ const lineParam = url.searchParams.get('line');
2855
+ if (lineParam) {
2856
+ 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>`;
2857
+ html = html.replace('</body>', `${scrollScript}</body>`);
2858
+ }
2859
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
2860
+ res.end(html);
2861
+ }
2862
+ catch (err) {
2863
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
2864
+ res.end(`Failed to serve annotator: ${err.message}`);
2865
+ }
2866
+ return;
2867
+ }
2868
+ }
2869
+ // Unhandled API route
2870
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2871
+ res.end(JSON.stringify({ error: 'API endpoint not found', path: apiPath }));
2872
+ return;
2873
+ }
2874
+ // For WebSocket paths, let the upgrade handler deal with it
2875
+ if (isWsPath) {
2876
+ // WebSocket paths are handled by the upgrade handler
2877
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
2878
+ res.end('WebSocket connections should use ws:// protocol');
2879
+ return;
2880
+ }
2881
+ // If we get here for non-API, non-WS paths and React dashboard is not available
2882
+ if (!hasReactDashboard) {
2883
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
2884
+ res.end('Dashboard not available');
2885
+ return;
2886
+ }
2887
+ // Fallback for unmatched paths
2888
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
2889
+ res.end('Not found');
2890
+ return;
2891
+ }
534
2892
  // 404 for everything else
535
2893
  res.writeHead(404, { 'Content-Type': 'text/plain' });
536
2894
  res.end('Not found');
537
2895
  }
538
2896
  catch (err) {
539
2897
  log('ERROR', `Request error: ${err.message}`);
540
- res.writeHead(500, { 'Content-Type': 'text/plain' });
541
- res.end('Internal server error: ' + err.message);
2898
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2899
+ res.end(JSON.stringify({ error: err.message }));
542
2900
  }
543
2901
  });
544
2902
  // SECURITY: Bind to localhost only to prevent network exposure
545
- server.listen(port, '127.0.0.1', () => {
2903
+ server.listen(port, '127.0.0.1', async () => {
546
2904
  log('INFO', `Tower server listening at http://localhost:${port}`);
2905
+ // Initialize shepherd session manager for persistent terminals
2906
+ const socketDir = path.join(homedir(), '.codev', 'run');
2907
+ const shepherdScript = path.join(__dirname, '..', '..', 'terminal', 'shepherd-main.js');
2908
+ shepherdManager = new SessionManager({
2909
+ socketDir,
2910
+ shepherdScript,
2911
+ nodeExecutable: process.execPath,
2912
+ });
2913
+ const staleCleaned = await shepherdManager.cleanupStaleSockets();
2914
+ if (staleCleaned > 0) {
2915
+ log('INFO', `Cleaned up ${staleCleaned} stale shepherd socket(s)`);
2916
+ }
2917
+ log('INFO', 'Shepherd session manager initialized');
2918
+ // TICK-001: Reconcile terminal sessions from previous run
2919
+ await reconcileTerminalSessions();
2920
+ // Spec 0100: Start background gate watcher for af send notifications
2921
+ startGateWatcher();
2922
+ log('INFO', 'Gate watcher started (10s poll interval)');
2923
+ // Spec 0097 Phase 4: Auto-connect tunnel if registered
2924
+ try {
2925
+ const config = readCloudConfig();
2926
+ if (config) {
2927
+ log('INFO', `Cloud config found, connecting tunnel (tower: ${config.tower_name}, key: ${maskApiKey(config.api_key)})`);
2928
+ await connectTunnel(config);
2929
+ }
2930
+ else {
2931
+ log('INFO', 'No cloud config found, operating in local-only mode');
2932
+ }
2933
+ }
2934
+ catch (err) {
2935
+ log('WARN', `Failed to read cloud config: ${err.message}. Operating in local-only mode.`);
2936
+ }
2937
+ // Start watching cloud-config.json for changes
2938
+ startConfigWatcher();
2939
+ });
2940
+ // Initialize terminal WebSocket server (Phase 2 - Spec 0090)
2941
+ terminalWss = new WebSocketServer({ noServer: true });
2942
+ // WebSocket upgrade handler for terminal connections and proxying
2943
+ server.on('upgrade', async (req, socket, head) => {
2944
+ const reqUrl = new URL(req.url || '/', `http://localhost:${port}`);
2945
+ // Phase 2: Handle /ws/terminal/:id routes directly
2946
+ const terminalMatch = reqUrl.pathname.match(/^\/ws\/terminal\/([^/]+)$/);
2947
+ if (terminalMatch) {
2948
+ const terminalId = terminalMatch[1];
2949
+ const manager = getTerminalManager();
2950
+ const session = manager.getSession(terminalId);
2951
+ if (!session) {
2952
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
2953
+ socket.destroy();
2954
+ return;
2955
+ }
2956
+ terminalWss.handleUpgrade(req, socket, head, (ws) => {
2957
+ handleTerminalWebSocket(ws, session, req);
2958
+ });
2959
+ return;
2960
+ }
2961
+ // Phase 4 (Spec 0090): Handle project WebSocket routes directly
2962
+ // Route: /project/:encodedPath/ws/terminal/:terminalId
2963
+ if (!reqUrl.pathname.startsWith('/project/')) {
2964
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
2965
+ socket.destroy();
2966
+ return;
2967
+ }
2968
+ const pathParts = reqUrl.pathname.split('/');
2969
+ // ['', 'project', base64urlPath, 'ws', 'terminal', terminalId]
2970
+ const encodedPath = pathParts[2];
2971
+ if (!encodedPath) {
2972
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
2973
+ socket.destroy();
2974
+ return;
2975
+ }
2976
+ // Decode Base64URL (RFC 4648) - NOT URL encoding
2977
+ // Wrap in try/catch to handle malformed Base64 input gracefully
2978
+ let projectPath;
2979
+ try {
2980
+ projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
2981
+ // Support both POSIX (/) and Windows (C:\) paths
2982
+ if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
2983
+ throw new Error('Invalid project path');
2984
+ }
2985
+ // Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
2986
+ projectPath = normalizeProjectPath(projectPath);
2987
+ }
2988
+ catch {
2989
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
2990
+ socket.destroy();
2991
+ return;
2992
+ }
2993
+ // Check for terminal WebSocket route: /project/:path/ws/terminal/:id
2994
+ const wsMatch = reqUrl.pathname.match(/^\/project\/[^/]+\/ws\/terminal\/([^/]+)$/);
2995
+ if (wsMatch) {
2996
+ const terminalId = wsMatch[1];
2997
+ const manager = getTerminalManager();
2998
+ const session = manager.getSession(terminalId);
2999
+ if (!session) {
3000
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
3001
+ socket.destroy();
3002
+ return;
3003
+ }
3004
+ terminalWss.handleUpgrade(req, socket, head, (ws) => {
3005
+ handleTerminalWebSocket(ws, session, req);
3006
+ });
3007
+ return;
3008
+ }
3009
+ // Unhandled WebSocket route
3010
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
3011
+ socket.destroy();
547
3012
  });
548
3013
  // Handle uncaught errors
549
3014
  process.on('uncaughtException', (err) => {