@cluesmith/codev 2.0.0-rc.5 → 2.0.0-rc.50

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 (330) 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-BIHeqvy0.css +32 -0
  5. package/dashboard/dist/assets/index-VvUWRPNP.js +120 -0
  6. package/dashboard/dist/assets/index-VvUWRPNP.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 +93 -64
  10. package/dist/agent-farm/cli.js.map +1 -1
  11. package/dist/agent-farm/commands/architect.d.ts.map +1 -1
  12. package/dist/agent-farm/commands/architect.js +13 -6
  13. package/dist/agent-farm/commands/architect.js.map +1 -1
  14. package/dist/agent-farm/commands/attach.d.ts +13 -0
  15. package/dist/agent-farm/commands/attach.d.ts.map +1 -0
  16. package/dist/agent-farm/commands/attach.js +179 -0
  17. package/dist/agent-farm/commands/attach.js.map +1 -0
  18. package/dist/agent-farm/commands/cleanup.d.ts.map +1 -1
  19. package/dist/agent-farm/commands/cleanup.js +30 -3
  20. package/dist/agent-farm/commands/cleanup.js.map +1 -1
  21. package/dist/agent-farm/commands/consult.js +1 -1
  22. package/dist/agent-farm/commands/consult.js.map +1 -1
  23. package/dist/agent-farm/commands/index.d.ts +2 -2
  24. package/dist/agent-farm/commands/index.d.ts.map +1 -1
  25. package/dist/agent-farm/commands/index.js +2 -2
  26. package/dist/agent-farm/commands/index.js.map +1 -1
  27. package/dist/agent-farm/commands/{util.d.ts → shell.d.ts} +5 -5
  28. package/dist/agent-farm/commands/shell.d.ts.map +1 -0
  29. package/dist/agent-farm/commands/{util.js → shell.js} +23 -36
  30. package/dist/agent-farm/commands/shell.js.map +1 -0
  31. package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
  32. package/dist/agent-farm/commands/spawn.js +455 -217
  33. package/dist/agent-farm/commands/spawn.js.map +1 -1
  34. package/dist/agent-farm/commands/start.d.ts +3 -0
  35. package/dist/agent-farm/commands/start.d.ts.map +1 -1
  36. package/dist/agent-farm/commands/start.js +92 -79
  37. package/dist/agent-farm/commands/start.js.map +1 -1
  38. package/dist/agent-farm/commands/status.d.ts +2 -0
  39. package/dist/agent-farm/commands/status.d.ts.map +1 -1
  40. package/dist/agent-farm/commands/status.js +56 -1
  41. package/dist/agent-farm/commands/status.js.map +1 -1
  42. package/dist/agent-farm/commands/stop.d.ts +6 -0
  43. package/dist/agent-farm/commands/stop.d.ts.map +1 -1
  44. package/dist/agent-farm/commands/stop.js +115 -11
  45. package/dist/agent-farm/commands/stop.js.map +1 -1
  46. package/dist/agent-farm/commands/tower.d.ts +9 -0
  47. package/dist/agent-farm/commands/tower.d.ts.map +1 -1
  48. package/dist/agent-farm/commands/tower.js +59 -19
  49. package/dist/agent-farm/commands/tower.js.map +1 -1
  50. package/dist/agent-farm/db/index.d.ts.map +1 -1
  51. package/dist/agent-farm/db/index.js +59 -0
  52. package/dist/agent-farm/db/index.js.map +1 -1
  53. package/dist/agent-farm/db/schema.d.ts +2 -2
  54. package/dist/agent-farm/db/schema.d.ts.map +1 -1
  55. package/dist/agent-farm/db/schema.js +8 -3
  56. package/dist/agent-farm/db/schema.js.map +1 -1
  57. package/dist/agent-farm/db/types.d.ts +3 -0
  58. package/dist/agent-farm/db/types.d.ts.map +1 -1
  59. package/dist/agent-farm/db/types.js +3 -0
  60. package/dist/agent-farm/db/types.js.map +1 -1
  61. package/dist/agent-farm/hq-connector.d.ts +2 -2
  62. package/dist/agent-farm/hq-connector.js +2 -2
  63. package/dist/agent-farm/lib/tower-client.d.ts +157 -0
  64. package/dist/agent-farm/lib/tower-client.d.ts.map +1 -0
  65. package/dist/agent-farm/lib/tower-client.js +223 -0
  66. package/dist/agent-farm/lib/tower-client.js.map +1 -0
  67. package/dist/agent-farm/servers/tower-server.js +1152 -95
  68. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  69. package/dist/agent-farm/state.d.ts +4 -10
  70. package/dist/agent-farm/state.d.ts.map +1 -1
  71. package/dist/agent-farm/state.js +30 -31
  72. package/dist/agent-farm/state.js.map +1 -1
  73. package/dist/agent-farm/types.d.ts +48 -0
  74. package/dist/agent-farm/types.d.ts.map +1 -1
  75. package/dist/agent-farm/utils/config.d.ts.map +1 -1
  76. package/dist/agent-farm/utils/config.js +12 -11
  77. package/dist/agent-farm/utils/config.js.map +1 -1
  78. package/dist/agent-farm/utils/deps.d.ts.map +1 -1
  79. package/dist/agent-farm/utils/deps.js +0 -16
  80. package/dist/agent-farm/utils/deps.js.map +1 -1
  81. package/dist/agent-farm/utils/notifications.d.ts +30 -0
  82. package/dist/agent-farm/utils/notifications.d.ts.map +1 -0
  83. package/dist/agent-farm/utils/notifications.js +121 -0
  84. package/dist/agent-farm/utils/notifications.js.map +1 -0
  85. package/dist/agent-farm/utils/server-utils.d.ts +2 -1
  86. package/dist/agent-farm/utils/server-utils.d.ts.map +1 -1
  87. package/dist/agent-farm/utils/server-utils.js +11 -1
  88. package/dist/agent-farm/utils/server-utils.js.map +1 -1
  89. package/dist/agent-farm/utils/shell.d.ts +9 -22
  90. package/dist/agent-farm/utils/shell.d.ts.map +1 -1
  91. package/dist/agent-farm/utils/shell.js +34 -34
  92. package/dist/agent-farm/utils/shell.js.map +1 -1
  93. package/dist/agent-farm/utils/terminal-ports.d.ts +1 -1
  94. package/dist/agent-farm/utils/terminal-ports.js +1 -1
  95. package/dist/cli.d.ts.map +1 -1
  96. package/dist/cli.js +5 -54
  97. package/dist/cli.js.map +1 -1
  98. package/dist/commands/adopt.d.ts.map +1 -1
  99. package/dist/commands/adopt.js +39 -4
  100. package/dist/commands/adopt.js.map +1 -1
  101. package/dist/commands/consult/index.d.ts.map +1 -1
  102. package/dist/commands/consult/index.js +63 -3
  103. package/dist/commands/consult/index.js.map +1 -1
  104. package/dist/commands/doctor.d.ts.map +1 -1
  105. package/dist/commands/doctor.js +0 -15
  106. package/dist/commands/doctor.js.map +1 -1
  107. package/dist/commands/init.d.ts.map +1 -1
  108. package/dist/commands/init.js +31 -2
  109. package/dist/commands/init.js.map +1 -1
  110. package/dist/commands/porch/build-counter.d.ts +5 -0
  111. package/dist/commands/porch/build-counter.d.ts.map +1 -0
  112. package/dist/commands/porch/build-counter.js +5 -0
  113. package/dist/commands/porch/build-counter.js.map +1 -0
  114. package/dist/commands/porch/checks.d.ts +16 -29
  115. package/dist/commands/porch/checks.d.ts.map +1 -1
  116. package/dist/commands/porch/checks.js +90 -144
  117. package/dist/commands/porch/checks.js.map +1 -1
  118. package/dist/commands/porch/claude.d.ts +27 -0
  119. package/dist/commands/porch/claude.d.ts.map +1 -0
  120. package/dist/commands/porch/claude.js +107 -0
  121. package/dist/commands/porch/claude.js.map +1 -0
  122. package/dist/commands/porch/index.d.ts +21 -43
  123. package/dist/commands/porch/index.d.ts.map +1 -1
  124. package/dist/commands/porch/index.js +456 -1015
  125. package/dist/commands/porch/index.js.map +1 -1
  126. package/dist/commands/porch/plan.d.ts +70 -0
  127. package/dist/commands/porch/plan.d.ts.map +1 -0
  128. package/dist/commands/porch/plan.js +190 -0
  129. package/dist/commands/porch/plan.js.map +1 -0
  130. package/dist/commands/porch/prompts.d.ts +19 -0
  131. package/dist/commands/porch/prompts.d.ts.map +1 -0
  132. package/dist/commands/porch/prompts.js +250 -0
  133. package/dist/commands/porch/prompts.js.map +1 -0
  134. package/dist/commands/porch/protocol.d.ts +59 -0
  135. package/dist/commands/porch/protocol.d.ts.map +1 -0
  136. package/dist/commands/porch/protocol.js +260 -0
  137. package/dist/commands/porch/protocol.js.map +1 -0
  138. package/dist/commands/porch/run.d.ts +40 -0
  139. package/dist/commands/porch/run.d.ts.map +1 -0
  140. package/dist/commands/porch/run.js +893 -0
  141. package/dist/commands/porch/run.js.map +1 -0
  142. package/dist/commands/porch/state.d.ts +23 -112
  143. package/dist/commands/porch/state.d.ts.map +1 -1
  144. package/dist/commands/porch/state.js +81 -699
  145. package/dist/commands/porch/state.js.map +1 -1
  146. package/dist/commands/porch/types.d.ts +72 -173
  147. package/dist/commands/porch/types.d.ts.map +1 -1
  148. package/dist/commands/porch/types.js +2 -1
  149. package/dist/commands/porch/types.js.map +1 -1
  150. package/dist/commands/update.d.ts.map +1 -1
  151. package/dist/commands/update.js +22 -0
  152. package/dist/commands/update.js.map +1 -1
  153. package/dist/lib/scaffold.d.ts +24 -0
  154. package/dist/lib/scaffold.d.ts.map +1 -1
  155. package/dist/lib/scaffold.js +78 -0
  156. package/dist/lib/scaffold.js.map +1 -1
  157. package/dist/terminal/index.d.ts +8 -0
  158. package/dist/terminal/index.d.ts.map +1 -0
  159. package/dist/terminal/index.js +5 -0
  160. package/dist/terminal/index.js.map +1 -0
  161. package/dist/terminal/pty-manager.d.ts +60 -0
  162. package/dist/terminal/pty-manager.d.ts.map +1 -0
  163. package/dist/terminal/pty-manager.js +334 -0
  164. package/dist/terminal/pty-manager.js.map +1 -0
  165. package/dist/terminal/pty-session.d.ts +79 -0
  166. package/dist/terminal/pty-session.d.ts.map +1 -0
  167. package/dist/terminal/pty-session.js +215 -0
  168. package/dist/terminal/pty-session.js.map +1 -0
  169. package/dist/terminal/ring-buffer.d.ts +27 -0
  170. package/dist/terminal/ring-buffer.d.ts.map +1 -0
  171. package/dist/terminal/ring-buffer.js +74 -0
  172. package/dist/terminal/ring-buffer.js.map +1 -0
  173. package/dist/terminal/ws-protocol.d.ts +27 -0
  174. package/dist/terminal/ws-protocol.d.ts.map +1 -0
  175. package/dist/terminal/ws-protocol.js +44 -0
  176. package/dist/terminal/ws-protocol.js.map +1 -0
  177. package/package.json +18 -3
  178. package/skeleton/DEPENDENCIES.md +3 -29
  179. package/skeleton/builders.md +1 -1
  180. package/skeleton/protocol-schema.json +282 -0
  181. package/skeleton/protocols/bugfix/builder-prompt.md +49 -0
  182. package/skeleton/protocols/bugfix/protocol.json +14 -2
  183. package/skeleton/protocols/experiment/builder-prompt.md +47 -0
  184. package/skeleton/protocols/experiment/protocol.json +101 -0
  185. package/skeleton/protocols/maintain/builder-prompt.md +41 -0
  186. package/skeleton/protocols/maintain/prompts/audit.md +111 -0
  187. package/skeleton/protocols/maintain/prompts/clean.md +91 -0
  188. package/skeleton/protocols/maintain/prompts/sync.md +113 -0
  189. package/skeleton/protocols/maintain/prompts/verify.md +110 -0
  190. package/skeleton/protocols/maintain/protocol.json +141 -0
  191. package/skeleton/protocols/maintain/protocol.md +13 -7
  192. package/skeleton/protocols/protocol-schema.json +53 -0
  193. package/skeleton/protocols/spider/builder-prompt.md +53 -0
  194. package/skeleton/protocols/spider/prompts/implement.md +109 -50
  195. package/skeleton/protocols/spider/prompts/specify.md +29 -4
  196. package/skeleton/protocols/spider/protocol.json +96 -154
  197. package/skeleton/protocols/spider/protocol.md +26 -16
  198. package/skeleton/protocols/spider/templates/plan.md +14 -0
  199. package/skeleton/protocols/tick/builder-prompt.md +51 -0
  200. package/skeleton/protocols/tick/protocol.json +7 -2
  201. package/skeleton/resources/commands/agent-farm.md +25 -43
  202. package/skeleton/resources/commands/overview.md +6 -16
  203. package/skeleton/resources/workflow-reference.md +2 -2
  204. package/skeleton/roles/architect.md +152 -315
  205. package/skeleton/roles/builder.md +109 -218
  206. package/skeleton/templates/AGENTS.md +1 -1
  207. package/skeleton/templates/CLAUDE.md +1 -1
  208. package/skeleton/templates/cheatsheet.md +4 -2
  209. package/templates/dashboard/index.html +17 -43
  210. package/templates/dashboard/js/dialogs.js +7 -7
  211. package/templates/dashboard/js/files.js +2 -2
  212. package/templates/dashboard/js/main.js +3 -3
  213. package/templates/dashboard/js/projects.js +3 -3
  214. package/templates/dashboard/js/tabs.js +1 -1
  215. package/templates/dashboard/js/utils.js +22 -87
  216. package/templates/tower.html +542 -27
  217. package/dist/agent-farm/commands/kickoff.d.ts +0 -19
  218. package/dist/agent-farm/commands/kickoff.d.ts.map +0 -1
  219. package/dist/agent-farm/commands/kickoff.js +0 -331
  220. package/dist/agent-farm/commands/kickoff.js.map +0 -1
  221. package/dist/agent-farm/commands/rename.d.ts +0 -13
  222. package/dist/agent-farm/commands/rename.d.ts.map +0 -1
  223. package/dist/agent-farm/commands/rename.js +0 -33
  224. package/dist/agent-farm/commands/rename.js.map +0 -1
  225. package/dist/agent-farm/commands/tutorial.d.ts +0 -10
  226. package/dist/agent-farm/commands/tutorial.d.ts.map +0 -1
  227. package/dist/agent-farm/commands/tutorial.js +0 -49
  228. package/dist/agent-farm/commands/tutorial.js.map +0 -1
  229. package/dist/agent-farm/commands/util.d.ts.map +0 -1
  230. package/dist/agent-farm/commands/util.js.map +0 -1
  231. package/dist/agent-farm/servers/dashboard-server.d.ts +0 -7
  232. package/dist/agent-farm/servers/dashboard-server.d.ts.map +0 -1
  233. package/dist/agent-farm/servers/dashboard-server.js +0 -1872
  234. package/dist/agent-farm/servers/dashboard-server.js.map +0 -1
  235. package/dist/agent-farm/tutorial/index.d.ts +0 -8
  236. package/dist/agent-farm/tutorial/index.d.ts.map +0 -1
  237. package/dist/agent-farm/tutorial/index.js +0 -8
  238. package/dist/agent-farm/tutorial/index.js.map +0 -1
  239. package/dist/agent-farm/tutorial/prompts.d.ts +0 -57
  240. package/dist/agent-farm/tutorial/prompts.d.ts.map +0 -1
  241. package/dist/agent-farm/tutorial/prompts.js +0 -147
  242. package/dist/agent-farm/tutorial/prompts.js.map +0 -1
  243. package/dist/agent-farm/tutorial/runner.d.ts +0 -52
  244. package/dist/agent-farm/tutorial/runner.d.ts.map +0 -1
  245. package/dist/agent-farm/tutorial/runner.js +0 -204
  246. package/dist/agent-farm/tutorial/runner.js.map +0 -1
  247. package/dist/agent-farm/tutorial/state.d.ts +0 -26
  248. package/dist/agent-farm/tutorial/state.d.ts.map +0 -1
  249. package/dist/agent-farm/tutorial/state.js +0 -89
  250. package/dist/agent-farm/tutorial/state.js.map +0 -1
  251. package/dist/agent-farm/tutorial/steps/first-spec.d.ts +0 -7
  252. package/dist/agent-farm/tutorial/steps/first-spec.d.ts.map +0 -1
  253. package/dist/agent-farm/tutorial/steps/first-spec.js +0 -136
  254. package/dist/agent-farm/tutorial/steps/first-spec.js.map +0 -1
  255. package/dist/agent-farm/tutorial/steps/implementation.d.ts +0 -7
  256. package/dist/agent-farm/tutorial/steps/implementation.d.ts.map +0 -1
  257. package/dist/agent-farm/tutorial/steps/implementation.js +0 -76
  258. package/dist/agent-farm/tutorial/steps/implementation.js.map +0 -1
  259. package/dist/agent-farm/tutorial/steps/index.d.ts +0 -10
  260. package/dist/agent-farm/tutorial/steps/index.d.ts.map +0 -1
  261. package/dist/agent-farm/tutorial/steps/index.js +0 -10
  262. package/dist/agent-farm/tutorial/steps/index.js.map +0 -1
  263. package/dist/agent-farm/tutorial/steps/planning.d.ts +0 -7
  264. package/dist/agent-farm/tutorial/steps/planning.d.ts.map +0 -1
  265. package/dist/agent-farm/tutorial/steps/planning.js +0 -143
  266. package/dist/agent-farm/tutorial/steps/planning.js.map +0 -1
  267. package/dist/agent-farm/tutorial/steps/review.d.ts +0 -7
  268. package/dist/agent-farm/tutorial/steps/review.d.ts.map +0 -1
  269. package/dist/agent-farm/tutorial/steps/review.js +0 -78
  270. package/dist/agent-farm/tutorial/steps/review.js.map +0 -1
  271. package/dist/agent-farm/tutorial/steps/setup.d.ts +0 -7
  272. package/dist/agent-farm/tutorial/steps/setup.d.ts.map +0 -1
  273. package/dist/agent-farm/tutorial/steps/setup.js +0 -126
  274. package/dist/agent-farm/tutorial/steps/setup.js.map +0 -1
  275. package/dist/agent-farm/tutorial/steps/welcome.d.ts +0 -7
  276. package/dist/agent-farm/tutorial/steps/welcome.d.ts.map +0 -1
  277. package/dist/agent-farm/tutorial/steps/welcome.js +0 -50
  278. package/dist/agent-farm/tutorial/steps/welcome.js.map +0 -1
  279. package/dist/commands/pcheck/cache.d.ts +0 -48
  280. package/dist/commands/pcheck/cache.d.ts.map +0 -1
  281. package/dist/commands/pcheck/cache.js +0 -170
  282. package/dist/commands/pcheck/cache.js.map +0 -1
  283. package/dist/commands/pcheck/evaluator.d.ts +0 -15
  284. package/dist/commands/pcheck/evaluator.d.ts.map +0 -1
  285. package/dist/commands/pcheck/evaluator.js +0 -246
  286. package/dist/commands/pcheck/evaluator.js.map +0 -1
  287. package/dist/commands/pcheck/index.d.ts +0 -12
  288. package/dist/commands/pcheck/index.d.ts.map +0 -1
  289. package/dist/commands/pcheck/index.js +0 -249
  290. package/dist/commands/pcheck/index.js.map +0 -1
  291. package/dist/commands/pcheck/parser.d.ts +0 -39
  292. package/dist/commands/pcheck/parser.d.ts.map +0 -1
  293. package/dist/commands/pcheck/parser.js +0 -155
  294. package/dist/commands/pcheck/parser.js.map +0 -1
  295. package/dist/commands/pcheck/types.d.ts +0 -82
  296. package/dist/commands/pcheck/types.d.ts.map +0 -1
  297. package/dist/commands/pcheck/types.js +0 -5
  298. package/dist/commands/pcheck/types.js.map +0 -1
  299. package/dist/commands/porch/consultation.d.ts +0 -56
  300. package/dist/commands/porch/consultation.d.ts.map +0 -1
  301. package/dist/commands/porch/consultation.js +0 -330
  302. package/dist/commands/porch/consultation.js.map +0 -1
  303. package/dist/commands/porch/notifications.d.ts +0 -99
  304. package/dist/commands/porch/notifications.d.ts.map +0 -1
  305. package/dist/commands/porch/notifications.js +0 -223
  306. package/dist/commands/porch/notifications.js.map +0 -1
  307. package/dist/commands/porch/plan-parser.d.ts +0 -38
  308. package/dist/commands/porch/plan-parser.d.ts.map +0 -1
  309. package/dist/commands/porch/plan-parser.js +0 -166
  310. package/dist/commands/porch/plan-parser.js.map +0 -1
  311. package/dist/commands/porch/protocol-loader.d.ts +0 -46
  312. package/dist/commands/porch/protocol-loader.d.ts.map +0 -1
  313. package/dist/commands/porch/protocol-loader.js +0 -253
  314. package/dist/commands/porch/protocol-loader.js.map +0 -1
  315. package/dist/commands/porch/signal-parser.d.ts +0 -88
  316. package/dist/commands/porch/signal-parser.d.ts.map +0 -1
  317. package/dist/commands/porch/signal-parser.js +0 -148
  318. package/dist/commands/porch/signal-parser.js.map +0 -1
  319. package/dist/commands/tower.d.ts +0 -16
  320. package/dist/commands/tower.d.ts.map +0 -1
  321. package/dist/commands/tower.js +0 -21
  322. package/dist/commands/tower.js.map +0 -1
  323. package/skeleton/config.json +0 -7
  324. package/skeleton/porch/protocols/bugfix.json +0 -85
  325. package/skeleton/porch/protocols/spider.json +0 -135
  326. package/skeleton/porch/protocols/tick.json +0 -76
  327. package/skeleton/protocols/spider/prompts/defend.md +0 -215
  328. package/skeleton/protocols/spider/prompts/evaluate.md +0 -241
  329. package/templates/dashboard/css/activity.css +0 -151
  330. package/templates/dashboard/js/activity.js +0 -112
