@cluesmith/codev 1.1.0

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 (245) hide show
  1. package/bin/af.js +8 -0
  2. package/bin/codev.js +4 -0
  3. package/bin/consult.js +7 -0
  4. package/dist/agent-farm/cli.d.ts +11 -0
  5. package/dist/agent-farm/cli.d.ts.map +1 -0
  6. package/dist/agent-farm/cli.js +359 -0
  7. package/dist/agent-farm/cli.js.map +1 -0
  8. package/dist/agent-farm/commands/cleanup.d.ts +12 -0
  9. package/dist/agent-farm/commands/cleanup.d.ts.map +1 -0
  10. package/dist/agent-farm/commands/cleanup.js +154 -0
  11. package/dist/agent-farm/commands/cleanup.js.map +1 -0
  12. package/dist/agent-farm/commands/db.d.ts +38 -0
  13. package/dist/agent-farm/commands/db.d.ts.map +1 -0
  14. package/dist/agent-farm/commands/db.js +133 -0
  15. package/dist/agent-farm/commands/db.js.map +1 -0
  16. package/dist/agent-farm/commands/index.d.ts +11 -0
  17. package/dist/agent-farm/commands/index.d.ts.map +1 -0
  18. package/dist/agent-farm/commands/index.js +11 -0
  19. package/dist/agent-farm/commands/index.js.map +1 -0
  20. package/dist/agent-farm/commands/open.d.ts +15 -0
  21. package/dist/agent-farm/commands/open.d.ts.map +1 -0
  22. package/dist/agent-farm/commands/open.js +118 -0
  23. package/dist/agent-farm/commands/open.js.map +1 -0
  24. package/dist/agent-farm/commands/rename.d.ts +13 -0
  25. package/dist/agent-farm/commands/rename.d.ts.map +1 -0
  26. package/dist/agent-farm/commands/rename.js +33 -0
  27. package/dist/agent-farm/commands/rename.js.map +1 -0
  28. package/dist/agent-farm/commands/send.d.ts +9 -0
  29. package/dist/agent-farm/commands/send.d.ts.map +1 -0
  30. package/dist/agent-farm/commands/send.js +282 -0
  31. package/dist/agent-farm/commands/send.js.map +1 -0
  32. package/dist/agent-farm/commands/spawn.d.ts +15 -0
  33. package/dist/agent-farm/commands/spawn.d.ts.map +1 -0
  34. package/dist/agent-farm/commands/spawn.js +575 -0
  35. package/dist/agent-farm/commands/spawn.js.map +1 -0
  36. package/dist/agent-farm/commands/start.d.ts +9 -0
  37. package/dist/agent-farm/commands/start.d.ts.map +1 -0
  38. package/dist/agent-farm/commands/start.js +175 -0
  39. package/dist/agent-farm/commands/start.js.map +1 -0
  40. package/dist/agent-farm/commands/status.d.ts +8 -0
  41. package/dist/agent-farm/commands/status.d.ts.map +1 -0
  42. package/dist/agent-farm/commands/status.js +123 -0
  43. package/dist/agent-farm/commands/status.js.map +1 -0
  44. package/dist/agent-farm/commands/stop.d.ts +8 -0
  45. package/dist/agent-farm/commands/stop.d.ts.map +1 -0
  46. package/dist/agent-farm/commands/stop.js +76 -0
  47. package/dist/agent-farm/commands/stop.js.map +1 -0
  48. package/dist/agent-farm/commands/tower.d.ts +19 -0
  49. package/dist/agent-farm/commands/tower.d.ts.map +1 -0
  50. package/dist/agent-farm/commands/tower.js +125 -0
  51. package/dist/agent-farm/commands/tower.js.map +1 -0
  52. package/dist/agent-farm/commands/tutorial.d.ts +10 -0
  53. package/dist/agent-farm/commands/tutorial.d.ts.map +1 -0
  54. package/dist/agent-farm/commands/tutorial.js +49 -0
  55. package/dist/agent-farm/commands/tutorial.js.map +1 -0
  56. package/dist/agent-farm/commands/util.d.ts +15 -0
  57. package/dist/agent-farm/commands/util.d.ts.map +1 -0
  58. package/dist/agent-farm/commands/util.js +108 -0
  59. package/dist/agent-farm/commands/util.js.map +1 -0
  60. package/dist/agent-farm/db/errors.d.ts +17 -0
  61. package/dist/agent-farm/db/errors.d.ts.map +1 -0
  62. package/dist/agent-farm/db/errors.js +46 -0
  63. package/dist/agent-farm/db/errors.js.map +1 -0
  64. package/dist/agent-farm/db/index.d.ts +41 -0
  65. package/dist/agent-farm/db/index.d.ts.map +1 -0
  66. package/dist/agent-farm/db/index.js +168 -0
  67. package/dist/agent-farm/db/index.js.map +1 -0
  68. package/dist/agent-farm/db/migrate.d.ts +15 -0
  69. package/dist/agent-farm/db/migrate.d.ts.map +1 -0
  70. package/dist/agent-farm/db/migrate.js +137 -0
  71. package/dist/agent-farm/db/migrate.js.map +1 -0
  72. package/dist/agent-farm/db/schema.d.ts +16 -0
  73. package/dist/agent-farm/db/schema.d.ts.map +1 -0
  74. package/dist/agent-farm/db/schema.js +103 -0
  75. package/dist/agent-farm/db/schema.js.map +1 -0
  76. package/dist/agent-farm/db/types.d.ts +87 -0
  77. package/dist/agent-farm/db/types.d.ts.map +1 -0
  78. package/dist/agent-farm/db/types.js +65 -0
  79. package/dist/agent-farm/db/types.js.map +1 -0
  80. package/dist/agent-farm/index.d.ts +7 -0
  81. package/dist/agent-farm/index.d.ts.map +1 -0
  82. package/dist/agent-farm/index.js +373 -0
  83. package/dist/agent-farm/index.js.map +1 -0
  84. package/dist/agent-farm/servers/annotate-server.d.ts +9 -0
  85. package/dist/agent-farm/servers/annotate-server.d.ts.map +1 -0
  86. package/dist/agent-farm/servers/annotate-server.js +136 -0
  87. package/dist/agent-farm/servers/annotate-server.js.map +1 -0
  88. package/dist/agent-farm/servers/dashboard-server.d.ts +9 -0
  89. package/dist/agent-farm/servers/dashboard-server.d.ts.map +1 -0
  90. package/dist/agent-farm/servers/dashboard-server.js +939 -0
  91. package/dist/agent-farm/servers/dashboard-server.js.map +1 -0
  92. package/dist/agent-farm/servers/tower-server.d.ts +9 -0
  93. package/dist/agent-farm/servers/tower-server.d.ts.map +1 -0
  94. package/dist/agent-farm/servers/tower-server.js +463 -0
  95. package/dist/agent-farm/servers/tower-server.js.map +1 -0
  96. package/dist/agent-farm/state.d.ts +93 -0
  97. package/dist/agent-farm/state.d.ts.map +1 -0
  98. package/dist/agent-farm/state.js +253 -0
  99. package/dist/agent-farm/state.js.map +1 -0
  100. package/dist/agent-farm/tutorial/index.d.ts +8 -0
  101. package/dist/agent-farm/tutorial/index.d.ts.map +1 -0
  102. package/dist/agent-farm/tutorial/index.js +8 -0
  103. package/dist/agent-farm/tutorial/index.js.map +1 -0
  104. package/dist/agent-farm/tutorial/prompts.d.ts +57 -0
  105. package/dist/agent-farm/tutorial/prompts.d.ts.map +1 -0
  106. package/dist/agent-farm/tutorial/prompts.js +147 -0
  107. package/dist/agent-farm/tutorial/prompts.js.map +1 -0
  108. package/dist/agent-farm/tutorial/runner.d.ts +52 -0
  109. package/dist/agent-farm/tutorial/runner.d.ts.map +1 -0
  110. package/dist/agent-farm/tutorial/runner.js +204 -0
  111. package/dist/agent-farm/tutorial/runner.js.map +1 -0
  112. package/dist/agent-farm/tutorial/state.d.ts +26 -0
  113. package/dist/agent-farm/tutorial/state.d.ts.map +1 -0
  114. package/dist/agent-farm/tutorial/state.js +89 -0
  115. package/dist/agent-farm/tutorial/state.js.map +1 -0
  116. package/dist/agent-farm/tutorial/steps/first-spec.d.ts +7 -0
  117. package/dist/agent-farm/tutorial/steps/first-spec.d.ts.map +1 -0
  118. package/dist/agent-farm/tutorial/steps/first-spec.js +136 -0
  119. package/dist/agent-farm/tutorial/steps/first-spec.js.map +1 -0
  120. package/dist/agent-farm/tutorial/steps/implementation.d.ts +7 -0
  121. package/dist/agent-farm/tutorial/steps/implementation.d.ts.map +1 -0
  122. package/dist/agent-farm/tutorial/steps/implementation.js +76 -0
  123. package/dist/agent-farm/tutorial/steps/implementation.js.map +1 -0
  124. package/dist/agent-farm/tutorial/steps/index.d.ts +10 -0
  125. package/dist/agent-farm/tutorial/steps/index.d.ts.map +1 -0
  126. package/dist/agent-farm/tutorial/steps/index.js +10 -0
  127. package/dist/agent-farm/tutorial/steps/index.js.map +1 -0
  128. package/dist/agent-farm/tutorial/steps/planning.d.ts +7 -0
  129. package/dist/agent-farm/tutorial/steps/planning.d.ts.map +1 -0
  130. package/dist/agent-farm/tutorial/steps/planning.js +143 -0
  131. package/dist/agent-farm/tutorial/steps/planning.js.map +1 -0
  132. package/dist/agent-farm/tutorial/steps/review.d.ts +7 -0
  133. package/dist/agent-farm/tutorial/steps/review.d.ts.map +1 -0
  134. package/dist/agent-farm/tutorial/steps/review.js +78 -0
  135. package/dist/agent-farm/tutorial/steps/review.js.map +1 -0
  136. package/dist/agent-farm/tutorial/steps/setup.d.ts +7 -0
  137. package/dist/agent-farm/tutorial/steps/setup.d.ts.map +1 -0
  138. package/dist/agent-farm/tutorial/steps/setup.js +126 -0
  139. package/dist/agent-farm/tutorial/steps/setup.js.map +1 -0
  140. package/dist/agent-farm/tutorial/steps/welcome.d.ts +7 -0
  141. package/dist/agent-farm/tutorial/steps/welcome.d.ts.map +1 -0
  142. package/dist/agent-farm/tutorial/steps/welcome.js +50 -0
  143. package/dist/agent-farm/tutorial/steps/welcome.js.map +1 -0
  144. package/dist/agent-farm/types.d.ts +131 -0
  145. package/dist/agent-farm/types.d.ts.map +1 -0
  146. package/dist/agent-farm/types.js +5 -0
  147. package/dist/agent-farm/types.js.map +1 -0
  148. package/dist/agent-farm/utils/config.d.ts +27 -0
  149. package/dist/agent-farm/utils/config.d.ts.map +1 -0
  150. package/dist/agent-farm/utils/config.js +242 -0
  151. package/dist/agent-farm/utils/config.js.map +1 -0
  152. package/dist/agent-farm/utils/deps.d.ts +51 -0
  153. package/dist/agent-farm/utils/deps.d.ts.map +1 -0
  154. package/dist/agent-farm/utils/deps.js +194 -0
  155. package/dist/agent-farm/utils/deps.js.map +1 -0
  156. package/dist/agent-farm/utils/index.d.ts +6 -0
  157. package/dist/agent-farm/utils/index.d.ts.map +1 -0
  158. package/dist/agent-farm/utils/index.js +6 -0
  159. package/dist/agent-farm/utils/index.js.map +1 -0
  160. package/dist/agent-farm/utils/logger.d.ts +31 -0
  161. package/dist/agent-farm/utils/logger.d.ts.map +1 -0
  162. package/dist/agent-farm/utils/logger.js +58 -0
  163. package/dist/agent-farm/utils/logger.js.map +1 -0
  164. package/dist/agent-farm/utils/orphan-handler.d.ts +27 -0
  165. package/dist/agent-farm/utils/orphan-handler.d.ts.map +1 -0
  166. package/dist/agent-farm/utils/orphan-handler.js +127 -0
  167. package/dist/agent-farm/utils/orphan-handler.js.map +1 -0
  168. package/dist/agent-farm/utils/port-registry.d.ts +58 -0
  169. package/dist/agent-farm/utils/port-registry.d.ts.map +1 -0
  170. package/dist/agent-farm/utils/port-registry.js +149 -0
  171. package/dist/agent-farm/utils/port-registry.js.map +1 -0
  172. package/dist/agent-farm/utils/shell.d.ts +45 -0
  173. package/dist/agent-farm/utils/shell.d.ts.map +1 -0
  174. package/dist/agent-farm/utils/shell.js +120 -0
  175. package/dist/agent-farm/utils/shell.js.map +1 -0
  176. package/dist/cli.d.ts +10 -0
  177. package/dist/cli.d.ts.map +1 -0
  178. package/dist/cli.js +160 -0
  179. package/dist/cli.js.map +1 -0
  180. package/dist/commands/adopt.d.ts +12 -0
  181. package/dist/commands/adopt.d.ts.map +1 -0
  182. package/dist/commands/adopt.js +178 -0
  183. package/dist/commands/adopt.js.map +1 -0
  184. package/dist/commands/consult/index.d.ts +17 -0
  185. package/dist/commands/consult/index.d.ts.map +1 -0
  186. package/dist/commands/consult/index.js +405 -0
  187. package/dist/commands/consult/index.js.map +1 -0
  188. package/dist/commands/doctor.d.ts +10 -0
  189. package/dist/commands/doctor.d.ts.map +1 -0
  190. package/dist/commands/doctor.js +346 -0
  191. package/dist/commands/doctor.js.map +1 -0
  192. package/dist/commands/init.d.ts +12 -0
  193. package/dist/commands/init.d.ts.map +1 -0
  194. package/dist/commands/init.js +167 -0
  195. package/dist/commands/init.js.map +1 -0
  196. package/dist/commands/tower.d.ts +16 -0
  197. package/dist/commands/tower.d.ts.map +1 -0
  198. package/dist/commands/tower.js +21 -0
  199. package/dist/commands/tower.js.map +1 -0
  200. package/dist/commands/update.d.ts +13 -0
  201. package/dist/commands/update.d.ts.map +1 -0
  202. package/dist/commands/update.js +137 -0
  203. package/dist/commands/update.js.map +1 -0
  204. package/dist/lib/templates.d.ts +57 -0
  205. package/dist/lib/templates.d.ts.map +1 -0
  206. package/dist/lib/templates.js +205 -0
  207. package/dist/lib/templates.js.map +1 -0
  208. package/package.json +55 -0
  209. package/templates/AGENTS.md +49 -0
  210. package/templates/CLAUDE.md +47 -0
  211. package/templates/DEPENDENCIES.md +344 -0
  212. package/templates/agents/architecture-documenter.md +189 -0
  213. package/templates/agents/codev-updater.md +276 -0
  214. package/templates/agents/spider-protocol-updater.md +118 -0
  215. package/templates/annotate.html +903 -0
  216. package/templates/bin/agent-farm +18 -0
  217. package/templates/bin/annotate-server.js +140 -0
  218. package/templates/bin/codev-doctor +335 -0
  219. package/templates/builders.md +30 -0
  220. package/templates/config.json +7 -0
  221. package/templates/dashboard-split.html +1679 -0
  222. package/templates/dashboard.html +149 -0
  223. package/templates/plans/.gitkeep +0 -0
  224. package/templates/protocols/experiment/protocol.md +229 -0
  225. package/templates/protocols/experiment/templates/notes.md +97 -0
  226. package/templates/protocols/maintain/protocol.md +235 -0
  227. package/templates/protocols/spider/protocol.md +639 -0
  228. package/templates/protocols/spider/templates/plan.md +169 -0
  229. package/templates/protocols/spider/templates/review.md +207 -0
  230. package/templates/protocols/spider/templates/spec.md +140 -0
  231. package/templates/protocols/spider-solo/protocol.md +619 -0
  232. package/templates/protocols/spider-solo/templates/plan.md +169 -0
  233. package/templates/protocols/spider-solo/templates/review.md +207 -0
  234. package/templates/protocols/spider-solo/templates/spec.md +140 -0
  235. package/templates/protocols/tick/protocol.md +250 -0
  236. package/templates/protocols/tick/templates/plan.md +67 -0
  237. package/templates/protocols/tick/templates/review.md +90 -0
  238. package/templates/protocols/tick/templates/spec.md +61 -0
  239. package/templates/reviews/.gitkeep +0 -0
  240. package/templates/roles/architect.md +230 -0
  241. package/templates/roles/builder.md +175 -0
  242. package/templates/roles/consultant.md +27 -0
  243. package/templates/specs/.gitkeep +0 -0
  244. package/templates/templates/projectlist.md +129 -0
  245. package/templates/tower.html +1032 -0
