@cluesmith/codev 2.0.0-rc.29 → 2.0.0-rc.32

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 (134) hide show
  1. package/dashboard/dist/assets/index-CXwnJkPh.css +32 -0
  2. package/dashboard/dist/assets/index-D429K6qO.js +120 -0
  3. package/dashboard/dist/assets/index-D429K6qO.js.map +1 -0
  4. package/dashboard/dist/index.html +13 -0
  5. package/dist/agent-farm/cli.d.ts.map +1 -1
  6. package/dist/agent-farm/cli.js +22 -0
  7. package/dist/agent-farm/cli.js.map +1 -1
  8. package/dist/agent-farm/commands/architect.d.ts.map +1 -1
  9. package/dist/agent-farm/commands/architect.js +0 -2
  10. package/dist/agent-farm/commands/architect.js.map +1 -1
  11. package/dist/agent-farm/commands/attach.d.ts +13 -0
  12. package/dist/agent-farm/commands/attach.d.ts.map +1 -0
  13. package/dist/agent-farm/commands/attach.js +179 -0
  14. package/dist/agent-farm/commands/attach.js.map +1 -0
  15. package/dist/agent-farm/commands/cleanup.js +1 -1
  16. package/dist/agent-farm/commands/cleanup.js.map +1 -1
  17. package/dist/agent-farm/commands/index.d.ts +1 -0
  18. package/dist/agent-farm/commands/index.d.ts.map +1 -1
  19. package/dist/agent-farm/commands/index.js +1 -0
  20. package/dist/agent-farm/commands/index.js.map +1 -1
  21. package/dist/agent-farm/commands/shell.d.ts +1 -1
  22. package/dist/agent-farm/commands/shell.d.ts.map +1 -1
  23. package/dist/agent-farm/commands/shell.js +19 -32
  24. package/dist/agent-farm/commands/shell.js.map +1 -1
  25. package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
  26. package/dist/agent-farm/commands/spawn.js +64 -97
  27. package/dist/agent-farm/commands/spawn.js.map +1 -1
  28. package/dist/agent-farm/commands/start.d.ts.map +1 -1
  29. package/dist/agent-farm/commands/start.js +8 -20
  30. package/dist/agent-farm/commands/start.js.map +1 -1
  31. package/dist/agent-farm/commands/stop.d.ts.map +1 -1
  32. package/dist/agent-farm/commands/stop.js +79 -10
  33. package/dist/agent-farm/commands/stop.js.map +1 -1
  34. package/dist/agent-farm/db/index.d.ts.map +1 -1
  35. package/dist/agent-farm/db/index.js +15 -0
  36. package/dist/agent-farm/db/index.js.map +1 -1
  37. package/dist/agent-farm/db/schema.d.ts +1 -1
  38. package/dist/agent-farm/db/schema.d.ts.map +1 -1
  39. package/dist/agent-farm/db/schema.js +6 -3
  40. package/dist/agent-farm/db/schema.js.map +1 -1
  41. package/dist/agent-farm/db/types.d.ts +3 -0
  42. package/dist/agent-farm/db/types.d.ts.map +1 -1
  43. package/dist/agent-farm/db/types.js +3 -0
  44. package/dist/agent-farm/db/types.js.map +1 -1
  45. package/dist/agent-farm/servers/dashboard-server.js +241 -108
  46. package/dist/agent-farm/servers/dashboard-server.js.map +1 -1
  47. package/dist/agent-farm/servers/tower-server.js +15 -52
  48. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  49. package/dist/agent-farm/state.d.ts +4 -0
  50. package/dist/agent-farm/state.d.ts.map +1 -1
  51. package/dist/agent-farm/state.js +30 -7
  52. package/dist/agent-farm/state.js.map +1 -1
  53. package/dist/agent-farm/types.d.ts +10 -0
  54. package/dist/agent-farm/types.d.ts.map +1 -1
  55. package/dist/agent-farm/utils/config.d.ts.map +1 -1
  56. package/dist/agent-farm/utils/config.js +1 -0
  57. package/dist/agent-farm/utils/config.js.map +1 -1
  58. package/dist/agent-farm/utils/deps.d.ts.map +1 -1
  59. package/dist/agent-farm/utils/deps.js +0 -16
  60. package/dist/agent-farm/utils/deps.js.map +1 -1
  61. package/dist/agent-farm/utils/shell.d.ts +8 -22
  62. package/dist/agent-farm/utils/shell.d.ts.map +1 -1
  63. package/dist/agent-farm/utils/shell.js +32 -34
  64. package/dist/agent-farm/utils/shell.js.map +1 -1
  65. package/dist/agent-farm/utils/terminal-ports.d.ts +1 -1
  66. package/dist/agent-farm/utils/terminal-ports.js +1 -1
  67. package/dist/commands/consult/index.d.ts.map +1 -1
  68. package/dist/commands/consult/index.js +6 -27
  69. package/dist/commands/consult/index.js.map +1 -1
  70. package/dist/commands/doctor.d.ts.map +1 -1
  71. package/dist/commands/doctor.js +0 -15
  72. package/dist/commands/doctor.js.map +1 -1
  73. package/dist/commands/porch/build-counter.d.ts +5 -0
  74. package/dist/commands/porch/build-counter.d.ts.map +1 -0
  75. package/dist/commands/porch/build-counter.js +5 -0
  76. package/dist/commands/porch/build-counter.js.map +1 -0
  77. package/dist/commands/porch/claude.d.ts +20 -22
  78. package/dist/commands/porch/claude.d.ts.map +1 -1
  79. package/dist/commands/porch/claude.js +92 -65
  80. package/dist/commands/porch/claude.js.map +1 -1
  81. package/dist/commands/porch/index.d.ts.map +1 -1
  82. package/dist/commands/porch/index.js +7 -6
  83. package/dist/commands/porch/index.js.map +1 -1
  84. package/dist/commands/porch/plan.d.ts +11 -1
  85. package/dist/commands/porch/plan.d.ts.map +1 -1
  86. package/dist/commands/porch/plan.js +33 -5
  87. package/dist/commands/porch/plan.js.map +1 -1
  88. package/dist/commands/porch/prompts.d.ts.map +1 -1
  89. package/dist/commands/porch/prompts.js +0 -20
  90. package/dist/commands/porch/prompts.js.map +1 -1
  91. package/dist/commands/porch/run.d.ts +20 -3
  92. package/dist/commands/porch/run.d.ts.map +1 -1
  93. package/dist/commands/porch/run.js +306 -161
  94. package/dist/commands/porch/run.js.map +1 -1
  95. package/dist/commands/porch/state.d.ts +5 -5
  96. package/dist/commands/porch/state.d.ts.map +1 -1
  97. package/dist/commands/porch/state.js +6 -49
  98. package/dist/commands/porch/state.js.map +1 -1
  99. package/dist/commands/porch/types.d.ts +3 -0
  100. package/dist/commands/porch/types.d.ts.map +1 -1
  101. package/dist/terminal/index.d.ts +8 -0
  102. package/dist/terminal/index.d.ts.map +1 -0
  103. package/dist/terminal/index.js +5 -0
  104. package/dist/terminal/index.js.map +1 -0
  105. package/dist/terminal/pty-manager.d.ts +60 -0
  106. package/dist/terminal/pty-manager.d.ts.map +1 -0
  107. package/dist/terminal/pty-manager.js +334 -0
  108. package/dist/terminal/pty-manager.js.map +1 -0
  109. package/dist/terminal/pty-session.d.ts +79 -0
  110. package/dist/terminal/pty-session.d.ts.map +1 -0
  111. package/dist/terminal/pty-session.js +215 -0
  112. package/dist/terminal/pty-session.js.map +1 -0
  113. package/dist/terminal/ring-buffer.d.ts +27 -0
  114. package/dist/terminal/ring-buffer.d.ts.map +1 -0
  115. package/dist/terminal/ring-buffer.js +74 -0
  116. package/dist/terminal/ring-buffer.js.map +1 -0
  117. package/dist/terminal/ws-protocol.d.ts +27 -0
  118. package/dist/terminal/ws-protocol.d.ts.map +1 -0
  119. package/dist/terminal/ws-protocol.js +44 -0
  120. package/dist/terminal/ws-protocol.js.map +1 -0
  121. package/package.json +11 -3
  122. package/skeleton/DEPENDENCIES.md +3 -29
  123. package/skeleton/builders.md +1 -1
  124. package/skeleton/protocols/spider/prompts/implement.md +10 -3
  125. package/templates/dashboard/js/tabs.js +1 -1
  126. package/templates/tower.html +3 -12
  127. package/dist/commands/porch/repl.d.ts +0 -33
  128. package/dist/commands/porch/repl.d.ts.map +0 -1
  129. package/dist/commands/porch/repl.js +0 -206
  130. package/dist/commands/porch/repl.js.map +0 -1
  131. package/dist/commands/porch/signals.d.ts +0 -38
  132. package/dist/commands/porch/signals.d.ts.map +0 -1
  133. package/dist/commands/porch/signals.js +0 -81
  134. package/dist/commands/porch/signals.js.map +0 -1