@@ -7,17 +7,182 @@ import http from 'node:http';
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import net from 'node:net';
10
+ import crypto from 'node:crypto';
10
11
  import { spawn, execSync } from 'node:child_process';
11
12
  import { homedir } from 'node:os';
12
13
  import { fileURLToPath } from 'node:url';
13
14
  import { Command } from 'commander';
15
+ import { WebSocketServer, WebSocket } from 'ws';
14
16
  import { getGlobalDb } from '../db/index.js';
15
17
  import { cleanupStaleEntries } from '../utils/port-registry.js';
16
18
  import { parseJsonBody, isRequestAllowed } from '../utils/server-utils.js';
19
+ import { TerminalManager } from '../../terminal/pty-manager.js';
20
+ import { encodeData, encodeControl, decodeFrame } from '../../terminal/ws-protocol.js';
17
21
  const __filename = fileURLToPath(import.meta.url);
18
22
  const __dirname = path.dirname(__filename);
19
23
  // Default port for tower dashboard
20
24
  const DEFAULT_PORT = 4100;
25
+ // Rate limiting for activation requests (Spec 0090 Phase 1)
26
+ // Simple in-memory rate limiter: 10 activations per minute per client
27
+ const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
28
+ const RATE_LIMIT_MAX = 10;
29
+ const activationRateLimits = new Map();
30
+ /**
31
+ * Check if a client has exceeded the rate limit for activations
32
+ * Returns true if rate limit exceeded, false if allowed
33
+ */
34
+ function isRateLimited(clientIp) {
35
+ const now = Date.now();
36
+ const entry = activationRateLimits.get(clientIp);
37
+ if (!entry || now - entry.windowStart >= RATE_LIMIT_WINDOW_MS) {
38
+ // New window
39
+ activationRateLimits.set(clientIp, { count: 1, windowStart: now });
40
+ return false;
41
+ }
42
+ if (entry.count >= RATE_LIMIT_MAX) {
43
+ return true;
44
+ }
45
+ entry.count++;
46
+ return false;
47
+ }
48
+ /**
49
+ * Clean up old rate limit entries periodically
50
+ */
51
+ function cleanupRateLimits() {
52
+ const now = Date.now();
53
+ for (const [ip, entry] of activationRateLimits.entries()) {
54
+ if (now - entry.windowStart >= RATE_LIMIT_WINDOW_MS * 2) {
55
+ activationRateLimits.delete(ip);
56
+ }
57
+ }
58
+ }
59
+ // Cleanup stale rate limit entries every 5 minutes
60
+ setInterval(cleanupRateLimits, 5 * 60 * 1000);
61
+ // ============================================================================
62
+ // PHASE 2 & 4: Terminal Management (Spec 0090)
63
+ // ============================================================================
64
+ // Global TerminalManager instance for tower-managed terminals
65
+ // Uses a temporary directory as projectRoot since terminals can be for any project
66
+ let terminalManager = null;
67
+ const projectTerminals = new Map();
68
+ /**
69
+ * Get or create project terminal registry entry
70
+ */
71
+ function getProjectTerminalsEntry(projectPath) {
72
+ let entry = projectTerminals.get(projectPath);
73
+ if (!entry) {
74
+ entry = { builders: new Map(), shells: new Map() };
75
+ projectTerminals.set(projectPath, entry);
76
+ }
77
+ return entry;
78
+ }
79
+ /**
80
+ * Generate next shell ID for a project
81
+ */
82
+ function getNextShellId(projectPath) {
83
+ const entry = getProjectTerminalsEntry(projectPath);
84
+ let maxId = 0;
85
+ for (const id of entry.shells.keys()) {
86
+ const num = parseInt(id.replace('shell-', ''), 10);
87
+ if (!isNaN(num) && num > maxId)
88
+ maxId = num;
89
+ }
90
+ return `shell-${maxId + 1}`;
91
+ }
92
+ /**
93
+ * Get or create the global TerminalManager instance
94
+ */
95
+ function getTerminalManager() {
96
+ if (!terminalManager) {
97
+ // Use a neutral projectRoot - terminals specify their own cwd
98
+ const projectRoot = process.env.HOME || '/tmp';
99
+ terminalManager = new TerminalManager({
100
+ projectRoot,
101
+ logDir: path.join(homedir(), '.agent-farm', 'logs'),
102
+ maxSessions: 100,
103
+ ringBufferLines: 1000,
104
+ diskLogEnabled: true,
105
+ diskLogMaxBytes: 50 * 1024 * 1024,
106
+ reconnectTimeoutMs: 300_000,
107
+ });
108
+ }
109
+ return terminalManager;
110
+ }
111
+ /**
112
+ * Handle WebSocket connection to a terminal session
113
+ * Uses hybrid binary protocol (Spec 0085):
114
+ * - 0x00 prefix: Control frame (JSON)
115
+ * - 0x01 prefix: Data frame (raw PTY bytes)
116
+ */
117
+ function handleTerminalWebSocket(ws, session, req) {
118
+ const resumeSeq = req.headers['x-session-resume'];
119
+ // Create a client adapter for the PTY session
120
+ // Uses binary protocol for data frames
121
+ const client = {
122
+ send: (data) => {
123
+ if (ws.readyState === WebSocket.OPEN) {
124
+ // Encode as binary data frame (0x01 prefix)
125
+ ws.send(encodeData(data));
126
+ }
127
+ },
128
+ };
129
+ // Attach client to session and get replay data
130
+ let replayLines;
131
+ if (resumeSeq && typeof resumeSeq === 'string') {
132
+ replayLines = session.attachResume(client, parseInt(resumeSeq, 10));
133
+ }
134
+ else {
135
+ replayLines = session.attach(client);
136
+ }
137
+ // Send replay data as binary data frame
138
+ if (replayLines.length > 0) {
139
+ const replayData = replayLines.join('\n');
140
+ if (ws.readyState === WebSocket.OPEN) {
141
+ ws.send(encodeData(replayData));
142
+ }
143
+ }
144
+ // Handle incoming messages from client (binary protocol)
145
+ ws.on('message', (rawData) => {
146
+ try {
147
+ const frame = decodeFrame(Buffer.from(rawData));
148
+ if (frame.type === 'data') {
149
+ // Write raw input to terminal
150
+ session.write(frame.data.toString('utf-8'));
151
+ }
152
+ else if (frame.type === 'control') {
153
+ // Handle control messages
154
+ const msg = frame.message;
155
+ if (msg.type === 'resize') {
156
+ const cols = msg.payload.cols;
157
+ const rows = msg.payload.rows;
158
+ if (typeof cols === 'number' && typeof rows === 'number') {
159
+ session.resize(cols, rows);
160
+ }
161
+ }
162
+ else if (msg.type === 'ping') {
163
+ if (ws.readyState === WebSocket.OPEN) {
164
+ ws.send(encodeControl({ type: 'pong', payload: {} }));
165
+ }
166
+ }
167
+ }
168
+ }
169
+ catch {
170
+ // If decode fails, try treating as raw UTF-8 input (for simpler clients)
171
+ try {
172
+ session.write(rawData.toString('utf-8'));
173
+ }
174
+ catch {
175
+ // Ignore malformed input
176
+ }
177
+ }
178
+ });
179
+ ws.on('close', () => {
180
+ session.detach(client);
181
+ });
182
+ ws.on('error', () => {
183
+ session.detach(client);
184
+ });
185
+ }
21
186
  // Parse arguments with Commander