@@ -0,0 +1,939 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Dashboard server for Agent Farm.
4
+ * Serves the split-pane dashboard UI and provides state/tab management APIs.
5
+ *
6
+ * Usage: node dashboard-server.js <port>
7
+ */
8
+ import http from 'node:http';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import net from 'node:net';
12
+ import { spawn, execSync } from 'node:child_process';
13
+ import { randomUUID } from 'node:crypto';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { loadState, getAnnotations, addAnnotation, removeAnnotation, getUtils, addUtil, removeUtil, getBuilder, getBuilders, removeBuilder, upsertBuilder, clearState, } from '../state.js';
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = path.dirname(__filename);
18
+ // Default dashboard port
19
+ const DEFAULT_DASHBOARD_PORT = 4200;
20
+ // Parse arguments (override default port if provided)
21
+ const port = parseInt(process.argv[2] || String(DEFAULT_DASHBOARD_PORT), 10);
22
+ // Configuration - ports are relative to the dashboard port
23
+ // This ensures multi-project support (e.g., dashboard on 4300 uses 4350 for annotations)
24
+ const CONFIG = {
25
+ dashboardPort: port,
26
+ architectPort: port + 1,
27
+ builderPortStart: port + 10,
28
+ utilPortStart: port + 30,
29
+ annotatePortStart: port + 50,
30
+ maxTabs: 20, // DoS protection: max concurrent tabs
31
+ };
32
+ // Find project root by looking for .agent-farm directory
33
+ function findProjectRoot() {
34
+ let dir = process.cwd();
35
+ while (dir !== '/') {
36
+ if (fs.existsSync(path.join(dir, '.agent-farm'))) {
37
+ return dir;
38
+ }
39
+ if (fs.existsSync(path.join(dir, 'codev'))) {
40
+ return dir;
41
+ }
42
+ dir = path.dirname(dir);
43
+ }
44
+ return process.cwd();
45
+ }
46
+ // Get project name from root path, with truncation for long names
47
+ function getProjectName(projectRoot) {
48
+ const baseName = path.basename(projectRoot);
49
+ const maxLength = 30;
50
+ if (baseName.length <= maxLength) {
51
+ return baseName;
52
+ }
53
+ // Truncate with ellipsis for very long names
54
+ return '...' + baseName.slice(-(maxLength - 3));
55
+ }
56
+ // HTML-escape a string to prevent XSS
57
+ function escapeHtml(str) {
58
+ return str
59
+ .replace(/&/g, '&amp;')
60
+ .replace(/</g, '&lt;')
61
+ .replace(/>/g, '&gt;')
62
+ .replace(/"/g, '&quot;')
63
+ .replace(/'/g, '&#39;');
64
+ }
65
+ function findTemplatePath(filename, required = false) {
66
+ // 1. Try relative to compiled output (dist/servers/ -> templates/)
67
+ const pkgPath = path.resolve(__dirname, '../templates/', filename);
68
+ if (fs.existsSync(pkgPath))
69
+ return pkgPath;
70
+ // 2. Try relative to source (src/servers/ -> templates/)
71
+ const devPath = path.resolve(__dirname, '../../templates/', filename);
72
+ if (fs.existsSync(devPath))
73
+ return devPath;
74
+ if (required) {
75
+ throw new Error(`Template not found: ${filename}`);
76
+ }
77
+ return null;
78
+ }
79
+ const projectRoot = findProjectRoot();
80
+ const templatePath = findTemplatePath('dashboard-split.html', true);
81
+ const legacyTemplatePath = findTemplatePath('dashboard.html', true);
82
+ // Clean up dead processes from state (called on state load)
83
+ function cleanupDeadProcesses() {
84
+ // Clean up dead shell processes
85
+ for (const util of getUtils()) {
86
+ if (!isProcessRunning(util.pid)) {
87
+ console.log(`Auto-closing shell tab ${util.name} (process ${util.pid} exited)`);
88
+ if (util.tmuxSession) {
89
+ killTmuxSession(util.tmuxSession);
90
+ }
91
+ removeUtil(util.id);
92
+ }
93
+ }
94
+ // Clean up dead annotation processes
95
+ for (const annotation of getAnnotations()) {
96
+ if (!isProcessRunning(annotation.pid)) {
97
+ console.log(`Auto-closing file tab ${annotation.file} (process ${annotation.pid} exited)`);
98
+ removeAnnotation(annotation.id);
99
+ }
100
+ }
101
+ }
102
+ // Load state with cleanup
103
+ function loadStateWithCleanup() {
104
+ cleanupDeadProcesses();
105
+ return loadState();
106
+ }
107
+ // Generate unique ID using crypto for collision resistance
108
+ function generateId(prefix) {
109
+ const uuid = randomUUID().replace(/-/g, '').substring(0, 8).toUpperCase();
110
+ return `${prefix}${uuid}`;
111
+ }
112
+ // Get all ports currently used in state
113
+ function getUsedPorts(state) {
114
+ const ports = new Set();
115
+ if (state.architect?.port)
116
+ ports.add(state.architect.port);
117
+ for (const builder of state.builders || []) {
118
+ if (builder.port)
119
+ ports.add(builder.port);
120
+ }
121
+ for (const util of state.utils || []) {
122
+ if (util.port)
123
+ ports.add(util.port);
124
+ }
125
+ for (const annotation of state.annotations || []) {
126
+ if (annotation.port)
127
+ ports.add(annotation.port);
128
+ }
129
+ return ports;
130
+ }
131
+ // Find available port in range (checks both state and actual availability)
132
+ async function findAvailablePort(startPort, state) {
133
+ // Get ports already allocated in state
134
+ const usedPorts = state ? getUsedPorts(state) : new Set();
135
+ // Skip ports already in state
136
+ let port = startPort;
137
+ while (usedPorts.has(port)) {
138
+ port++;
139
+ }
140
+ // Then verify the port is actually available for binding
141
+ return new Promise((resolve) => {
142
+ const server = net.createServer();
143
+ server.listen(port, () => {
144
+ const { port: boundPort } = server.address();
145
+ server.close(() => resolve(boundPort));
146
+ });
147
+ server.on('error', () => {
148
+ resolve(findAvailablePort(port + 1, state));
149
+ });
150
+ });
151
+ }
152
+ // Wait for a port to be accepting connections (server ready)
153
+ async function waitForPortReady(port, timeoutMs = 5000) {
154
+ const startTime = Date.now();
155
+ const pollInterval = 100; // Check every 100ms
156
+ while (Date.now() - startTime < timeoutMs) {
157
+ const isReady = await new Promise((resolve) => {
158
+ const socket = new net.Socket();
159
+ socket.setTimeout(pollInterval);
160
+ socket.on('connect', () => {
161
+ socket.destroy();
162
+ resolve(true);
163
+ });
164
+ socket.on('error', () => {
165
+ socket.destroy();
166
+ resolve(false);
167
+ });
168
+ socket.on('timeout', () => {
169
+ socket.destroy();
170
+ resolve(false);
171
+ });
172
+ socket.connect(port, '127.0.0.1');
173
+ });
174
+ if (isReady) {
175
+ return true;
176
+ }
177
+ // Wait before next poll
178
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
179
+ }
180
+ return false;
181
+ }
182
+ // Kill tmux session
183
+ function killTmuxSession(sessionName) {
184
+ try {
185
+ execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { stdio: 'ignore' });
186
+ }
187
+ catch {
188
+ // Session may not exist
189
+ }
190
+ }
191
+ // Check if a process is running
192
+ function isProcessRunning(pid) {
193
+ try {
194
+ // Signal 0 doesn't kill, just checks if process exists
195
+ process.kill(pid, 0);
196
+ return true;
197
+ }
198
+ catch {
199
+ return false;
200
+ }
201
+ }
202
+ // Graceful process termination with two-phase shutdown
203
+ async function killProcessGracefully(pid, tmuxSession) {
204
+ // First kill tmux session if provided
205
+ if (tmuxSession) {
206
+ killTmuxSession(tmuxSession);
207
+ }
208
+ try {
209
+ // First try SIGTERM
210
+ process.kill(pid, 'SIGTERM');
211
+ // Wait up to 500ms for process to exit
212
+ await new Promise((resolve) => {
213
+ let attempts = 0;
214
+ const checkInterval = setInterval(() => {
215
+ attempts++;
216
+ try {
217
+ // Signal 0 checks if process exists
218
+ process.kill(pid, 0);
219
+ if (attempts >= 5) {
220
+ // Process still alive after 500ms, use SIGKILL
221
+ clearInterval(checkInterval);
222
+ try {
223
+ process.kill(pid, 'SIGKILL');
224
+ }
225
+ catch {
226
+ // Already dead
227
+ }
228
+ resolve();
229
+ }
230
+ }
231
+ catch {
232
+ // Process is dead
233
+ clearInterval(checkInterval);
234
+ resolve();
235
+ }
236
+ }, 100);
237
+ });
238
+ }
239
+ catch {
240
+ // Process may already be dead
241
+ }
242
+ }
243
+ // Spawn detached process with error handling
244
+ function spawnDetached(command, args, cwd) {
245
+ try {
246
+ const child = spawn(command, args, {
247
+ cwd,
248
+ detached: true,
249
+ stdio: 'ignore',
250
+ });
251
+ child.on('error', (err) => {
252
+ console.error(`Failed to spawn ${command}:`, err.message);
253
+ });
254
+ child.unref();
255
+ return child.pid || null;
256
+ }
257
+ catch (err) {
258
+ console.error(`Failed to spawn ${command}:`, err.message);
259
+ return null;
260
+ }
261
+ }
262
+ // Check if tmux session exists
263
+ function tmuxSessionExists(sessionName) {
264
+ try {
265
+ execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { stdio: 'ignore' });
266
+ return true;
267
+ }
268
+ catch {
269
+ return false;
270
+ }
271
+ }
272
+ // Create a persistent tmux session and attach ttyd to it
273
+ // Idempotent: if session exists, just spawn ttyd to attach to it
274
+ function spawnTmuxWithTtyd(sessionName, shellCommand, ttydPort, cwd) {
275
+ try {
276
+ // Only create session if it doesn't exist (idempotent)
277
+ if (!tmuxSessionExists(sessionName)) {
278
+ // Create tmux session with the shell command
279
+ execSync(`tmux new-session -d -s "${sessionName}" -x 200 -y 50 "${shellCommand}"`, { cwd, stdio: 'ignore' });
280
+ // Enable mouse support in the session
281
+ execSync(`tmux set-option -t "${sessionName}" -g mouse on`, { stdio: 'ignore' });
282
+ // Enable OSC 52 clipboard (allows copy to browser clipboard via ttyd)
283
+ execSync(`tmux set-option -t "${sessionName}" -g set-clipboard on`, { stdio: 'ignore' });
284
+ // Enable passthrough for hyperlinks and clipboard
285
+ execSync(`tmux set-option -t "${sessionName}" -g allow-passthrough on`, { stdio: 'ignore' });
286
+ // Copy selection to clipboard when mouse is released
287
+ // Use copy-pipe-and-cancel with pbcopy to directly copy to system clipboard
288
+ // (OSC 52 via set-clipboard doesn't work reliably through ttyd/xterm.js)
289
+ execSync(`tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"`, { stdio: 'ignore' });
290
+ execSync(`tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"`, { stdio: 'ignore' });
291
+ }
292
+ // Start ttyd to attach to the tmux session
293
+ // Using simple theme arg to avoid shell escaping issues
294
+ // Use custom index.html for file path click-to-open functionality (optional)
295
+ const customIndexPath = findTemplatePath('ttyd-index.html');
296
+ const ttydArgs = [
297
+ '-W',
298
+ '-p', String(ttydPort),
299
+ '-t', 'theme={"background":"#000000"}',
300
+ '-t', 'fontSize=14',
301
+ '-t', 'rightClickSelectsWord=true', // Enable word selection on right-click for better UX
302
+ ];
303
+ // Add custom index if it exists
304
+ if (customIndexPath) {
305
+ ttydArgs.push('-I', customIndexPath);
306
+ }
307
+ ttydArgs.push('tmux', 'attach-session', '-t', sessionName);
308
+ const pid = spawnDetached('ttyd', ttydArgs, cwd);
309
+ return pid;
310
+ }
311
+ catch (err) {
312
+ console.error(`Failed to create tmux session ${sessionName}:`, err.message);
313
+ // Cleanup any partial session
314
+ killTmuxSession(sessionName);
315
+ return null;
316
+ }
317
+ }
318
+ /**
319
+ * Generate a short 4-character base64-encoded ID for worktree names
320
+ */
321
+ function generateShortId() {
322
+ const num = Math.floor(Math.random() * 0xFFFFFF);
323
+ const bytes = new Uint8Array([num >> 16, (num >> 8) & 0xFF, num & 0xFF]);
324
+ return btoa(String.fromCharCode(...bytes))
325
+ .replace(/\+/g, '-')
326
+ .replace(/\//g, '_')
327
+ .replace(/=/g, '')
328
+ .substring(0, 4);
329
+ }
330
+ /**
331
+ * Spawn a worktree builder - creates git worktree and starts builder CLI
332
+ * Similar to shell spawning but with git worktree isolation
333
+ */
334
+ function spawnWorktreeBuilder(builderPort, state) {
335
+ const shortId = generateShortId();
336
+ const builderId = `worktree-${shortId}`;
337
+ const branchName = `builder/worktree-${shortId}`;
338
+ const worktreePath = path.resolve(projectRoot, '.builders', builderId);
339
+ const sessionName = `builder-${builderId}`;
340
+ try {
341
+ // Ensure .builders directory exists
342
+ const buildersDir = path.resolve(projectRoot, '.builders');
343
+ if (!fs.existsSync(buildersDir)) {
344
+ fs.mkdirSync(buildersDir, { recursive: true });
345
+ }
346
+ // Create git branch and worktree
347
+ execSync(`git branch "${branchName}" HEAD`, { cwd: projectRoot, stdio: 'ignore' });
348
+ execSync(`git worktree add "${worktreePath}" "${branchName}"`, { cwd: projectRoot, stdio: 'ignore' });
349
+ // Get builder command from config or use default
350
+ const configPath = path.resolve(projectRoot, 'codev', 'config.json');
351
+ let builderCommand = 'claude';
352
+ if (fs.existsSync(configPath)) {
353
+ try {
354
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
355
+ builderCommand = config?.shell?.builder || 'claude';
356
+ }
357
+ catch {
358
+ // Use default
359
+ }
360
+ }
361
+ // Create tmux session with builder command
362
+ execSync(`tmux new-session -d -s "${sessionName}" -x 200 -y 50 -c "${worktreePath}" "${builderCommand}"`, { cwd: worktreePath, stdio: 'ignore' });
363
+ // Enable mouse support
364
+ execSync(`tmux set-option -t "${sessionName}" -g mouse on`, { stdio: 'ignore' });
365
+ execSync(`tmux set-option -t "${sessionName}" -g set-clipboard on`, { stdio: 'ignore' });
366
+ execSync(`tmux set-option -t "${sessionName}" -g allow-passthrough on`, { stdio: 'ignore' });
367
+ // Copy selection to clipboard when mouse is released (pbcopy for macOS)
368
+ execSync(`tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"`, { stdio: 'ignore' });
369
+ execSync(`tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"`, { stdio: 'ignore' });
370
+ // Start ttyd
371
+ const customIndexPath = findTemplatePath('ttyd-index.html');
372
+ const ttydArgs = [
373
+ '-W',
374
+ '-p', String(builderPort),
375
+ '-t', 'theme={"background":"#000000"}',
376
+ '-t', 'fontSize=14',
377
+ '-t', 'rightClickSelectsWord=true',
378
+ ];
379
+ if (customIndexPath) {
380
+ ttydArgs.push('-I', customIndexPath);
381
+ }
382
+ ttydArgs.push('tmux', 'attach-session', '-t', sessionName);
383
+ const pid = spawnDetached('ttyd', ttydArgs, worktreePath);
384
+ if (!pid) {
385
+ // Cleanup on failure
386
+ killTmuxSession(sessionName);
387
+ try {
388
+ execSync(`git worktree remove "${worktreePath}" --force`, { cwd: projectRoot, stdio: 'ignore' });
389
+ execSync(`git branch -D "${branchName}"`, { cwd: projectRoot, stdio: 'ignore' });
390
+ }
391
+ catch {
392
+ // Best effort cleanup
393
+ }
394
+ return null;
395
+ }
396
+ const builder = {
397
+ id: builderId,
398
+ name: `Worktree ${shortId}`,
399
+ port: builderPort,
400
+ pid,
401
+ status: 'implementing',
402
+ phase: 'interactive',
403
+ worktree: worktreePath,
404
+ branch: branchName,
405
+ tmuxSession: sessionName,
406
+ type: 'worktree',
407
+ };
408
+ return { builder, pid };
409
+ }
410
+ catch (err) {
411
+ console.error(`Failed to spawn worktree builder:`, err.message);
412
+ // Cleanup any partial state
413
+ killTmuxSession(sessionName);
414
+ try {
415
+ execSync(`git worktree remove "${worktreePath}" --force`, { cwd: projectRoot, stdio: 'ignore' });
416
+ execSync(`git branch -D "${branchName}"`, { cwd: projectRoot, stdio: 'ignore' });
417
+ }
418
+ catch {
419
+ // Best effort cleanup
420
+ }
421
+ return null;
422
+ }
423
+ }
424
+ // Parse JSON body from request with size limit
425
+ function parseJsonBody(req, maxSize = 1024 * 1024) {
426
+ return new Promise((resolve, reject) => {
427
+ let body = '';
428
+ let size = 0;
429
+ req.on('data', (chunk) => {
430
+ size += chunk.length;
431
+ if (size > maxSize) {
432
+ reject(new Error('Request body too large'));
433
+ req.destroy();
434
+ return;
435
+ }
436
+ body += chunk.toString();
437
+ });
438
+ req.on('end', () => {
439
+ try {
440
+ resolve(body ? JSON.parse(body) : {});
441
+ }
442
+ catch {
443
+ reject(new Error('Invalid JSON'));
444
+ }
445
+ });
446
+ req.on('error', reject);
447
+ });
448
+ }
449
+ // Validate path is within project root (prevent path traversal)
450
+ // Handles URL-encoded dots (%2e), symlinks, and other encodings
451
+ function validatePathWithinProject(filePath) {
452
+ // First decode any URL encoding to catch %2e%2e (encoded ..)
453
+ let decodedPath;
454
+ try {
455
+ decodedPath = decodeURIComponent(filePath);
456
+ }
457
+ catch {
458
+ // Invalid encoding
459
+ return null;
460
+ }
461
+ // Resolve to absolute path
462
+ const resolvedPath = decodedPath.startsWith('/')
463
+ ? path.resolve(decodedPath)
464
+ : path.resolve(projectRoot, decodedPath);
465
+ // Normalize to remove any .. or . segments
466
+ const normalizedPath = path.normalize(resolvedPath);
467
+ // First check normalized path (for paths that don't exist yet)
468
+ if (!normalizedPath.startsWith(projectRoot + path.sep) && normalizedPath !== projectRoot) {
469
+ return null; // Path escapes project root
470
+ }
471
+ // If file exists, resolve symlinks to prevent symlink-based path traversal
472
+ // An attacker could create a symlink within the repo pointing outside
473
+ if (fs.existsSync(normalizedPath)) {
474
+ try {
475
+ const realPath = fs.realpathSync(normalizedPath);
476
+ if (!realPath.startsWith(projectRoot + path.sep) && realPath !== projectRoot) {
477
+ return null; // Symlink target escapes project root
478
+ }
479
+ return realPath;
480
+ }
481
+ catch {
482
+ // realpathSync failed (broken symlink, permissions, etc.)
483
+ return null;
484
+ }
485
+ }
486
+ return normalizedPath;
487
+ }
488
+ // Count total tabs for DoS protection
489
+ function countTotalTabs(state) {
490
+ return state.builders.length + state.utils.length + state.annotations.length;
491
+ }
492
+ // Find annotation server script (prefer .ts for dev, .js for compiled)
493
+ function getAnnotateServerPath() {
494
+ const tsPath = path.join(__dirname, 'annotate-server.ts');
495
+ const jsPath = path.join(__dirname, 'annotate-server.js');
496
+ if (fs.existsSync(tsPath)) {
497
+ return { script: tsPath, useTsx: true };
498
+ }
499
+ return { script: jsPath, useTsx: false };
500
+ }
501
+ // Use split template as main, legacy is already loaded via findTemplatePath
502
+ const finalTemplatePath = templatePath;
503
+ // Security: Validate request origin
504
+ function isRequestAllowed(req) {
505
+ const host = req.headers.host;
506
+ const origin = req.headers.origin;
507
+ // Host check (prevent DNS rebinding attacks)
508
+ if (host && !host.startsWith('localhost') && !host.startsWith('127.0.0.1')) {
509
+ return false;
510
+ }
511
+ // Origin check (prevent CSRF from external sites)
512
+ // Note: CLI tools/curl might not send Origin, so we only block if Origin is present and invalid
513
+ if (origin && !origin.startsWith('http://localhost') && !origin.startsWith('http://127.0.0.1')) {
514
+ return false;
515
+ }
516
+ return true;
517
+ }
518
+ // Create server
519
+ const server = http.createServer(async (req, res) => {
520
+ // Security: Validate Host and Origin headers
521
+ if (!isRequestAllowed(req)) {
522
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
523
+ res.end('Forbidden');
524
+ return;
525
+ }
526
+ // CORS headers - restrict to localhost only for security
527
+ const origin = req.headers.origin;
528
+ if (origin && (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:'))) {
529
+ res.setHeader('Access-Control-Allow-Origin', origin);
530
+ }
531
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
532
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
533
+ // Prevent caching of API responses
534
+ res.setHeader('Cache-Control', 'no-store');
535
+ if (req.method === 'OPTIONS') {
536
+ res.writeHead(200);
537
+ res.end();
538
+ return;
539
+ }
540
+ const url = new URL(req.url || '/', `http://localhost:${port}`);
541
+ try {
542
+ // API: Get state
543
+ if (req.method === 'GET' && url.pathname === '/api/state') {
544
+ const state = loadStateWithCleanup();
545
+ res.writeHead(200, { 'Content-Type': 'application/json' });
546
+ res.end(JSON.stringify(state));
547
+ return;
548
+ }
549
+ // API: Create file tab (annotation)
550
+ if (req.method === 'POST' && url.pathname === '/api/tabs/file') {
551
+ const body = await parseJsonBody(req);
552
+ const filePath = body.path;
553
+ if (!filePath) {
554
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
555
+ res.end('Missing path');
556
+ return;
557
+ }
558
+ // Validate path is within project root (prevent path traversal)
559
+ const fullPath = validatePathWithinProject(filePath);
560
+ if (!fullPath) {
561
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
562
+ res.end('Path must be within project directory');
563
+ return;
564
+ }
565
+ // Check file exists
566
+ if (!fs.existsSync(fullPath)) {
567
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
568
+ res.end(`File not found: ${filePath}`);
569
+ return;
570
+ }
571
+ // Check if already open
572
+ const annotations = getAnnotations();
573
+ const existing = annotations.find((a) => a.file === fullPath);
574
+ if (existing) {
575
+ // Verify the process is still running
576
+ if (isProcessRunning(existing.pid)) {
577
+ res.writeHead(200, { 'Content-Type': 'application/json' });
578
+ res.end(JSON.stringify({ id: existing.id, port: existing.port, existing: true }));
579
+ return;
580
+ }
581
+ // Process is dead - clean up stale entry and spawn new one
582
+ console.log(`Cleaning up stale annotation for ${fullPath} (pid ${existing.pid} dead)`);
583
+ removeAnnotation(existing.id);
584
+ }
585
+ // DoS protection: check tab limit
586
+ const state = loadState();
587
+ if (countTotalTabs(state) >= CONFIG.maxTabs) {
588
+ res.writeHead(429, { 'Content-Type': 'text/plain' });
589
+ res.end(`Tab limit reached (max ${CONFIG.maxTabs}). Close some tabs first.`);
590
+ return;
591
+ }
592
+ // Find available port (pass state to avoid already-allocated ports)
593
+ const annotatePort = await findAvailablePort(CONFIG.annotatePortStart, state);
594
+ // Start annotation server
595
+ const { script: serverScript, useTsx } = getAnnotateServerPath();
596
+ if (!fs.existsSync(serverScript)) {
597
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
598
+ res.end('Annotation server not found');
599
+ return;
600
+ }
601
+ // Use tsx for TypeScript files, node for compiled JavaScript
602
+ const cmd = useTsx ? 'npx' : 'node';
603
+ const args = useTsx
604
+ ? ['tsx', serverScript, String(annotatePort), fullPath]
605
+ : [serverScript, String(annotatePort), fullPath];
606
+ const pid = spawnDetached(cmd, args, projectRoot);
607
+ if (!pid) {
608
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
609
+ res.end('Failed to start annotation server');
610
+ return;
611
+ }
612
+ // Wait for annotation server to be ready (accepting connections)
613
+ const serverReady = await waitForPortReady(annotatePort, 5000);
614
+ if (!serverReady) {
615
+ // Server didn't start in time - kill it and report error
616
+ try {
617
+ process.kill(pid);
618
+ }
619
+ catch {
620
+ // Process may have already died
621
+ }
622
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
623
+ res.end('Annotation server failed to start (timeout)');
624
+ return;
625
+ }
626
+ // Create annotation record
627
+ const annotation = {
628
+ id: generateId('A'),
629
+ file: fullPath,
630
+ port: annotatePort,
631
+ pid,
632
+ parent: { type: 'architect' },
633
+ };
634
+ addAnnotation(annotation);
635
+ res.writeHead(201, { 'Content-Type': 'application/json' });
636
+ res.end(JSON.stringify({ id: annotation.id, port: annotatePort }));
637
+ return;
638
+ }
639
+ // API: Create builder tab (spawns worktree builder with random ID)
640
+ if (req.method === 'POST' && url.pathname === '/api/tabs/builder') {
641
+ const builderState = loadState();
642
+ // DoS protection: check tab limit
643
+ if (countTotalTabs(builderState) >= CONFIG.maxTabs) {
644
+ res.writeHead(429, { 'Content-Type': 'text/plain' });
645
+ res.end(`Tab limit reached (max ${CONFIG.maxTabs}). Close some tabs first.`);
646
+ return;
647
+ }
648
+ // Find available port for builder
649
+ const builderPort = await findAvailablePort(CONFIG.builderPortStart, builderState);
650
+ // Spawn worktree builder
651
+ const result = spawnWorktreeBuilder(builderPort, builderState);
652
+ if (!result) {
653
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
654
+ res.end('Failed to spawn worktree builder');
655
+ return;
656
+ }
657
+ // Wait for ttyd to be ready
658
+ await new Promise((resolve) => setTimeout(resolve, 500));
659
+ // Save builder to state
660
+ upsertBuilder(result.builder);
661
+ res.writeHead(201, { 'Content-Type': 'application/json' });
662
+ res.end(JSON.stringify({ id: result.builder.id, port: result.builder.port, name: result.builder.name }));
663
+ return;
664
+ }
665
+ // API: Create shell tab
666
+ if (req.method === 'POST' && url.pathname === '/api/tabs/shell') {
667
+ const body = await parseJsonBody(req);
668
+ const name = body.name || undefined;
669
+ // Validate name if provided (prevent command injection)
670
+ if (name && !/^[a-zA-Z0-9_-]+$/.test(name)) {
671
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
672
+ res.end('Invalid name format');
673
+ return;
674
+ }
675
+ const shellState = loadState();
676
+ // DoS protection: check tab limit
677
+ if (countTotalTabs(shellState) >= CONFIG.maxTabs) {
678
+ res.writeHead(429, { 'Content-Type': 'text/plain' });
679
+ res.end(`Tab limit reached (max ${CONFIG.maxTabs}). Close some tabs first.`);
680
+ return;
681
+ }
682
+ // Generate ID and name
683
+ const id = generateId('U');
684
+ const utilName = name || `shell-${shellState.utils.length + 1}`;
685
+ const sessionName = `af-shell-${id}`;
686
+ // Find available port (pass state to avoid already-allocated ports)
687
+ const utilPort = await findAvailablePort(CONFIG.utilPortStart, shellState);
688
+ // Get shell command
689
+ const shell = process.env.SHELL || '/bin/bash';
690
+ // Start tmux session with ttyd attached
691
+ const pid = spawnTmuxWithTtyd(sessionName, shell, utilPort, projectRoot);
692
+ if (!pid) {
693
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
694
+ res.end('Failed to start shell');
695
+ return;
696
+ }
697
+ // Wait for ttyd to be ready
698
+ await new Promise((resolve) => setTimeout(resolve, 500));
699
+ // Create util record
700
+ const util = {
701
+ id,
702
+ name: utilName,
703
+ port: utilPort,
704
+ pid,
705
+ tmuxSession: sessionName,
706
+ };
707
+ addUtil(util);
708
+ res.writeHead(201, { 'Content-Type': 'application/json' });
709
+ res.end(JSON.stringify({ id, port: utilPort, name: utilName }));
710
+ return;
711
+ }
712
+ // API: Close tab
713
+ if (req.method === 'DELETE' && url.pathname.startsWith('/api/tabs/')) {
714
+ const tabId = decodeURIComponent(url.pathname.replace('/api/tabs/', ''));
715
+ let found = false;
716
+ // Check if it's a file tab
717
+ if (tabId.startsWith('file-')) {
718
+ const annotationId = tabId.replace('file-', '');
719
+ const tabAnnotations = getAnnotations();
720
+ const annotation = tabAnnotations.find((a) => a.id === annotationId);
721
+ if (annotation) {
722
+ await killProcessGracefully(annotation.pid);
723
+ removeAnnotation(annotationId);
724
+ found = true;
725
+ }
726
+ }
727
+ // Check if it's a builder tab
728
+ if (tabId.startsWith('builder-')) {
729
+ const builderId = tabId.replace('builder-', '');
730
+ const builder = getBuilder(builderId);
731
+ if (builder) {
732
+ await killProcessGracefully(builder.pid);
733
+ removeBuilder(builderId);
734
+ found = true;
735
+ }
736
+ }
737
+ // Check if it's a shell tab
738
+ if (tabId.startsWith('shell-')) {
739
+ const utilId = tabId.replace('shell-', '');
740
+ const tabUtils = getUtils();
741
+ const util = tabUtils.find((u) => u.id === utilId);
742
+ if (util) {
743
+ await killProcessGracefully(util.pid, util.tmuxSession);
744
+ removeUtil(utilId);
745
+ found = true;
746
+ }
747
+ }
748
+ if (found) {
749
+ res.writeHead(200, { 'Content-Type': 'application/json' });
750
+ res.end(JSON.stringify({ success: true }));
751
+ }
752
+ else {
753
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
754
+ res.end('Tab not found');
755
+ }
756
+ return;
757
+ }
758
+ // API: Stop all
759
+ if (req.method === 'POST' && url.pathname === '/api/stop') {
760
+ const stopState = loadState();
761
+ // Kill all tmux sessions first
762
+ for (const util of stopState.utils) {
763
+ if (util.tmuxSession) {
764
+ killTmuxSession(util.tmuxSession);
765
+ }
766
+ }
767
+ if (stopState.architect?.tmuxSession) {
768
+ killTmuxSession(stopState.architect.tmuxSession);
769
+ }
770
+ // Kill all processes gracefully
771
+ const pids = [];
772
+ if (stopState.architect) {
773
+ pids.push(stopState.architect.pid);
774
+ }
775
+ for (const builder of stopState.builders) {
776
+ pids.push(builder.pid);
777
+ }
778
+ for (const util of stopState.utils) {
779
+ pids.push(util.pid);
780
+ }
781
+ for (const annotation of stopState.annotations) {
782
+ pids.push(annotation.pid);
783
+ }
784
+ // Kill all processes in parallel
785
+ await Promise.all(pids.map((pid) => killProcessGracefully(pid)));
786
+ // Clear state
787
+ clearState();
788
+ res.writeHead(200, { 'Content-Type': 'application/json' });
789
+ res.end(JSON.stringify({ success: true, killed: pids.length }));
790
+ // Exit after a short delay
791
+ setTimeout(() => process.exit(0), 500);
792
+ return;
793
+ }
794
+ // Open file route - handles file clicks from terminal
795
+ // Returns a small HTML page that messages the dashboard via BroadcastChannel
796
+ if (req.method === 'GET' && url.pathname === '/open-file') {
797
+ const filePath = url.searchParams.get('path');
798
+ const line = url.searchParams.get('line');
799
+ const sourcePort = url.searchParams.get('sourcePort');
800
+ if (!filePath) {
801
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
802
+ res.end('Missing path parameter');
803
+ return;
804
+ }
805
+ // Determine base path for relative path resolution
806
+ // If sourcePort is provided, look up the builder/util to get its worktree
807
+ let basePath = projectRoot;
808
+ if (sourcePort) {
809
+ const portNum = parseInt(sourcePort, 10);
810
+ const builders = getBuilders();
811
+ // Check if it's a builder terminal
812
+ const builder = builders.find((b) => b.port === portNum);
813
+ if (builder && builder.worktree) {
814
+ basePath = builder.worktree;
815
+ }
816
+ // Check if it's a utility terminal (they run in project root, so no change needed)
817
+ // Architect terminal also runs in project root
818
+ }
819
+ // Validate path is within project (or builder worktree)
820
+ // For relative paths, resolve against the determined base path
821
+ let fullPath;
822
+ if (filePath.startsWith('/')) {
823
+ // Absolute path - validate against project root
824
+ fullPath = validatePathWithinProject(filePath);
825
+ }
826
+ else {
827
+ // Relative path - resolve against base path, then validate
828
+ const resolvedPath = path.resolve(basePath, filePath);
829
+ // For builder worktrees, the path is within project root (worktrees are under .builders/)
830
+ fullPath = validatePathWithinProject(resolvedPath);
831
+ }
832
+ if (!fullPath) {
833
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
834
+ res.end('Path must be within project directory');
835
+ return;
836
+ }
837
+ // Check file exists
838
+ if (!fs.existsSync(fullPath)) {
839
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
840
+ res.end(`File not found: ${filePath}`);
841
+ return;
842
+ }
843
+ // HTML-escape the file path for safe display
844
+ const escapeHtml = (str) => str
845
+ .replace(/&/g, '&amp;')
846
+ .replace(/</g, '&lt;')
847
+ .replace(/>/g, '&gt;')
848
+ .replace(/"/g, '&quot;');
849
+ const safeFilePath = escapeHtml(filePath);
850
+ const safeLineDisplay = line ? ':' + escapeHtml(line) : '';
851
+ // Serve a small HTML page that communicates back to dashboard
852
+ // Note: We only use BroadcastChannel, not API call (dashboard handles tab creation)
853
+ const html = `<!DOCTYPE html>
854
+ <html>
855
+ <head>
856
+ <title>Opening file...</title>
857
+ <style>
858
+ body {
859
+ font-family: system-ui;
860
+ background: #1a1a1a;
861
+ color: #ccc;
862
+ display: flex;
863
+ align-items: center;
864
+ justify-content: center;
865
+ height: 100vh;
866
+ margin: 0;
867
+ }
868
+ .message { text-align: center; }
869
+ .path { color: #3b82f6; font-family: monospace; margin: 8px 0; }
870
+ </style>
871
+ </head>
872
+ <body>
873
+ <div class="message">
874
+ <p>Opening file...</p>
875
+ <p class="path">${safeFilePath}${safeLineDisplay}</p>
876
+ </div>
877
+ <script>
878
+ (async function() {
879
+ const path = ${JSON.stringify(fullPath)};
880
+ const line = ${line ? parseInt(line, 10) : 'null'};
881
+
882
+ // Use BroadcastChannel to message the dashboard
883
+ // Dashboard will handle opening the file tab
884
+ const channel = new BroadcastChannel('agent-farm');
885
+ channel.postMessage({
886
+ type: 'openFile',
887
+ path: path,
888
+ line: line
889
+ });
890
+
891
+ // Close this window/tab after a short delay
892
+ setTimeout(() => {
893
+ window.close();
894
+ // If window.close() doesn't work (wasn't opened by script),
895
+ // show success message
896
+ document.body.innerHTML = '<div class="message"><p>File opened in dashboard</p><p class="path">You can close this tab</p></div>';
897
+ }, 500);
898
+ })();
899
+ </script>
900
+ </body>
901
+ </html>`;
902
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
903
+ res.end(html);
904
+ return;
905
+ }
906
+ // Serve dashboard
907
+ if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
908
+ try {
909
+ let template = fs.readFileSync(finalTemplatePath, 'utf-8');
910
+ const state = loadStateWithCleanup();
911
+ // Inject project name into template (HTML-escaped for security)
912
+ const projectName = escapeHtml(getProjectName(projectRoot));
913
+ template = template.replace(/\{\{PROJECT_NAME\}\}/g, projectName);
914
+ // Inject state into template
915
+ const stateJson = JSON.stringify(state);
916
+ template = template.replace('// STATE_INJECTION_POINT', `window.INITIAL_STATE = ${stateJson};`);
917
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
918
+ res.end(template);
919
+ }
920
+ catch (err) {
921
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
922
+ res.end('Error loading dashboard: ' + err.message);
923
+ }
924
+ return;
925
+ }
926
+ // 404 for everything else
927
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
928
+ res.end('Not found');
929
+ }
930
+ catch (err) {
931
+ console.error('Request error:', err);
932
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
933
+ res.end('Internal server error: ' + err.message);
934
+ }
935
+ });
936
+ server.listen(port, () => {
937
+ console.log(`Dashboard: http://localhost:${port}`);
938
+ });
939
+ //# sourceMappingURL=dashboard-server.js.map