@@ -16,8 +16,8 @@ const execAsync = promisify(exec);
16
16
  import { Command } from 'commander';
17
17
  import { getPortForTerminal } from '../utils/terminal-ports.js';
18
18
  import { escapeHtml, parseJsonBody, isRequestAllowed as isRequestAllowedBase, } from '../utils/server-utils.js';
19
- import { loadState, getAnnotations, addAnnotation, removeAnnotation, getUtils, tryAddUtil, removeUtil, getBuilder, getBuilders, removeBuilder, upsertBuilder, clearState, } from '../state.js';
20
- import { spawnTtyd } from '../utils/shell.js';
19
+ import { loadState, getAnnotations, addAnnotation, removeAnnotation, getUtils, addUtil, removeUtil, updateUtil, getBuilder, getBuilders, removeBuilder, upsertBuilder, clearState, getArchitect, setArchitect, } from '../state.js';
20
+ import { TerminalManager } from '../../terminal/pty-manager.js';
21
21
  const __filename = fileURLToPath(import.meta.url);
22
22
  const __dirname = path.dirname(__filename);
23
23
  // Default dashboard port
@@ -91,6 +91,131 @@ function findTemplatePath(filename, required = false) {
91
91
  const projectRoot = findProjectRoot();
92
92
  // Use modular dashboard template (Spec 0060)
93
93
  const templatePath = findTemplatePath('dashboard/index.html', true);
94
+ // Terminal backend is always node-pty (Spec 0085)
95
+ const terminalBackend = 'node-pty';
96
+ // Load dashboard frontend preference from config (Spec 0085)
97
+ function loadDashboardFrontend() {
98
+ const configPath = path.resolve(projectRoot, 'codev', 'config.json');
99
+ if (fs.existsSync(configPath)) {
100
+ try {
101
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
102
+ return config?.dashboard?.frontend ?? 'react';
103
+ }
104
+ catch { /* ignore */ }
105
+ }
106
+ return 'react';
107
+ }
108
+ const dashboardFrontend = loadDashboardFrontend();
109
+ // React dashboard dist path (built by Vite)
110
+ const reactDashboardPath = path.resolve(__dirname, '../../../dashboard/dist');
111
+ const useReactDashboard = dashboardFrontend === 'react' && fs.existsSync(reactDashboardPath);
112
+ if (useReactDashboard) {
113
+ console.log('Dashboard frontend: React');
114
+ }
115
+ else if (dashboardFrontend === 'react') {
116
+ console.log('Dashboard frontend: React (dist not found, falling back to legacy)');
117
+ }
118
+ else {
119
+ console.log('Dashboard frontend: legacy');
120
+ }
121
+ const terminalManager = new TerminalManager({ projectRoot });
122
+ console.log('Terminal backend: node-pty');
123
+ // Clear stale terminalIds on startup — TerminalManager starts empty, so any
124
+ // persisted terminalId from a previous run is no longer valid.
125
+ {
126
+ const arch = getArchitect();
127
+ if (arch?.terminalId) {
128
+ setArchitect({ ...arch, terminalId: undefined });
129
+ }
130
+ for (const builder of getBuilders()) {
131
+ if (builder.terminalId) {
132
+ upsertBuilder({ ...builder, terminalId: undefined });
133
+ }
134
+ }
135
+ for (const util of getUtils()) {
136
+ if (util.terminalId) {
137
+ updateUtil(util.id, { terminalId: undefined });
138
+ }
139
+ }
140
+ }
141
+ // Auto-create architect PTY session if architect exists with a tmux session
142
+ async function initArchitectTerminal() {
143
+ const architect = getArchitect();
144
+ if (!architect || !architect.tmuxSession || architect.terminalId)
145
+ return;
146
+ try {
147
+ // Verify the tmux session actually exists before trying to attach.
148
+ // If it doesn't exist, tmux attach exits immediately, leaving a dead terminalId.
149
+ const { spawnSync } = await import('node:child_process');
150
+ const probe = spawnSync('tmux', ['has-session', '-t', architect.tmuxSession], { stdio: 'ignore' });
151
+ if (probe.status !== 0) {
152
+ console.log(`initArchitectTerminal: tmux session '${architect.tmuxSession}' does not exist yet`);
153
+ return;
154
+ }
155
+ // Use tmux directly (not via bash -c) to avoid DA response chaff.
156
+ // bash -c creates a brief window where readline echoes DA responses as text.
157
+ const info = await terminalManager.createSession({
158
+ command: 'tmux',
159
+ args: ['attach-session', '-t', architect.tmuxSession],
160
+ cwd: projectRoot,
161
+ cols: 200,
162
+ rows: 50,
163
+ label: 'architect',
164
+ });
165
+ // Wait to detect immediate exit (e.g., tmux session disappeared between check and attach)
166
+ await new Promise((resolve) => setTimeout(resolve, 500));
167
+ const session = terminalManager.getSession(info.id);
168
+ if (!session || session.info.exitCode !== undefined) {
169
+ console.error(`initArchitectTerminal: PTY exited immediately (exit=${session?.info.exitCode})`);
170
+ terminalManager.killSession(info.id);
171
+ return;
172
+ }
173
+ setArchitect({ ...architect, terminalId: info.id });
174
+ console.log(`Architect terminal session created: ${info.id}`);
175
+ }
176
+ catch (err) {
177
+ console.error('Failed to create architect terminal session:', err.message);
178
+ }
179
+ }
180
+ // Poll for architect state and create PTY session once available
181
+ // start.ts writes architect to DB before spawning this server, but there can be a small delay
182
+ (async function waitForArchitectAndInit() {
183
+ for (let attempt = 0; attempt < 30; attempt++) {
184
+ await new Promise((resolve) => setTimeout(resolve, 500));
185
+ try {
186
+ const arch = getArchitect();
187
+ if (!arch)
188
+ continue;
189
+ if (arch.terminalId)
190
+ return; // Already has terminal
191
+ if (!arch.tmuxSession)
192
+ continue; // No tmux session yet
193
+ console.log(`initArchitectTerminal: attempt ${attempt + 1}, tmux=${arch.tmuxSession}`);
194
+ await initArchitectTerminal();
195
+ const updated = getArchitect();
196
+ if (updated?.terminalId) {
197
+ console.log(`initArchitectTerminal: success, terminalId=${updated.terminalId}`);
198
+ return;
199
+ }
200
+ console.log(`initArchitectTerminal: attempt ${attempt + 1} failed, terminalId still unset`);
201
+ }
202
+ catch (err) {
203
+ console.error(`initArchitectTerminal: attempt ${attempt + 1} error:`, err.message);
204
+ }
205
+ }
206
+ console.warn('initArchitectTerminal: gave up after 30 attempts');
207
+ })();
208
+ // Log telemetry
209
+ try {
210
+ const metricsPath = path.join(projectRoot, '.agent-farm', 'metrics.log');
211
+ fs.mkdirSync(path.dirname(metricsPath), { recursive: true });
212
+ fs.appendFileSync(metricsPath, JSON.stringify({
213
+ event: 'backend_selected',
214
+ backend: 'node-pty',
215
+ timestamp: new Date().toISOString(),
216
+ }) + '\n');
217
+ }
218
+ catch { /* ignore */ }
94
219
  // Clean up dead processes from state (called on state load)
95
220
  function cleanupDeadProcesses() {
96
221
  // Clean up dead shell processes
@@ -217,6 +342,9 @@ async function killProcessGracefully(pid, tmuxSession) {
217
342
  if (tmuxSession) {
218
343
  killTmuxSession(tmuxSession);
219
344
  }
345
+ // Guard: PID 0 sends signal to entire process group — never do that
346
+ if (!pid || pid <= 0)
347
+ return;
220
348
  try {
221
349
  // First try SIGTERM
222
350
  process.kill(pid, 'SIGTERM');
@@ -281,42 +409,24 @@ function tmuxSessionExists(sessionName) {
281
409
  return false;
282
410
  }
283
411
  }
284
- // Create a persistent tmux session and attach ttyd to it
285
- // Idempotent: if session exists, just spawn ttyd to attach to it
286
- function spawnTmuxWithTtyd(sessionName, shellCommand, ttydPort, cwd) {
412
+ // Create a PTY terminal session via the TerminalManager.
413
+ // Returns the terminal session ID, or null on failure.
414
+ async function createTerminalSession(shellCommand, cwd, label) {
415
+ if (!terminalManager)
416
+ return null;
287
417
  try {
288
- // Only create session if it doesn't exist (idempotent)
289
- if (!tmuxSessionExists(sessionName)) {
290
- // Create tmux session with the shell command
291
- execSync(`tmux new-session -d -s "${sessionName}" -x 200 -y 50 "${shellCommand}"`, { cwd, stdio: 'ignore' });
292
- // Hide the tmux status bar (dashboard has its own tabs)
293
- execSync(`tmux set-option -t "${sessionName}" status off`, { stdio: 'ignore' });
294
- // Enable mouse support in the session
295
- execSync(`tmux set-option -t "${sessionName}" -g mouse on`, { stdio: 'ignore' });
296
- // Enable OSC 52 clipboard (allows copy to browser clipboard via ttyd)
297
- execSync(`tmux set-option -t "${sessionName}" -g set-clipboard on`, { stdio: 'ignore' });
298
- // Enable passthrough for hyperlinks and clipboard
299
- execSync(`tmux set-option -t "${sessionName}" -g allow-passthrough on`, { stdio: 'ignore' });
300
- // Copy selection to clipboard when mouse is released
301
- // Use copy-pipe-and-cancel with pbcopy to directly copy to system clipboard
302
- // (OSC 52 via set-clipboard doesn't work reliably through ttyd/xterm.js)
303
- execSync(`tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"`, { stdio: 'ignore' });
304
- execSync(`tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"`, { stdio: 'ignore' });
305
- }
306
- // Start ttyd to attach to the tmux session
307
- const customIndexPath = findTemplatePath('ttyd-index.html');
308
- const ttydProcess = spawnTtyd({
309
- port: ttydPort,
310
- sessionName,
418
+ const info = await terminalManager.createSession({
419
+ command: '/bin/bash',
420
+ args: ['-c', shellCommand],
311
421
  cwd,
312
- customIndexPath: customIndexPath ?? undefined,
422
+ cols: 200,
423
+ rows: 50,
424
+ label,
313
425
  });
314
- return ttydProcess?.pid ?? null;
426
+ return info.id;
315
427
  }
316
428
  catch (err) {
317
- console.error(`Failed to create tmux session ${sessionName}:`, err.message);
318
- // Cleanup any partial session
319
- killTmuxSession(sessionName);
429
+ console.error(`Failed to create terminal session:`, err.message);
320
430
  return null;
321
431
  }
322
432
  }
@@ -336,7 +446,7 @@ function generateShortId() {
336
446
  * Spawn a worktree builder - creates git worktree and starts builder CLI
337
447
  * Similar to shell spawning but with git worktree isolation
338
448
  */
339
- function spawnWorktreeBuilder(builderPort, state) {
449
+ async function spawnWorktreeBuilder(builderPort, state) {
340
450
  const shortId = generateShortId();
341
451
  const builderId = `worktree-${shortId}`;
342
452
  const branchName = `builder/worktree-${shortId}`;
@@ -364,29 +474,10 @@ function spawnWorktreeBuilder(builderPort, state) {
364
474
  // Use default
365
475
  }
366
476
  }
367
- // Create tmux session with builder command
368
- execSync(`tmux new-session -d -s "${sessionName}" -x 200 -y 50 -c "${worktreePath}" "${builderCommand}"`, { cwd: worktreePath, stdio: 'ignore' });
369
- // Hide the tmux status bar (dashboard has its own tabs)
370
- execSync(`tmux set-option -t "${sessionName}" status off`, { stdio: 'ignore' });
371
- // Enable mouse support
372
- execSync(`tmux set-option -t "${sessionName}" -g mouse on`, { stdio: 'ignore' });
373
- execSync(`tmux set-option -t "${sessionName}" -g set-clipboard on`, { stdio: 'ignore' });
374
- execSync(`tmux set-option -t "${sessionName}" -g allow-passthrough on`, { stdio: 'ignore' });
375
- // Copy selection to clipboard when mouse is released (pbcopy for macOS)
376
- execSync(`tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"`, { stdio: 'ignore' });
377
- execSync(`tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"`, { stdio: 'ignore' });
378
- // Start ttyd connecting to the tmux session
379
- const customIndexPath = findTemplatePath('ttyd-index.html');
380
- const ttydProcess = spawnTtyd({
381
- port: builderPort,
382
- sessionName,
383
- cwd: worktreePath,
384
- customIndexPath: customIndexPath ?? undefined,
385
- });
386
- const pid = ttydProcess?.pid ?? null;
387
- if (!pid) {
477
+ // Create PTY terminal session via node-pty
478
+ const terminalId = await createTerminalSession(builderCommand, worktreePath, `builder-${builderId}`);
479
+ if (!terminalId) {
388
480
  // Cleanup on failure
389
- killTmuxSession(sessionName);
390
481
  try {
391
482
  execSync(`git worktree remove "${worktreePath}" --force`, { cwd: projectRoot, stdio: 'ignore' });
392
483
  execSync(`git branch -D "${branchName}"`, { cwd: projectRoot, stdio: 'ignore' });
@@ -399,16 +490,17 @@ function spawnWorktreeBuilder(builderPort, state) {
399
490
  const builder = {
400
491
  id: builderId,
401
492
  name: `Worktree ${shortId}`,
402
- port: builderPort,
403
- pid,
493
+ port: 0,
494
+ pid: 0,
404
495
  status: 'implementing',
405
496
  phase: 'interactive',
406
497
  worktree: worktreePath,
407
498
  branch: branchName,
408
499
  tmuxSession: sessionName,
409
500
  type: 'worktree',
501
+ terminalId,
410
502
  };
411
- return { builder, pid };
503
+ return { builder, pid: 0 };
412
504
  }
413
505
  catch (err) {
414
506
  console.error(`Failed to spawn worktree builder:`, err.message);
@@ -1045,6 +1137,12 @@ const server = http.createServer(async (req, res) => {
1045
1137
  }
1046
1138
  const url = new URL(req.url || '/', `http://localhost:${port}`);
1047
1139
  try {
1140
+ // Spec 0085: node-pty terminal manager REST API routes
1141
+ if (terminalManager && url.pathname.startsWith('/api/terminals')) {
1142
+ if (terminalManager.handleRequest(req, res)) {
1143
+ return;
1144
+ }
1145
+ }
1048
1146
  // API: Get state
1049
1147
  if (req.method === 'GET' && url.pathname === '/api/state') {
1050
1148
  const state = loadStateWithCleanup();
@@ -1154,14 +1252,12 @@ const server = http.createServer(async (req, res) => {
1154
1252
  // Find available port for builder
1155
1253
  const builderPort = await findAvailablePort(CONFIG.builderPortStart, builderState);
1156
1254
  // Spawn worktree builder
1157
- const result = spawnWorktreeBuilder(builderPort, builderState);
1255
+ const result = await spawnWorktreeBuilder(builderPort, builderState);
1158
1256
  if (!result) {
1159
1257
  res.writeHead(500, { 'Content-Type': 'text/plain' });
1160
1258
  res.end('Failed to spawn worktree builder');
1161
1259
  return;
1162
1260
  }
1163
- // Wait for ttyd to be ready
1164
- await new Promise((resolve) => setTimeout(resolve, 500));
1165
1261
  // Save builder to state
1166
1262
  upsertBuilder(result.builder);
1167
1263
  res.writeHead(201, { 'Content-Type': 'application/json' });
@@ -1294,49 +1390,25 @@ const server = http.createServer(async (req, res) => {
1294
1390
  const shellCommand = command
1295
1391
  ? `${shell} -c '${command.replace(/'/g, "'\\''")}; exec ${shell}'`
1296
1392
  : shell;
1297
- // Retry loop for concurrent port allocation race conditions
1298
- const MAX_PORT_RETRIES = 5;
1299
- let utilPort = null;
1300
- let pid = null;
1301
- for (let attempt = 0; attempt < MAX_PORT_RETRIES; attempt++) {
1302
- // Get fresh state on each attempt to see newly allocated ports
1303
- const currentState = loadState();
1304
- const candidatePort = await findAvailablePort(CONFIG.utilPortStart, currentState);
1305
- // Start tmux session with ttyd attached (use cwd which may be worktree)
1306
- const spawnedPid = spawnTmuxWithTtyd(sessionName, shellCommand, candidatePort, cwd);
1307
- if (!spawnedPid) {
1308
- res.writeHead(500, { 'Content-Type': 'text/plain' });
1309
- res.end('Failed to start shell');
1310
- return;
1311
- }
1312
- // Wait for ttyd to be ready
1313
- await new Promise((resolve) => setTimeout(resolve, 500));
1314
- // Try to add util record - may fail if port was taken by concurrent request
1315
- const util = {
1316
- id,
1317
- name: utilName,
1318
- port: candidatePort,
1319
- pid: spawnedPid,
1320
- tmuxSession: sessionName,
1321
- worktreePath: worktreePath, // Track for cleanup on tab close
1322
- };
1323
- if (tryAddUtil(util)) {
1324
- // Success - port reserved
1325
- utilPort = candidatePort;
1326
- pid = spawnedPid;
1327
- break;
1328
- }
1329
- // Port conflict - kill the spawned process and retry
1330
- console.log(`[info] Port ${candidatePort} conflict, retrying (attempt ${attempt + 1}/${MAX_PORT_RETRIES})`);
1331
- await killProcessGracefully(spawnedPid);
1332
- }
1333
- if (utilPort === null || pid === null) {
1393
+ // Create PTY terminal session via node-pty
1394
+ const terminalId = await createTerminalSession(shellCommand, cwd, `shell-${utilName}`);
1395
+ if (!terminalId) {
1334
1396
  res.writeHead(500, { 'Content-Type': 'text/plain' });
1335
- res.end('Failed to allocate port after multiple retries');
1397
+ res.end('Failed to create terminal session');
1336
1398
  return;
1337
1399
  }
1400
+ const util = {
1401
+ id,
1402
+ name: utilName,
1403
+ port: 0,
1404
+ pid: 0,
1405
+ tmuxSession: sessionName,
1406
+ worktreePath: worktreePath,
1407
+ terminalId,
1408
+ };
1409
+ addUtil(util);
1338
1410
  res.writeHead(201, { 'Content-Type': 'application/json' });
1339
- res.end(JSON.stringify({ success: true, id, port: utilPort, name: utilName }));
1411
+ res.end(JSON.stringify({ success: true, id, port: 0, name: utilName, terminalId }));
1340
1412
  return;
1341
1413
  }
1342
1414
  // API: Check if tab process is running (Bugfix #132)
@@ -1357,8 +1429,7 @@ const server = http.createServer(async (req, res) => {
1357
1429
  const util = tabUtils.find((u) => u.id === utilId);
1358
1430
  if (util) {
1359
1431
  found = true;
1360
- // Check tmux session status instead of ttyd PID (Spec 0076)
1361
- // ttyd stays alive after shell exits, so checking its PID is wrong
1432
+ // Check tmux session status (Spec 0076)
1362
1433
  if (util.tmuxSession) {
1363
1434
  running = tmuxSessionExists(util.tmuxSession);
1364
1435
  }
@@ -1374,7 +1445,7 @@ const server = http.createServer(async (req, res) => {
1374
1445
  const builder = getBuilder(builderId);
1375
1446
  if (builder) {
1376
1447
  found = true;
1377
- // Check tmux session status instead of ttyd PID (Spec 0076)
1448
+ // Check tmux session status (Spec 0076)
1378
1449
  if (builder.tmuxSession) {
1379
1450
  running = tmuxSessionExists(builder.tmuxSession);
1380
1451
  }
@@ -1425,6 +1496,10 @@ const server = http.createServer(async (req, res) => {
1425
1496
  const tabUtils = getUtils();
1426
1497
  const util = tabUtils.find((u) => u.id === utilId);
1427
1498
  if (util) {
1499
+ // Kill PTY session if present
1500
+ if (util.terminalId && terminalManager) {
1501
+ terminalManager.killSession(util.terminalId);
1502
+ }
1428
1503
  await killProcessGracefully(util.pid, util.tmuxSession);
1429
1504
  // Note: worktrees are NOT cleaned up on tab close - they may contain useful context
1430
1505
  // Users can manually clean up with `git worktree list` and `git worktree remove`
@@ -1881,7 +1956,7 @@ const server = http.createServer(async (req, res) => {
1881
1956
  return;
1882
1957
  }
1883
1958
  // Terminal proxy route (Spec 0062 - Secure Remote Access)
1884
- // Routes /terminal/:id to the appropriate ttyd instance
1959
+ // Routes /terminal/:id to the appropriate terminal instance
1885
1960
  const terminalMatch = url.pathname.match(/^\/terminal\/([^/]+)(\/.*)?$/);
1886
1961
  if (terminalMatch) {
1887
1962
  const terminalId = terminalMatch[1];
@@ -1914,8 +1989,49 @@ const server = http.createServer(async (req, res) => {
1914
1989
  terminalProxy.web(req, res, { target: `http://localhost:${annotation.port}` });
1915
1990
  return;
1916
1991
  }
1917
- // Serve dashboard
1918
- if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
1992
+ // Serve dashboard (Spec 0085: React or legacy based on config)
1993
+ if (useReactDashboard && req.method === 'GET') {
1994
+ // Serve React dashboard static files
1995
+ const filePath = url.pathname === '/' || url.pathname === '/index.html'
1996
+ ? path.join(reactDashboardPath, 'index.html')
1997
+ : path.join(reactDashboardPath, url.pathname);
1998
+ // Security: Prevent path traversal
1999
+ const resolved = path.resolve(filePath);
2000
+ if (!resolved.startsWith(reactDashboardPath)) {
2001
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
2002
+ res.end('Forbidden');
2003
+ return;
2004
+ }
2005
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
2006
+ const ext = path.extname(resolved);
2007
+ const mimeTypes = {
2008
+ '.html': 'text/html; charset=utf-8',
2009
+ '.js': 'application/javascript',
2010
+ '.css': 'text/css',
2011
+ '.json': 'application/json',
2012
+ '.svg': 'image/svg+xml',
2013
+ '.png': 'image/png',
2014
+ '.ico': 'image/x-icon',
2015
+ '.map': 'application/json',
2016
+ };
2017
+ const contentType = mimeTypes[ext] ?? 'application/octet-stream';
2018
+ // Cache static assets (hashed filenames) but not index.html
2019
+ if (ext !== '.html') {
2020
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
2021
+ }
2022
+ res.writeHead(200, { 'Content-Type': contentType });
2023
+ fs.createReadStream(resolved).pipe(res);
2024
+ return;
2025
+ }
2026
+ // SPA fallback: serve index.html for client-side routing
2027
+ if (!url.pathname.startsWith('/api/') && !url.pathname.startsWith('/ws/') && !url.pathname.startsWith('/terminal/') && !url.pathname.startsWith('/annotation/')) {
2028
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
2029
+ fs.createReadStream(path.join(reactDashboardPath, 'index.html')).pipe(res);
2030
+ return;
2031
+ }
2032
+ }
2033
+ if (!useReactDashboard && req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
2034
+ // Legacy vanilla JS dashboard
1919
2035
  try {
1920
2036
  let template = fs.readFileSync(templatePath, 'utf-8');
1921
2037
  const state = loadStateWithCleanup();
@@ -1944,8 +2060,12 @@ const server = http.createServer(async (req, res) => {
1944
2060
  res.end('Internal server error: ' + err.message);
1945
2061
  }
1946
2062
  });
2063
+ // Spec 0085: Attach node-pty WebSocket handler for /ws/terminal/:id routes
2064
+ if (terminalManager) {
2065
+ terminalManager.attachWebSocket(server);
2066
+ }
1947
2067
  // WebSocket upgrade handler for terminal proxy (Spec 0062)
1948
- // ttyd uses WebSocket for bidirectional terminal communication
2068
+ // WebSocket for bidirectional terminal communication
1949
2069
  server.on('upgrade', (req, socket, head) => {
1950
2070
  // Security check for non-auth mode
1951
2071
  const host = req.headers.host;
@@ -2017,4 +2137,17 @@ else {
2017
2137
  console.log(`Dashboard: http://localhost:${port}`);
2018
2138
  });
2019
2139
  }
2140
+ // Spec 0085: Graceful shutdown for node-pty terminal manager
2141
+ process.on('SIGTERM', () => {
2142
+ if (terminalManager) {
2143
+ terminalManager.shutdown();
2144
+ }
2145
+ process.exit(0);
2146
+ });
2147
+ process.on('SIGINT', () => {
2148
+ if (terminalManager) {
2149
+ terminalManager.shutdown();
2150
+ }
2151
+ process.exit(0);
2152
+ });
2020
2153
  //# sourceMappingURL=dashboard-server.js.map