22
187
  const program = new Command()
23
188
  .name('tower-server')
@@ -52,6 +217,41 @@ function log(level, message) {
52
217
  }
53
218
  }
54
219
  }
220
+ // Global exception handlers to catch uncaught errors
221
+ process.on('uncaughtException', (err) => {
222
+ log('ERROR', `Uncaught exception: ${err.message}\n${err.stack}`);
223
+ process.exit(1);
224
+ });
225
+ process.on('unhandledRejection', (reason) => {
226
+ const message = reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason);
227
+ log('ERROR', `Unhandled rejection: ${message}`);
228
+ process.exit(1);
229
+ });
230
+ // Graceful shutdown handler (Phase 2 - Spec 0090)
231
+ async function gracefulShutdown(signal) {
232
+ log('INFO', `Received ${signal}, starting graceful shutdown...`);
233
+ // 1. Stop accepting new connections
234
+ server?.close();
235
+ // 2. Close all WebSocket connections
236
+ if (terminalWss) {
237
+ for (const client of terminalWss.clients) {
238
+ client.close(1001, 'Server shutting down');
239
+ }
240
+ terminalWss.close();
241
+ }
242
+ // 3. Kill all PTY sessions
243
+ if (terminalManager) {
244
+ log('INFO', 'Shutting down terminal manager...');
245
+ terminalManager.shutdown();
246
+ }
247
+ // 4. Stop cloudflared tunnel if running
248
+ stopTunnel();
249
+ log('INFO', 'Graceful shutdown complete');
250
+ process.exit(0);
251
+ }
252
+ // Catch signals for clean shutdown
253
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
254
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
55
255
  if (isNaN(port) || port < 1 || port > 65535) {
56
256
  log('ERROR', `Invalid port "${portArg}". Must be a number between 1 and 65535.`);
57
257
  process.exit(1);
@@ -97,6 +297,200 @@ async function isPortListening(port) {
97
297
  function getProjectName(projectPath) {
98
298
  return path.basename(projectPath);
99
299
  }
300
+ /**
301
+ * Get the base port for a project from global.db
302
+ * Returns null if project not found or not running
303
+ */
304
+ async function getBasePortForProject(projectPath) {
305
+ try {
306
+ const db = getGlobalDb();
307
+ const row = db.prepare('SELECT base_port FROM port_allocations WHERE project_path = ?').get(projectPath);
308
+ if (!row)
309
+ return null;
310
+ // Check if actually running
311
+ const isRunning = await isPortListening(row.base_port);
312
+ return isRunning ? row.base_port : null;
313
+ }
314
+ catch {
315
+ return null;
316
+ }
317
+ }
318
+ // Cloudflared tunnel management
319
+ let tunnelProcess = null;
320
+ let tunnelUrl = null;
321
+ function isCloudflaredInstalled() {
322
+ try {
323
+ execSync('which cloudflared', { stdio: 'ignore' });
324
+ return true;
325
+ }
326
+ catch {
327
+ return false;
328
+ }
329
+ }
330
+ function getTunnelStatus() {
331
+ return {
332
+ available: isCloudflaredInstalled(),
333
+ running: tunnelProcess !== null && tunnelUrl !== null,
334
+ url: tunnelUrl,
335
+ };
336
+ }
337
+ async function startTunnel(port) {
338
+ if (!isCloudflaredInstalled()) {
339
+ return { success: false, error: 'cloudflared not installed. Install with: brew install cloudflared' };
340
+ }
341
+ if (tunnelProcess) {
342
+ return { success: true, url: tunnelUrl || undefined };
343
+ }
344
+ return new Promise((resolve) => {
345
+ tunnelProcess = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
346
+ stdio: ['ignore', 'pipe', 'pipe'],
347
+ });
348
+ const handleOutput = (data) => {
349
+ const text = data.toString();
350
+ const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
351
+ if (match && !tunnelUrl) {
352
+ tunnelUrl = match[0];
353
+ log('INFO', `Cloudflared tunnel started: ${tunnelUrl}`);
354
+ resolve({ success: true, url: tunnelUrl });
355
+ }
356
+ };
357
+ tunnelProcess.stdout?.on('data', handleOutput);
358
+ tunnelProcess.stderr?.on('data', handleOutput);
359
+ tunnelProcess.on('close', (code) => {
360
+ log('INFO', `Cloudflared tunnel closed with code ${code}`);
361
+ tunnelProcess = null;
362
+ tunnelUrl = null;
363
+ });
364
+ // Timeout after 30 seconds
365
+ setTimeout(() => {
366
+ if (!tunnelUrl) {
367
+ tunnelProcess?.kill();
368
+ tunnelProcess = null;
369
+ resolve({ success: false, error: 'Tunnel startup timed out' });
370
+ }
371
+ }, 30000);
372
+ });
373
+ }
374
+ function stopTunnel() {
375
+ if (tunnelProcess) {
376
+ tunnelProcess.kill();
377
+ tunnelProcess = null;
378
+ tunnelUrl = null;
379
+ log('INFO', 'Cloudflared tunnel stopped');
380
+ }
381
+ return { success: true };
382
+ }
383
+ const sseClients = [];
384
+ let notificationIdCounter = 0;
385
+ /**
386
+ * Broadcast a notification to all connected SSE clients
387
+ */
388
+ function broadcastNotification(notification) {
389
+ const id = ++notificationIdCounter;
390
+ const data = JSON.stringify({ ...notification, id });
391
+ const message = `id: ${id}\ndata: ${data}\n\n`;
392
+ for (const client of sseClients) {
393
+ try {
394
+ client.res.write(message);
395
+ }
396
+ catch {
397
+ // Client disconnected, will be cleaned up on next iteration
398
+ }
399
+ }
400
+ }
401
+ /**
402
+ * Get gate status for a project by querying its dashboard API.
403
+ * Uses timeout to prevent hung projects from stalling tower status.
404
+ */
405
+ async function getGateStatusForProject(basePort) {
406
+ const controller = new AbortController();
407
+ const timeout = setTimeout(() => controller.abort(), 2000); // 2-second timeout
408
+ try {
409
+ const response = await fetch(`http://localhost:${basePort}/api/status`, {
410
+ signal: controller.signal,
411
+ });
412
+ clearTimeout(timeout);
413
+ if (!response.ok)
414
+ return { hasGate: false };
415
+ const projectStatus = await response.json();
416
+ // Check if any builder has a pending gate
417
+ const builderWithGate = projectStatus.builders?.find((b) => b.gateStatus?.waiting || b.status === 'gate-pending');
418
+ if (builderWithGate) {
419
+ return {
420
+ hasGate: true,
421
+ gateName: builderWithGate.gateStatus?.gateName || builderWithGate.currentGate,
422
+ builderId: builderWithGate.id,
423
+ timestamp: builderWithGate.gateStatus?.timestamp || Date.now(),
424
+ };
425
+ }
426
+ }
427
+ catch {
428
+ // Project dashboard not responding or timeout
429
+ }
430
+ return { hasGate: false };
431
+ }
432
+ /**
433
+ * Get terminal list for a project from tower's registry.
434
+ * Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server fetch.
435
+ * Returns architect, builders, and shells with their URLs.
436
+ */
437
+ function getTerminalsForProject(projectPath, proxyUrl) {
438
+ const entry = projectTerminals.get(projectPath);
439
+ const manager = getTerminalManager();
440
+ const terminals = [];
441
+ if (!entry) {
442
+ return { terminals: [], gateStatus: { hasGate: false } };
443
+ }
444
+ // Add architect terminal
445
+ if (entry.architect) {
446
+ const session = manager.getSession(entry.architect);
447
+ if (session) {
448
+ terminals.push({
449
+ type: 'architect',
450
+ id: 'architect',
451
+ label: 'Architect',
452
+ url: `${proxyUrl}?tab=architect`,
453
+ active: true,
454
+ });
455
+ }
456
+ }
457
+ // Add builder terminals
458
+ for (const [builderId] of entry.builders) {
459
+ const terminalId = entry.builders.get(builderId);
460
+ if (terminalId) {
461
+ const session = manager.getSession(terminalId);
462
+ if (session) {
463
+ terminals.push({
464
+ type: 'builder',
465
+ id: builderId,
466
+ label: `Builder ${builderId}`,
467
+ url: `${proxyUrl}?tab=builder-${builderId}`,
468
+ active: true,
469
+ });
470
+ }
471
+ }
472
+ }
473
+ // Add shell terminals
474
+ for (const [shellId] of entry.shells) {
475
+ const terminalId = entry.shells.get(shellId);
476
+ if (terminalId) {
477
+ const session = manager.getSession(terminalId);
478
+ if (session) {
479
+ terminals.push({
480
+ type: 'shell',
481
+ id: shellId,
482
+ label: `Shell ${shellId.replace('shell-', '')}`,
483
+ url: `${proxyUrl}?tab=shell-${shellId}`,
484
+ active: true,
485
+ });
486
+ }
487
+ }
488
+ }
489
+ // Gate status - builders don't have gate tracking yet in tower
490
+ // TODO: Add gate status tracking when porch integration is updated
491
+ const gateStatus = { hasGate: false };
492
+ return { terminals, gateStatus };
493
+ }
100
494
  /**
101
495
  * Get all instances with their status
102
496
  */
@@ -110,35 +504,37 @@ async function getInstances() {
110
504
  }
111
505
  const basePort = allocation.base_port;
112
506
  const dashboardPort = basePort;
113
- const architectPort = basePort + 1;
114
507
  // Check if dashboard is running (main indicator of running instance)
508
+ // All terminals are multiplexed on dashboardPort via WebSocket (Spec 0085)
115
509
  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;
510
+ // Encode project path for proxy URL
511
+ const encodedPath = Buffer.from(allocation.project_path).toString('base64url');
512
+ const proxyUrl = `/project/${encodedPath}/`;
513
+ // Get terminals and gate status from tower's registry
514
+ // Phase 4 (Spec 0090): Tower manages terminals directly
515
+ const { terminals, gateStatus } = getTerminalsForProject(allocation.project_path, proxyUrl);
118
516
  const ports = [
119
517
  {
120
518
  type: 'Dashboard',
121
519
  port: dashboardPort,
122
- url: `http://localhost:${dashboardPort}`,
520
+ url: proxyUrl, // Use tower proxy URL, not raw localhost
123
521
  active: dashboardActive,
124
522
  },
125
- {
126
- type: 'Architect',
127
- port: architectPort,
128
- url: `http://localhost:${architectPort}`,
129
- active: architectActive,
130
- },
131
523
  ];
132
524
  instances.push({
133
525
  projectPath: allocation.project_path,
134
526
  projectName: getProjectName(allocation.project_path),
135
527
  basePort,
136
528
  dashboardPort,
137
- architectPort,
529
+ architectPort: basePort + 1, // Legacy field for backward compat
138
530
  registered: allocation.registered_at,
139
531
  lastUsed: allocation.last_used_at,
140
532
  running: dashboardActive,
533
+ proxyUrl, // Tower proxy URL for dashboard
534
+ architectUrl: `${proxyUrl}?tab=architect`, // Direct URL to architect terminal
535
+ terminals, // All available terminals
141
536
  ports,
537
+ gateStatus,
142
538
  });
143
539
  }
144
540
  // Sort: running first, then by last used (most recent first)
@@ -213,8 +609,8 @@ async function getDirectorySuggestions(inputPath) {
213
609
  }
214
610
  /**
215
611
  * Launch a new agent-farm instance
216
- * First stops any stale state, then starts fresh
217
- * Auto-adopts non-codev directories
612
+ * Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server
613
+ * Auto-adopts non-codev directories and creates architect terminal
218
614
  */
219
615
  async function launchInstance(projectPath) {
220
616
  // Clean up stale port allocations before launching (handles machine restarts)
@@ -246,74 +642,76 @@ async function launchInstance(projectPath) {
246
642
  return { success: false, error: `Failed to adopt codev: ${err.message}` };
247
643
  }
248
644
  }
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
645
+ // Phase 4 (Spec 0090): Tower manages terminals directly
646
+ // No dashboard-server spawning - tower handles everything
253
647
  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)
648
+ // Clear any stale state file
649
+ const stateFile = path.join(projectPath, '.agent-farm', 'state.json');
650
+ if (fs.existsSync(stateFile)) {
651
+ try {
652
+ fs.unlinkSync(stateFile);
653
+ }
654
+ catch {
655
+ // Ignore - file might not exist or be locked
656
+ }
657
+ }
658
+ // Ensure project has port allocation
287
659
  const resolvedPath = fs.realpathSync(projectPath);
288
660
  const db = getGlobalDb();
289
- const allocation = db
661
+ let allocation = db
290
662
  .prepare('SELECT base_port FROM port_allocations WHERE project_path = ? OR project_path = ?')
291
663
  .get(projectPath, resolvedPath);
292
- if (allocation) {
293
- const dashboardPort = allocation.base_port;
294
- const isRunning = await isPortListening(dashboardPort);
295
- if (!isRunning) {
296
- // Process failed to start - try to get error info
297
- const errorInfo = stderr || stdout || 'Unknown error - check codev installation';
298
- child.unref();
299
- return {
300
- success: false,
301
- error: `Failed to start: ${errorInfo.trim().split('\n')[0]}`,
302
- };
303
- }
304
- }
305
- else {
306
- // No allocation found - process might have failed before registering
307
- if (stderr || stdout) {
308
- const errorInfo = stderr || stdout;
309
- child.unref();
310
- return {
311
- success: false,
312
- error: `Failed to start: ${errorInfo.trim().split('\n')[0]}`,
313
- };
314
- }
315
- }
316
- child.unref();
664
+ if (!allocation) {
665
+ // Allocate a new port for this project
666
+ // Find the next available port block (starting at 4200, incrementing by 100)
667
+ const existingPorts = db
668
+ .prepare('SELECT base_port FROM port_allocations ORDER BY base_port')
669
+ .all();
670
+ let nextPort = 4200;
671
+ for (const { base_port } of existingPorts) {
672
+ if (base_port >= nextPort) {
673
+ nextPort = base_port + 100;
674
+ }
675
+ }
676
+ db.prepare("INSERT INTO port_allocations (project_path, project_name, base_port, created_at) VALUES (?, ?, ?, datetime('now'))").run(resolvedPath, path.basename(projectPath), nextPort);
677
+ allocation = { base_port: nextPort };
678
+ log('INFO', `Allocated port ${nextPort} for project: ${projectPath}`);
679
+ }
680
+ // Initialize project terminal entry
681
+ const entry = getProjectTerminalsEntry(resolvedPath);
682
+ // Create architect terminal if not already present
683
+ if (!entry.architect) {
684
+ const manager = getTerminalManager();
685
+ // Read af-config.json to get the architect command
686
+ let architectCmd = 'claude';
687
+ const configPath = path.join(projectPath, 'af-config.json');
688
+ if (fs.existsSync(configPath)) {
689
+ try {
690
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
691
+ if (config.shell?.architect) {
692
+ architectCmd = config.shell.architect;
693
+ }
694
+ }
695
+ catch {
696
+ // Ignore config read errors, use default
697
+ }
698
+ }
699
+ try {
700
+ const session = await manager.createSession({
701
+ command: architectCmd,
702
+ args: [],
703
+ cwd: projectPath,
704
+ label: 'Architect',
705
+ env: process.env,
706
+ });
707
+ entry.architect = session.id;
708
+ log('INFO', `Created architect terminal for project: ${projectPath}`);
709
+ }
710
+ catch (err) {
711
+ log('WARN', `Failed to create architect terminal: ${err.message}`);
712
+ // Don't fail the launch - project is still active, just without architect
713
+ }
714
+ }
317
715
  return { success: true, adopted };
318
716
  }
319
717
  catch (err) {
@@ -334,27 +732,55 @@ function getProcessOnPort(targetPort) {
334
732
  }
335
733
  }
336
734
  /**
337
- * Stop an agent-farm instance by killing processes on its ports
735
+ * Stop an agent-farm instance by killing all its terminals
736
+ * Phase 4 (Spec 0090): Tower manages terminals directly
338
737
  */
339
- async function stopInstance(basePort) {
738
+ async function stopInstance(projectPath) {
340
739
  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);
740
+ const manager = getTerminalManager();
741
+ // Resolve symlinks for consistent lookup
742
+ let resolvedPath = projectPath;
743
+ try {
744
+ if (fs.existsSync(projectPath)) {
745
+ resolvedPath = fs.realpathSync(projectPath);
746
+ }
747
+ }
748
+ catch {
749
+ // Ignore - use original path
750
+ }
751
+ // Get project terminals
752
+ const entry = projectTerminals.get(resolvedPath) || projectTerminals.get(projectPath);
753
+ if (entry) {
754
+ // Kill architect
755
+ if (entry.architect) {
756
+ const session = manager.getSession(entry.architect);
757
+ if (session) {
758
+ manager.killSession(entry.architect);
759
+ stopped.push(session.pid);
350
760
  }
351
- catch {
352
- // Process may have already exited
761
+ }
762
+ // Kill all shells
763
+ for (const terminalId of entry.shells.values()) {
764
+ const session = manager.getSession(terminalId);
765
+ if (session) {
766
+ manager.killSession(terminalId);
767
+ stopped.push(session.pid);
353
768
  }
354
769
  }
770
+ // Kill all builders
771
+ for (const terminalId of entry.builders.values()) {
772
+ const session = manager.getSession(terminalId);
773
+ if (session) {
774
+ manager.killSession(terminalId);
775
+ stopped.push(session.pid);
776
+ }
777
+ }
778
+ // Clear project from registry
779
+ projectTerminals.delete(resolvedPath);
780
+ projectTerminals.delete(projectPath);
355
781
  }
356
782
  if (stopped.length === 0) {
357
- return { success: true, error: 'No processes found to stop', stopped };
783
+ return { success: true, error: 'No terminals found to stop', stopped };
358
784
  }
359
785
  return { success: true, stopped };
360
786
  }
@@ -375,6 +801,54 @@ function findTemplatePath() {
375
801
  // escapeHtml, parseJsonBody, isRequestAllowed imported from ../utils/server-utils.js
376
802
  // Find template path
377
803
  const templatePath = findTemplatePath();
804
+ // WebSocket server for terminal connections (Phase 2 - Spec 0090)
805
+ let terminalWss = null;
806
+ // React dashboard dist path (for serving directly from tower)
807
+ // React dashboard dist path (for serving directly from tower)
808
+ // Phase 4 (Spec 0090): Tower serves everything directly, no dashboard-server
809
+ const reactDashboardPath = path.resolve(__dirname, '../../../dashboard/dist');
810
+ const hasReactDashboard = fs.existsSync(reactDashboardPath);
811
+ if (hasReactDashboard) {
812
+ log('INFO', `React dashboard found at: ${reactDashboardPath}`);
813
+ }
814
+ else {
815
+ log('WARN', 'React dashboard not found - project dashboards will not work');
816
+ }
817
+ // MIME types for static file serving
818
+ const MIME_TYPES = {
819
+ '.html': 'text/html',
820
+ '.js': 'application/javascript',
821
+ '.css': 'text/css',
822
+ '.json': 'application/json',
823
+ '.png': 'image/png',
824
+ '.jpg': 'image/jpeg',
825
+ '.gif': 'image/gif',
826
+ '.svg': 'image/svg+xml',
827
+ '.ico': 'image/x-icon',
828
+ '.woff': 'font/woff',
829
+ '.woff2': 'font/woff2',
830
+ '.ttf': 'font/ttf',
831
+ '.map': 'application/json',
832
+ };
833
+ /**
834
+ * Serve a static file from the React dashboard dist
835
+ */
836
+ function serveStaticFile(filePath, res) {
837
+ if (!fs.existsSync(filePath)) {
838
+ return false;
839
+ }
840
+ const ext = path.extname(filePath);
841
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
842
+ try {
843
+ const content = fs.readFileSync(filePath);
844
+ res.writeHead(200, { 'Content-Type': contentType });
845
+ res.end(content);
846
+ return true;
847
+ }
848
+ catch {
849
+ return false;
850
+ }
851
+ }
378
852
  // Create server
379
853
  const server = http.createServer(async (req, res) => {
380
854
  // Security: Validate Host and Origin headers
@@ -398,13 +872,275 @@ const server = http.createServer(async (req, res) => {
398
872
  }
399
873
  const url = new URL(req.url || '/', `http://localhost:${port}`);
400
874
  try {
401
- // API: Get status of all instances
875
+ // =========================================================================
876
+ // NEW API ENDPOINTS (Spec 0090 - Tower as Single Daemon)
877
+ // =========================================================================
878
+ // Health check endpoint (Spec 0090 Phase 1)
879
+ if (req.method === 'GET' && url.pathname === '/health') {
880
+ const instances = await getInstances();
881
+ const activeCount = instances.filter((i) => i.running).length;
882
+ res.writeHead(200, { 'Content-Type': 'application/json' });
883
+ res.end(JSON.stringify({
884
+ status: 'healthy',
885
+ uptime: process.uptime(),
886
+ activeProjects: activeCount,
887
+ totalProjects: instances.length,
888
+ memoryUsage: process.memoryUsage().heapUsed,
889
+ timestamp: new Date().toISOString(),
890
+ }));
891
+ return;
892
+ }
893
+ // API: List all projects (Spec 0090 Phase 1)
894
+ if (req.method === 'GET' && url.pathname === '/api/projects') {
895
+ const instances = await getInstances();
896
+ const projects = instances.map((i) => ({
897
+ path: i.projectPath,
898
+ name: i.projectName,
899
+ basePort: i.basePort,
900
+ active: i.running,
901
+ proxyUrl: i.proxyUrl,
902
+ terminals: i.terminals.length,
903
+ lastUsed: i.lastUsed,
904
+ }));
905
+ res.writeHead(200, { 'Content-Type': 'application/json' });
906
+ res.end(JSON.stringify({ projects }));
907
+ return;
908
+ }
909
+ // API: Project-specific endpoints (Spec 0090 Phase 1)
910
+ // Routes: /api/projects/:encodedPath/activate, /deactivate, /status
911
+ const projectApiMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/(activate|deactivate|status)$/);
912
+ if (projectApiMatch) {
913
+ const [, encodedPath, action] = projectApiMatch;
914
+ let projectPath;
915
+ try {
916
+ projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
917
+ if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
918
+ throw new Error('Invalid path');
919
+ }
920
+ }
921
+ catch {
922
+ res.writeHead(400, { 'Content-Type': 'application/json' });
923
+ res.end(JSON.stringify({ error: 'Invalid project path encoding' }));
924
+ return;
925
+ }
926
+ // GET /api/projects/:path/status
927
+ if (req.method === 'GET' && action === 'status') {
928
+ const instances = await getInstances();
929
+ const instance = instances.find((i) => i.projectPath === projectPath);
930
+ if (!instance) {
931
+ res.writeHead(404, { 'Content-Type': 'application/json' });
932
+ res.end(JSON.stringify({ error: 'Project not found' }));
933
+ return;
934
+ }
935
+ res.writeHead(200, { 'Content-Type': 'application/json' });
936
+ res.end(JSON.stringify({
937
+ path: instance.projectPath,
938
+ name: instance.projectName,
939
+ active: instance.running,
940
+ basePort: instance.basePort,
941
+ terminals: instance.terminals,
942
+ gateStatus: instance.gateStatus,
943
+ }));
944
+ return;
945
+ }
946
+ // POST /api/projects/:path/activate
947
+ if (req.method === 'POST' && action === 'activate') {
948
+ // Rate limiting: 10 activations per minute per client
949
+ const clientIp = req.socket.remoteAddress || '127.0.0.1';
950
+ if (isRateLimited(clientIp)) {
951
+ res.writeHead(429, { 'Content-Type': 'application/json' });
952
+ res.end(JSON.stringify({ error: 'Too many activations, try again later' }));
953
+ return;
954
+ }
955
+ const result = await launchInstance(projectPath);
956
+ if (result.success) {
957
+ res.writeHead(200, { 'Content-Type': 'application/json' });
958
+ res.end(JSON.stringify({ success: true, adopted: result.adopted }));
959
+ }
960
+ else {
961
+ res.writeHead(400, { 'Content-Type': 'application/json' });
962
+ res.end(JSON.stringify({ success: false, error: result.error }));
963
+ }
964
+ return;
965
+ }
966
+ // POST /api/projects/:path/deactivate
967
+ if (req.method === 'POST' && action === 'deactivate') {
968
+ // Check if project exists in port allocations
969
+ const allocations = loadPortAllocations();
970
+ const resolvedPath = fs.existsSync(projectPath) ? fs.realpathSync(projectPath) : projectPath;
971
+ const allocation = allocations.find((a) => a.project_path === projectPath || a.project_path === resolvedPath);
972
+ if (!allocation) {
973
+ res.writeHead(404, { 'Content-Type': 'application/json' });
974
+ res.end(JSON.stringify({ ok: false, error: 'Project not found' }));
975
+ return;
976
+ }
977
+ // Phase 4: Stop terminals directly via tower
978
+ const result = await stopInstance(projectPath);
979
+ res.writeHead(200, { 'Content-Type': 'application/json' });
980
+ res.end(JSON.stringify(result));
981
+ return;
982
+ }
983
+ }
984
+ // =========================================================================
985
+ // TERMINAL API (Phase 2 - Spec 0090)
986
+ // =========================================================================
987
+ // POST /api/terminals - Create a new terminal
988
+ if (req.method === 'POST' && url.pathname === '/api/terminals') {
989
+ try {
990
+ const body = await parseJsonBody(req);
991
+ const manager = getTerminalManager();
992
+ const info = await manager.createSession({
993
+ command: typeof body.command === 'string' ? body.command : undefined,
994
+ args: Array.isArray(body.args) ? body.args : undefined,
995
+ cols: typeof body.cols === 'number' ? body.cols : undefined,
996
+ rows: typeof body.rows === 'number' ? body.rows : undefined,
997
+ cwd: typeof body.cwd === 'string' ? body.cwd : undefined,
998
+ env: typeof body.env === 'object' && body.env !== null ? body.env : undefined,
999
+ label: typeof body.label === 'string' ? body.label : undefined,
1000
+ });
1001
+ res.writeHead(201, { 'Content-Type': 'application/json' });
1002
+ res.end(JSON.stringify({ ...info, wsPath: `/ws/terminal/${info.id}` }));
1003
+ }
1004
+ catch (err) {
1005
+ const message = err instanceof Error ? err.message : 'Unknown error';
1006
+ log('ERROR', `Failed to create terminal: ${message}`);
1007
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1008
+ res.end(JSON.stringify({ error: 'INTERNAL_ERROR', message }));
1009
+ }
1010
+ return;
1011
+ }
1012
+ // GET /api/terminals - List all terminals
1013
+ if (req.method === 'GET' && url.pathname === '/api/terminals') {
1014
+ const manager = getTerminalManager();
1015
+ const terminals = manager.listSessions();
1016
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1017
+ res.end(JSON.stringify({ terminals }));
1018
+ return;
1019
+ }
1020
+ // Terminal-specific routes: /api/terminals/:id/*
1021
+ const terminalRouteMatch = url.pathname.match(/^\/api\/terminals\/([^/]+)(\/.*)?$/);
1022
+ if (terminalRouteMatch) {
1023
+ const [, terminalId, subpath] = terminalRouteMatch;
1024
+ const manager = getTerminalManager();
1025
+ // GET /api/terminals/:id - Get terminal info
1026
+ if (req.method === 'GET' && (!subpath || subpath === '')) {
1027
+ const session = manager.getSession(terminalId);
1028
+ if (!session) {
1029
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1030
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1031
+ return;
1032
+ }
1033
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1034
+ res.end(JSON.stringify(session.info));
1035
+ return;
1036
+ }
1037
+ // DELETE /api/terminals/:id - Kill terminal
1038
+ if (req.method === 'DELETE' && (!subpath || subpath === '')) {
1039
+ if (!manager.killSession(terminalId)) {
1040
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1041
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1042
+ return;
1043
+ }
1044
+ res.writeHead(204);
1045
+ res.end();
1046
+ return;
1047
+ }
1048
+ // POST /api/terminals/:id/resize - Resize terminal
1049
+ if (req.method === 'POST' && subpath === '/resize') {
1050
+ try {
1051
+ const body = await parseJsonBody(req);
1052
+ if (typeof body.cols !== 'number' || typeof body.rows !== 'number') {
1053
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1054
+ res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'cols and rows must be numbers' }));
1055
+ return;
1056
+ }
1057
+ const info = manager.resizeSession(terminalId, body.cols, body.rows);
1058
+ if (!info) {
1059
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1060
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1061
+ return;
1062
+ }
1063
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1064
+ res.end(JSON.stringify(info));
1065
+ }
1066
+ catch {
1067
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1068
+ res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'Invalid JSON body' }));
1069
+ }
1070
+ return;
1071
+ }
1072
+ // GET /api/terminals/:id/output - Get terminal output
1073
+ if (req.method === 'GET' && subpath === '/output') {
1074
+ const lines = parseInt(url.searchParams.get('lines') ?? '100', 10);
1075
+ const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
1076
+ const output = manager.getOutput(terminalId, lines, offset);
1077
+ if (!output) {
1078
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1079
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1080
+ return;
1081
+ }
1082
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1083
+ res.end(JSON.stringify(output));
1084
+ return;
1085
+ }
1086
+ }
1087
+ // =========================================================================
1088
+ // EXISTING API ENDPOINTS
1089
+ // =========================================================================
1090
+ // API: Get status of all instances (legacy - kept for backward compat)
402
1091
  if (req.method === 'GET' && url.pathname === '/api/status') {
403
1092
  const instances = await getInstances();
404
1093
  res.writeHead(200, { 'Content-Type': 'application/json' });
405
1094
  res.end(JSON.stringify({ instances }));
406
1095
  return;
407
1096
  }
1097
+ // API: Server-Sent Events for push notifications
1098
+ if (req.method === 'GET' && url.pathname === '/api/events') {
1099
+ const clientId = crypto.randomBytes(8).toString('hex');
1100
+ res.writeHead(200, {
1101
+ 'Content-Type': 'text/event-stream',
1102
+ 'Cache-Control': 'no-cache',
1103
+ Connection: 'keep-alive',
1104
+ });
1105
+ // Send initial connection event
1106
+ res.write(`data: ${JSON.stringify({ type: 'connected', id: clientId })}\n\n`);
1107
+ const client = { res, id: clientId };
1108
+ sseClients.push(client);
1109
+ log('INFO', `SSE client connected: ${clientId} (total: ${sseClients.length})`);
1110
+ // Clean up on disconnect
1111
+ req.on('close', () => {
1112
+ const index = sseClients.findIndex((c) => c.id === clientId);
1113
+ if (index !== -1) {
1114
+ sseClients.splice(index, 1);
1115
+ }
1116
+ log('INFO', `SSE client disconnected: ${clientId} (total: ${sseClients.length})`);
1117
+ });
1118
+ return;
1119
+ }
1120
+ // API: Receive notification from builder
1121
+ if (req.method === 'POST' && url.pathname === '/api/notify') {
1122
+ const body = await parseJsonBody(req);
1123
+ const type = typeof body.type === 'string' ? body.type : 'info';
1124
+ const title = typeof body.title === 'string' ? body.title : '';
1125
+ const messageBody = typeof body.body === 'string' ? body.body : '';
1126
+ const project = typeof body.project === 'string' ? body.project : undefined;
1127
+ if (!title || !messageBody) {
1128
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1129
+ res.end(JSON.stringify({ success: false, error: 'Missing title or body' }));
1130
+ return;
1131
+ }
1132
+ // Broadcast to all connected SSE clients
1133
+ broadcastNotification({
1134
+ type,
1135
+ title,
1136
+ body: messageBody,
1137
+ project,
1138
+ });
1139
+ log('INFO', `Notification broadcast: ${title}`);
1140
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1141
+ res.end(JSON.stringify({ success: true }));
1142
+ return;
1143
+ }
408
1144
  // API: Browse directories for autocomplete
409
1145
  if (req.method === 'GET' && url.pathname === '/api/browse') {
410
1146
  const inputPath = url.searchParams.get('path') || '';
@@ -499,16 +1235,44 @@ const server = http.createServer(async (req, res) => {
499
1235
  res.end(JSON.stringify(result));
500
1236
  return;
501
1237
  }
1238
+ // API: Get tunnel status (cloudflared availability and running tunnel)
1239
+ if (req.method === 'GET' && url.pathname === '/api/tunnel/status') {
1240
+ const status = getTunnelStatus();
1241
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1242
+ res.end(JSON.stringify(status));
1243
+ return;
1244
+ }
1245
+ // API: Start cloudflared tunnel
1246
+ if (req.method === 'POST' && url.pathname === '/api/tunnel/start') {
1247
+ const result = await startTunnel(port);
1248
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1249
+ res.end(JSON.stringify(result));
1250
+ return;
1251
+ }
1252
+ // API: Stop cloudflared tunnel
1253
+ if (req.method === 'POST' && url.pathname === '/api/tunnel/stop') {
1254
+ const result = stopTunnel();
1255
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1256
+ res.end(JSON.stringify(result));
1257
+ return;
1258
+ }
502
1259
  // API: Stop an instance
1260
+ // Phase 4 (Spec 0090): Accept projectPath or basePort for backwards compat
503
1261
  if (req.method === 'POST' && url.pathname === '/api/stop') {
504
1262
  const body = await parseJsonBody(req);
505
- const basePort = body.basePort;
506
- if (!basePort) {
1263
+ let targetPath = body.projectPath;
1264
+ // Backwards compat: if basePort provided, find the project path
1265
+ if (!targetPath && body.basePort) {
1266
+ const allocations = loadPortAllocations();
1267
+ const allocation = allocations.find((a) => a.base_port === body.basePort);
1268
+ targetPath = allocation?.project_path || '';
1269
+ }
1270
+ if (!targetPath) {
507
1271
  res.writeHead(400, { 'Content-Type': 'application/json' });
508
- res.end(JSON.stringify({ success: false, error: 'Missing basePort' }));
1272
+ res.end(JSON.stringify({ success: false, error: 'Missing projectPath or basePort' }));
509
1273
  return;
510
1274
  }
511
- const result = await stopInstance(basePort);
1275
+ const result = await stopInstance(targetPath);
512
1276
  res.writeHead(200, { 'Content-Type': 'application/json' });
513
1277
  res.end(JSON.stringify(result));
514
1278
  return;
@@ -531,6 +1295,228 @@ const server = http.createServer(async (req, res) => {
531
1295
  }
532
1296
  return;
533
1297
  }
1298
+ // Project routes: /project/:base64urlPath/*
1299
+ // Phase 4 (Spec 0090): Tower serves React dashboard and handles APIs directly
1300
+ // Uses Base64URL (RFC 4648) encoding to avoid issues with slashes in paths
1301
+ if (url.pathname.startsWith('/project/')) {
1302
+ const pathParts = url.pathname.split('/');
1303
+ // ['', 'project', base64urlPath, ...rest]
1304
+ const encodedPath = pathParts[2];
1305
+ const subPath = pathParts.slice(3).join('/');
1306
+ if (!encodedPath) {
1307
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
1308
+ res.end('Missing project path');
1309
+ return;
1310
+ }
1311
+ // Decode Base64URL (RFC 4648)
1312
+ let projectPath;
1313
+ try {
1314
+ projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
1315
+ // Support both POSIX (/) and Windows (C:\) paths
1316
+ if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
1317
+ throw new Error('Invalid project path');
1318
+ }
1319
+ }
1320
+ catch {
1321
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
1322
+ res.end('Invalid project path encoding');
1323
+ return;
1324
+ }
1325
+ const basePort = await getBasePortForProject(projectPath);
1326
+ // Phase 4 (Spec 0090): Tower handles everything directly
1327
+ const isApiCall = subPath.startsWith('api/') || subPath === 'api';
1328
+ const isWsPath = subPath.startsWith('ws/') || subPath === 'ws';
1329
+ // Serve React dashboard static files directly if:
1330
+ // 1. Not an API call
1331
+ // 2. Not a WebSocket path
1332
+ // 3. React dashboard is available
1333
+ // 4. Project doesn't need to be running for static files
1334
+ if (!isApiCall && !isWsPath && hasReactDashboard) {
1335
+ // Determine which static file to serve
1336
+ let staticPath;
1337
+ if (!subPath || subPath === '' || subPath === 'index.html') {
1338
+ staticPath = path.join(reactDashboardPath, 'index.html');
1339
+ }
1340
+ else {
1341
+ // Check if it's a static asset
1342
+ staticPath = path.join(reactDashboardPath, subPath);
1343
+ }
1344
+ // Try to serve the static file
1345
+ if (serveStaticFile(staticPath, res)) {
1346
+ return;
1347
+ }
1348
+ // SPA fallback: serve index.html for client-side routing
1349
+ const indexPath = path.join(reactDashboardPath, 'index.html');
1350
+ if (serveStaticFile(indexPath, res)) {
1351
+ return;
1352
+ }
1353
+ }
1354
+ // Phase 4 (Spec 0090): Handle project APIs directly instead of proxying to dashboard-server
1355
+ if (isApiCall) {
1356
+ const apiPath = subPath.replace(/^api\/?/, '');
1357
+ // GET /api/state - Return project state (architect, builders, shells)
1358
+ if (req.method === 'GET' && (apiPath === 'state' || apiPath === '')) {
1359
+ const entry = getProjectTerminalsEntry(projectPath);
1360
+ const manager = getTerminalManager();
1361
+ // Build state response compatible with React dashboard
1362
+ const state = {
1363
+ architect: null,
1364
+ builders: [],
1365
+ utils: [],
1366
+ annotations: [],
1367
+ projectName: path.basename(projectPath),
1368
+ };
1369
+ // Add architect if exists
1370
+ if (entry.architect) {
1371
+ const session = manager.getSession(entry.architect);
1372
+ state.architect = {
1373
+ port: basePort || 0,
1374
+ pid: session?.pid || 0,
1375
+ terminalId: entry.architect,
1376
+ };
1377
+ }
1378
+ // Add shells
1379
+ for (const [shellId, terminalId] of entry.shells) {
1380
+ const session = manager.getSession(terminalId);
1381
+ state.utils.push({
1382
+ id: shellId,
1383
+ name: `Shell ${shellId.replace('shell-', '')}`,
1384
+ port: basePort || 0,
1385
+ pid: session?.pid || 0,
1386
+ terminalId,
1387
+ });
1388
+ }
1389
+ // Add builders
1390
+ for (const [builderId, terminalId] of entry.builders) {
1391
+ const session = manager.getSession(terminalId);
1392
+ state.builders.push({
1393
+ id: builderId,
1394
+ name: `Builder ${builderId}`,
1395
+ port: basePort || 0,
1396
+ pid: session?.pid || 0,
1397
+ status: 'running',
1398
+ phase: '',
1399
+ worktree: '',
1400
+ branch: '',
1401
+ type: 'spec',
1402
+ terminalId,
1403
+ });
1404
+ }
1405
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1406
+ res.end(JSON.stringify(state));
1407
+ return;
1408
+ }
1409
+ // POST /api/tabs/shell - Create a new shell terminal
1410
+ if (req.method === 'POST' && apiPath === 'tabs/shell') {
1411
+ try {
1412
+ const manager = getTerminalManager();
1413
+ const shellId = getNextShellId(projectPath);
1414
+ // Create terminal session
1415
+ const session = await manager.createSession({
1416
+ command: process.env.SHELL || '/bin/bash',
1417
+ args: [],
1418
+ cwd: projectPath,
1419
+ label: `Shell ${shellId.replace('shell-', '')}`,
1420
+ env: process.env,
1421
+ });
1422
+ // Register terminal with project
1423
+ const entry = getProjectTerminalsEntry(projectPath);
1424
+ entry.shells.set(shellId, session.id);
1425
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1426
+ res.end(JSON.stringify({
1427
+ id: shellId,
1428
+ port: basePort || 0,
1429
+ name: `Shell ${shellId.replace('shell-', '')}`,
1430
+ terminalId: session.id,
1431
+ }));
1432
+ }
1433
+ catch (err) {
1434
+ log('ERROR', `Failed to create shell: ${err.message}`);
1435
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1436
+ res.end(JSON.stringify({ error: err.message }));
1437
+ }
1438
+ return;
1439
+ }
1440
+ // DELETE /api/tabs/:id - Delete a terminal tab
1441
+ const deleteMatch = apiPath.match(/^tabs\/(.+)$/);
1442
+ if (req.method === 'DELETE' && deleteMatch) {
1443
+ const tabId = deleteMatch[1];
1444
+ const entry = getProjectTerminalsEntry(projectPath);
1445
+ const manager = getTerminalManager();
1446
+ // Find and delete the terminal
1447
+ let terminalId;
1448
+ if (tabId.startsWith('shell-')) {
1449
+ terminalId = entry.shells.get(tabId);
1450
+ if (terminalId) {
1451
+ entry.shells.delete(tabId);
1452
+ }
1453
+ }
1454
+ else if (tabId.startsWith('builder-')) {
1455
+ terminalId = entry.builders.get(tabId);
1456
+ if (terminalId) {
1457
+ entry.builders.delete(tabId);
1458
+ }
1459
+ }
1460
+ else if (tabId === 'architect') {
1461
+ terminalId = entry.architect;
1462
+ if (terminalId) {
1463
+ entry.architect = undefined;
1464
+ }
1465
+ }
1466
+ if (terminalId) {
1467
+ manager.killSession(terminalId);
1468
+ res.writeHead(204);
1469
+ res.end();
1470
+ }
1471
+ else {
1472
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1473
+ res.end(JSON.stringify({ error: 'Tab not found' }));
1474
+ }
1475
+ return;
1476
+ }
1477
+ // POST /api/stop - Stop all terminals for project
1478
+ if (req.method === 'POST' && apiPath === 'stop') {
1479
+ const entry = getProjectTerminalsEntry(projectPath);
1480
+ const manager = getTerminalManager();
1481
+ // Kill all terminals
1482
+ if (entry.architect) {
1483
+ manager.killSession(entry.architect);
1484
+ }
1485
+ for (const terminalId of entry.shells.values()) {
1486
+ manager.killSession(terminalId);
1487
+ }
1488
+ for (const terminalId of entry.builders.values()) {
1489
+ manager.killSession(terminalId);
1490
+ }
1491
+ // Clear registry
1492
+ projectTerminals.delete(projectPath);
1493
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1494
+ res.end(JSON.stringify({ ok: true }));
1495
+ return;
1496
+ }
1497
+ // Unhandled API route
1498
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1499
+ res.end(JSON.stringify({ error: 'API endpoint not found', path: apiPath }));
1500
+ return;
1501
+ }
1502
+ // For WebSocket paths, let the upgrade handler deal with it
1503
+ if (isWsPath) {
1504
+ // WebSocket paths are handled by the upgrade handler
1505
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
1506
+ res.end('WebSocket connections should use ws:// protocol');
1507
+ return;
1508
+ }
1509
+ // If we get here for non-API, non-WS paths and React dashboard is not available
1510
+ if (!hasReactDashboard) {
1511
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
1512
+ res.end('Dashboard not available');
1513
+ return;
1514
+ }
1515
+ // Fallback for unmatched paths
1516
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
1517
+ res.end('Not found');
1518
+ return;
1519
+ }
534
1520
  // 404 for everything else
535
1521
  res.writeHead(404, { 'Content-Type': 'text/plain' });
536
1522
  res.end('Not found');
@@ -545,6 +1531,77 @@ const server = http.createServer(async (req, res) => {
545
1531
  server.listen(port, '127.0.0.1', () => {
546
1532
  log('INFO', `Tower server listening at http://localhost:${port}`);
547
1533
  });
1534
+ // Initialize terminal WebSocket server (Phase 2 - Spec 0090)
1535
+ terminalWss = new WebSocketServer({ noServer: true });
1536
+ // WebSocket upgrade handler for terminal connections and proxying
1537
+ server.on('upgrade', async (req, socket, head) => {
1538
+ const reqUrl = new URL(req.url || '/', `http://localhost:${port}`);
1539
+ // Phase 2: Handle /ws/terminal/:id routes directly
1540
+ const terminalMatch = reqUrl.pathname.match(/^\/ws\/terminal\/([^/]+)$/);
1541
+ if (terminalMatch) {
1542
+ const terminalId = terminalMatch[1];
1543
+ const manager = getTerminalManager();
1544
+ const session = manager.getSession(terminalId);
1545
+ if (!session) {
1546
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
1547
+ socket.destroy();
1548
+ return;
1549
+ }
1550
+ terminalWss.handleUpgrade(req, socket, head, (ws) => {
1551
+ handleTerminalWebSocket(ws, session, req);
1552
+ });
1553
+ return;
1554
+ }
1555
+ // Phase 4 (Spec 0090): Handle project WebSocket routes directly
1556
+ // Route: /project/:encodedPath/ws/terminal/:terminalId
1557
+ if (!reqUrl.pathname.startsWith('/project/')) {
1558
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
1559
+ socket.destroy();
1560
+ return;
1561
+ }
1562
+ const pathParts = reqUrl.pathname.split('/');
1563
+ // ['', 'project', base64urlPath, 'ws', 'terminal', terminalId]
1564
+ const encodedPath = pathParts[2];
1565
+ if (!encodedPath) {
1566
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
1567
+ socket.destroy();
1568
+ return;
1569
+ }
1570
+ // Decode Base64URL (RFC 4648) - NOT URL encoding
1571
+ // Wrap in try/catch to handle malformed Base64 input gracefully
1572
+ let projectPath;
1573
+ try {
1574
+ projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
1575
+ // Support both POSIX (/) and Windows (C:\) paths
1576
+ if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
1577
+ throw new Error('Invalid project path');
1578
+ }
1579
+ }
1580
+ catch {
1581
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
1582
+ socket.destroy();
1583
+ return;
1584
+ }
1585
+ // Check for terminal WebSocket route: /project/:path/ws/terminal/:id
1586
+ const wsMatch = reqUrl.pathname.match(/^\/project\/[^/]+\/ws\/terminal\/([^/]+)$/);
1587
+ if (wsMatch) {
1588
+ const terminalId = wsMatch[1];
1589
+ const manager = getTerminalManager();
1590
+ const session = manager.getSession(terminalId);
1591
+ if (!session) {
1592
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
1593
+ socket.destroy();
1594
+ return;
1595
+ }
1596
+ terminalWss.handleUpgrade(req, socket, head, (ws) => {
1597
+ handleTerminalWebSocket(ws, session, req);
1598
+ });
1599
+ return;
1600
+ }
1601
+ // Unhandled WebSocket route
1602
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
1603
+ socket.destroy();
1604
+ });
548
1605
  // Handle uncaught errors
549
1606
  process.on('uncaughtException', (err) => {
550
1607
  log('ERROR', `Uncaught exception: ${err.message}\n${err.stack}`);