@cluesmith/codev 2.0.0-rc.64 → 2.0.0-rc.69

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 (59) hide show
  1. package/dashboard/dist/assets/{index-DZuzzh0T.js → index-CG7nUttd.js} +22 -22
  2. package/dashboard/dist/assets/index-CG7nUttd.js.map +1 -0
  3. package/dashboard/dist/index.html +1 -1
  4. package/dist/agent-farm/cli.d.ts.map +1 -1
  5. package/dist/agent-farm/cli.js +4 -1
  6. package/dist/agent-farm/cli.js.map +1 -1
  7. package/dist/agent-farm/commands/architect.d.ts.map +1 -1
  8. package/dist/agent-farm/commands/architect.js +4 -6
  9. package/dist/agent-farm/commands/architect.js.map +1 -1
  10. package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
  11. package/dist/agent-farm/commands/spawn.js +54 -6
  12. package/dist/agent-farm/commands/spawn.js.map +1 -1
  13. package/dist/agent-farm/commands/tower-cloud.d.ts +1 -0
  14. package/dist/agent-farm/commands/tower-cloud.d.ts.map +1 -1
  15. package/dist/agent-farm/commands/tower-cloud.js +9 -8
  16. package/dist/agent-farm/commands/tower-cloud.js.map +1 -1
  17. package/dist/agent-farm/db/index.d.ts.map +1 -1
  18. package/dist/agent-farm/db/index.js +18 -0
  19. package/dist/agent-farm/db/index.js.map +1 -1
  20. package/dist/agent-farm/lib/cloud-config.d.ts +13 -0
  21. package/dist/agent-farm/lib/cloud-config.d.ts.map +1 -1
  22. package/dist/agent-farm/lib/cloud-config.js +38 -1
  23. package/dist/agent-farm/lib/cloud-config.js.map +1 -1
  24. package/dist/agent-farm/lib/tunnel-client.d.ts.map +1 -1
  25. package/dist/agent-farm/lib/tunnel-client.js +15 -6
  26. package/dist/agent-farm/lib/tunnel-client.js.map +1 -1
  27. package/dist/agent-farm/servers/tower-server.js +166 -138
  28. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  29. package/dist/agent-farm/utils/session.d.ts +22 -0
  30. package/dist/agent-farm/utils/session.d.ts.map +1 -1
  31. package/dist/agent-farm/utils/session.js +45 -0
  32. package/dist/agent-farm/utils/session.js.map +1 -1
  33. package/dist/commands/consult/index.d.ts +10 -2
  34. package/dist/commands/consult/index.d.ts.map +1 -1
  35. package/dist/commands/consult/index.js +133 -37
  36. package/dist/commands/consult/index.js.map +1 -1
  37. package/dist/commands/doctor.d.ts.map +1 -1
  38. package/dist/commands/doctor.js +96 -52
  39. package/dist/commands/doctor.js.map +1 -1
  40. package/dist/commands/porch/index.d.ts +4 -0
  41. package/dist/commands/porch/index.d.ts.map +1 -1
  42. package/dist/commands/porch/index.js +40 -11
  43. package/dist/commands/porch/index.js.map +1 -1
  44. package/dist/commands/porch/state.d.ts +18 -0
  45. package/dist/commands/porch/state.d.ts.map +1 -1
  46. package/dist/commands/porch/state.js +41 -2
  47. package/dist/commands/porch/state.js.map +1 -1
  48. package/package.json +2 -1
  49. package/skeleton/protocols/bugfix/builder-prompt.md +3 -3
  50. package/skeleton/protocols/bugfix/prompts/pr.md +8 -4
  51. package/skeleton/protocols/bugfix/protocol.json +2 -32
  52. package/skeleton/protocols/experiment/builder-prompt.md +1 -1
  53. package/skeleton/protocols/maintain/builder-prompt.md +1 -1
  54. package/skeleton/protocols/spir/builder-prompt.md +1 -1
  55. package/skeleton/protocols/tick/builder-prompt.md +1 -1
  56. package/skeleton/protocols/tick/protocol.json +1 -1
  57. package/skeleton/roles/builder.md +9 -8
  58. package/templates/tower.html +275 -41
  59. package/dashboard/dist/assets/index-DZuzzh0T.js.map +0 -1
@@ -21,6 +21,7 @@ import { TerminalManager } from '../../terminal/pty-manager.js';
21
21
  import { encodeData, encodeControl, decodeFrame } from '../../terminal/ws-protocol.js';
22
22
  import { TunnelClient } from '../lib/tunnel-client.js';
23
23
  import { readCloudConfig, getCloudConfigPath, maskApiKey } from '../lib/cloud-config.js';
24
+ import { parseTmuxSessionName } from '../utils/session.js';
24
25
  const __filename = fileURLToPath(import.meta.url);
25
26
  const __dirname = path.dirname(__filename);
26
27
  // Default port for tower dashboard
@@ -456,14 +457,16 @@ function createTmuxSession(sessionName, command, args, cwd, cols, rows) {
456
457
  killTmuxSession(sessionName);
457
458
  }
458
459
  try {
459
- // Use spawnSync with array args to avoid shell injection via project paths
460
+ // Use spawnSync with array args to avoid shell injection via project paths.
461
+ // Wrap command with `env -u CLAUDECODE` to prevent Claude from detecting
462
+ // a nested session when the Tower was started from within Claude Code.
460
463
  const tmuxArgs = [
461
464
  'new-session', '-d',
462
465
  '-s', sessionName,
463
466
  '-c', cwd,
464
467
  '-x', String(cols),
465
468
  '-y', String(rows),
466
- command, ...args,
469
+ 'env', '-u', 'CLAUDECODE', command, ...args,
467
470
  ];
468
471
  const result = spawnSync('tmux', tmuxArgs, { stdio: 'ignore' });
469
472
  if (result.status !== 0) {
@@ -476,7 +479,17 @@ function createTmuxSession(sessionName, command, args, cwd, cols, rows) {
476
479
  // events during layout settling. Default tmux behavior (size to smallest
477
480
  // client) is more stable since we only have one client per session.
478
481
  spawnSync('tmux', ['set-option', '-t', sessionName, 'status', 'off'], { stdio: 'ignore' });
479
- spawnSync('tmux', ['set-option', '-t', sessionName, 'mouse', 'on'], { stdio: 'ignore' });
482
+ // Mouse OFF xterm.js in the browser handles selection and Cmd+C/Cmd+V
483
+ // clipboard. tmux mouse mode conflicts (auto-copy on selection, intercepts
484
+ // click/drag). See codev/resources/terminal-tmux.md.
485
+ spawnSync('tmux', ['set-option', '-t', sessionName, 'mouse', 'off'], { stdio: 'ignore' });
486
+ // Alternate screen OFF — without this, tmux puts xterm.js into alternate
487
+ // buffer which has no scrollback. xterm.js then translates wheel events
488
+ // to arrow keys (cycling command history). With alternate-screen off,
489
+ // tmux writes to the normal buffer and xterm.js native scroll works.
490
+ spawnSync('tmux', ['set-option', '-t', sessionName, 'alternate-screen', 'off'], { stdio: 'ignore' });
491
+ // Unset CLAUDECODE so spawned Claude processes don't detect a nested session
492
+ spawnSync('tmux', ['set-environment', '-t', sessionName, '-u', 'CLAUDECODE'], { stdio: 'ignore' });
480
493
  return sessionName;
481
494
  }
482
495
  catch (err) {
@@ -522,36 +535,14 @@ function killTmuxSession(sessionName) {
522
535
  // Session may have already died
523
536
  }
524
537
  }
525
- /**
526
- * Parse a codev tmux session name to extract type, project, and role.
527
- * Returns null if the name doesn't match any known codev pattern.
528
- *
529
- * Examples:
530
- * "architect-codev-public" → { type: 'architect', projectBasename: 'codev-public', roleId: null }
531
- * "builder-codevos_ai-0001" → { type: 'builder', projectBasename: 'codevos_ai', roleId: '0001' }
532
- * "shell-codev-public-shell-1" → { type: 'shell', projectBasename: 'codev-public', roleId: 'shell-1' }
533
- */
534
- function parseTmuxSessionName(name) {
535
- // architect-{basename}
536
- const architectMatch = name.match(/^architect-(.+)$/);
537
- if (architectMatch) {
538
- return { type: 'architect', projectBasename: architectMatch[1], roleId: null };
539
- }
540
- // builder-{basename}-{specId} — specId is always the last segment (digits like "0001")
541
- const builderMatch = name.match(/^builder-(.+)-(\d{4,})$/);
542
- if (builderMatch) {
543
- return { type: 'builder', projectBasename: builderMatch[1], roleId: builderMatch[2] };
544
- }
545
- // shell-{basename}-{shellId} — shellId is "shell-N" (last two segments)
546
- const shellMatch = name.match(/^shell-(.+)-(shell-\d+)$/);
547
- if (shellMatch) {
548
- return { type: 'shell', projectBasename: shellMatch[1], roleId: shellMatch[2] };
549
- }
550
- return null;
551
- }
538
+ // ============================================================================
539
+ // Tmux-First Discovery (tmux is source of truth for existence)
540
+ // ============================================================================
552
541
  /**
553
542
  * List all tmux sessions that match codev naming conventions.
554
543
  * Returns an array of { tmuxName, parsed } for each matching session.
544
+ * Sessions with recognized prefixes (architect-, builder-, shell-) but
545
+ * unparseable ID formats are included with parsed: null for SQLite lookup.
555
546
  */
556
547
  // Cache for listCodevTmuxSessions — avoid shelling out on every dashboard poll
557
548
  let _tmuxListCache = [];
@@ -573,6 +564,10 @@ function listCodevTmuxSessions(bypassCache = false) {
573
564
  if (parsed) {
574
565
  codevSessions.push({ tmuxName: name, parsed });
575
566
  }
567
+ else if (/^(?:architect|builder|shell)-/.test(name)) {
568
+ // Recognized codev prefix but unparseable ID format — include for SQLite lookup
569
+ codevSessions.push({ tmuxName: name, parsed: null });
570
+ }
576
571
  }
577
572
  _tmuxListCache = codevSessions;
578
573
  _tmuxListCacheTime = now;
@@ -655,11 +650,11 @@ async function reconcileTerminalSessions() {
655
650
  const dbRow = findSqliteRowForTmuxSession(tmuxName);
656
651
  matchedTmuxNames.add(tmuxName);
657
652
  // Determine metadata — prefer SQLite, fall back to parsed name
658
- const projectPath = dbRow?.project_path || resolveProjectPathFromBasename(parsed.projectBasename);
659
- const type = dbRow?.type || parsed.type;
660
- const roleId = dbRow?.role_id || parsed.roleId;
661
- if (!projectPath) {
662
- log('WARN', `Cannot resolve project path for tmux session "${tmuxName}" (basename: ${parsed.projectBasename}) — skipping`);
653
+ const projectPath = dbRow?.project_path || (parsed && resolveProjectPathFromBasename(parsed.projectBasename));
654
+ const type = (dbRow?.type || parsed?.type);
655
+ const roleId = dbRow?.role_id ?? parsed?.roleId ?? null;
656
+ if (!projectPath || !type) {
657
+ log('WARN', `Cannot resolve ${!projectPath ? 'project path' : 'type'} for tmux session "${tmuxName}"${parsed ? ` (basename: ${parsed.projectBasename})` : ''} — skipping`);
663
658
  continue;
664
659
  }
665
660
  // Skip sessions whose project path doesn't exist on disk or is in a
@@ -703,6 +698,10 @@ async function reconcileTerminalSessions() {
703
698
  db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbRow.id);
704
699
  }
705
700
  saveTerminalSession(newSession.id, projectPath, type, roleId, newSession.pid, tmuxName);
701
+ registerKnownProject(projectPath);
702
+ // Ensure correct tmux options on reconnected sessions
703
+ spawnSync('tmux', ['set-option', '-t', tmuxName, 'mouse', 'off'], { stdio: 'ignore' });
704
+ spawnSync('tmux', ['set-option', '-t', tmuxName, 'alternate-screen', 'off'], { stdio: 'ignore' });
706
705
  if (dbRow) {
707
706
  log('INFO', `Reconnected tmux "${tmuxName}" → terminal ${newSession.id} (${type} for ${path.basename(projectPath)})`);
708
707
  reconnected++;
@@ -935,11 +934,39 @@ if (isNaN(port) || port < 1 || port > 65535) {
935
934
  }
936
935
  log('INFO', `Tower server starting on port ${port}`);
937
936
  /**
938
- * Get all known project paths from terminal_sessions and in-memory cache
937
+ * Register a project in the known_projects table so it persists across restarts
938
+ * even when all terminal sessions are gone.
939
+ */
940
+ function registerKnownProject(projectPath) {
941
+ try {
942
+ const db = getGlobalDb();
943
+ db.prepare(`
944
+ INSERT INTO known_projects (project_path, name, last_launched_at)
945
+ VALUES (?, ?, datetime('now'))
946
+ ON CONFLICT(project_path) DO UPDATE SET last_launched_at = datetime('now')
947
+ `).run(projectPath, path.basename(projectPath));
948
+ }
949
+ catch {
950
+ // Table may not exist yet (pre-migration)
951
+ }
952
+ }
953
+ /**
954
+ * Get all known project paths from known_projects, terminal_sessions, and in-memory cache
939
955
  */
940
956
  function getKnownProjectPaths() {
941
957
  const projectPaths = new Set();
942
- // From terminal_sessions table (persists across Tower restarts)
958
+ // From known_projects table (persists even after all terminals are killed)
959
+ try {
960
+ const db = getGlobalDb();
961
+ const projects = db.prepare('SELECT project_path FROM known_projects').all();
962
+ for (const p of projects) {
963
+ projectPaths.add(p.project_path);
964
+ }
965
+ }
966
+ catch {
967
+ // Table may not exist yet
968
+ }
969
+ // From terminal_sessions table (catches any missed by known_projects)
943
970
  try {
944
971
  const db = getGlobalDb();
945
972
  const sessions = db.prepare('SELECT DISTINCT project_path FROM terminal_sessions').all();
@@ -1141,6 +1168,9 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
1141
1168
  const projectBasename = sanitizeTmuxSessionName(path.basename(normalizedPath));
1142
1169
  const liveTmux = listCodevTmuxSessions();
1143
1170
  for (const { tmuxName, parsed } of liveTmux) {
1171
+ // Skip sessions we couldn't fully parse (no projectBasename to match)
1172
+ if (!parsed)
1173
+ continue;
1144
1174
  // Only process sessions whose sanitized project basename matches
1145
1175
  if (parsed.projectBasename !== projectBasename)
1146
1176
  continue;
@@ -1151,9 +1181,7 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
1151
1181
  if (alreadyRegistered)
1152
1182
  continue;
1153
1183
  // Orphaned tmux session — reconnect it.
1154
- // Skip architect sessions: launchInstance handles architect creation/reconnection
1155
- // and has its own exit handler for auto-restart. Reconnecting here races with
1156
- // the restart logic and can attach to a dead tmux session.
1184
+ // Skip architect sessions: launchInstance handles creation/reconnection.
1157
1185
  if (parsed.type === 'architect')
1158
1186
  continue;
1159
1187
  try {
@@ -1351,6 +1379,8 @@ async function launchInstance(projectPath) {
1351
1379
  try {
1352
1380
  // Ensure project has port allocation
1353
1381
  const resolvedPath = fs.realpathSync(projectPath);
1382
+ // Persist in known_projects so the project survives terminal cleanup
1383
+ registerKnownProject(resolvedPath);
1354
1384
  // Initialize project terminal entry
1355
1385
  const entry = getProjectTerminalsEntry(resolvedPath);
1356
1386
  // Create architect terminal if not already present
@@ -1390,12 +1420,17 @@ async function launchInstance(projectPath) {
1390
1420
  log('INFO', `Reconnecting to existing tmux session "${sanitizedTmuxName}" for architect`);
1391
1421
  }
1392
1422
  else {
1393
- const createdName = createTmuxSession(tmuxName, cmd, cmdArgs, projectPath, 200, 50);
1423
+ // Wrap architect in a restart loop inside tmux so it auto-restarts
1424
+ // when the user exits Claude Code (e.g., /exit). The loop runs
1425
+ // inside tmux itself, independent of Tower's node-pty exit handler.
1426
+ const innerCmd = [cmd, ...cmdArgs].map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' ');
1427
+ const loopCmd = `while true; do ${innerCmd}; echo "Architect exited. Restarting in 2s..."; sleep 2; done`;
1428
+ const createdName = createTmuxSession(tmuxName, 'sh', ['-c', loopCmd], projectPath, 200, 50);
1394
1429
  if (createdName) {
1395
1430
  cmd = 'tmux';
1396
1431
  cmdArgs = ['attach-session', '-t', createdName];
1397
1432
  activeTmuxSession = createdName;
1398
- log('INFO', `Created tmux session "${createdName}" for architect`);
1433
+ log('INFO', `Created tmux session "${createdName}" for architect (with restart loop)`);
1399
1434
  }
1400
1435
  }
1401
1436
  }
@@ -1409,49 +1444,17 @@ async function launchInstance(projectPath) {
1409
1444
  entry.architect = session.id;
1410
1445
  // TICK-001: Save to SQLite for persistence (with tmux session name)
1411
1446
  saveTerminalSession(session.id, resolvedPath, 'architect', null, session.pid, activeTmuxSession);
1412
- // Auto-restart architect on exit
1447
+ // Clean up cache/SQLite when the node-pty session exits.
1448
+ // Restart is handled by the while-true loop inside tmux (not here).
1413
1449
  const ptySession = manager.getSession(session.id);
1414
1450
  if (ptySession) {
1415
- const startedAt = Date.now();
1416
1451
  ptySession.on('exit', () => {
1417
- // Re-read entry from the Map — getTerminalsForProject() periodically
1418
- // replaces the Map entry with a fresh object, so the `entry` captured
1419
- // in the closure may be stale.
1420
1452
  const currentEntry = getProjectTerminalsEntry(resolvedPath);
1421
1453
  if (currentEntry.architect === session.id) {
1422
1454
  currentEntry.architect = undefined;
1423
1455
  }
1424
1456
  deleteTerminalSession(session.id);
1425
- // Check if the tmux session's inner process is still alive.
1426
- // The node-pty process is `tmux attach` — it exits on disconnect
1427
- // timeout, but the tmux session (and the architect process inside
1428
- // it) may still be running. Only kill tmux if the inner process
1429
- // has also exited (e.g., user typed "exit" or process crashed).
1430
- const tmuxAlive = activeTmuxSession && tmuxSessionExists(activeTmuxSession);
1431
- if (activeTmuxSession && !tmuxAlive) {
1432
- log('INFO', `Tmux session "${activeTmuxSession}" already gone for ${projectPath}`);
1433
- }
1434
- else if (tmuxAlive) {
1435
- log('INFO', `Tmux session "${activeTmuxSession}" still alive for ${projectPath}, preserving for reconnect`);
1436
- }
1437
- // Only restart if the architect ran for at least 5s (prevents crash loops)
1438
- const uptime = Date.now() - startedAt;
1439
- if (uptime < 5000) {
1440
- log('INFO', `Architect exited after ${uptime}ms for ${projectPath}, not restarting (too short)`);
1441
- return;
1442
- }
1443
- // Kill the stale tmux session so launchInstance creates a fresh one
1444
- // instead of reconnecting to the dead session.
1445
- if (activeTmuxSession && tmuxSessionExists(activeTmuxSession)) {
1446
- killTmuxSession(activeTmuxSession);
1447
- log('INFO', `Killed stale tmux session "${activeTmuxSession}" before restart`);
1448
- }
1449
- log('INFO', `Architect exited for ${projectPath}, restarting in 2s...`);
1450
- setTimeout(() => {
1451
- launchInstance(projectPath).catch((err) => {
1452
- log('WARN', `Failed to restart architect for ${projectPath}: ${err.message}`);
1453
- });
1454
- }, 2000);
1457
+ log('INFO', `Architect pty exited for ${projectPath} (tmux loop handles restart)`);
1455
1458
  });
1456
1459
  }
1457
1460
  log('INFO', `Created architect terminal for project: ${projectPath}`);
@@ -1598,6 +1601,73 @@ function serveStaticFile(filePath, res) {
1598
1601
  return false;
1599
1602
  }
1600
1603
  }
1604
+ /**
1605
+ * Handle tunnel management endpoints (Spec 0097 Phase 4).
1606
+ * Extracted so both /api/tunnel/* and /project/<encoded>/api/tunnel/* can use it.
1607
+ */
1608
+ async function handleTunnelEndpoint(req, res, tunnelSub) {
1609
+ // POST connect
1610
+ if (req.method === 'POST' && tunnelSub === 'connect') {
1611
+ try {
1612
+ const config = readCloudConfig();
1613
+ if (!config) {
1614
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1615
+ res.end(JSON.stringify({ success: false, error: 'Not registered. Run \'af tower register\' first.' }));
1616
+ return;
1617
+ }
1618
+ if (tunnelClient)
1619
+ tunnelClient.resetCircuitBreaker();
1620
+ const client = await connectTunnel(config);
1621
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1622
+ res.end(JSON.stringify({ success: true, state: client.getState() }));
1623
+ }
1624
+ catch (err) {
1625
+ log('ERROR', `Tunnel connect failed: ${err.message}`);
1626
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1627
+ res.end(JSON.stringify({ success: false, error: err.message }));
1628
+ }
1629
+ return;
1630
+ }
1631
+ // POST disconnect
1632
+ if (req.method === 'POST' && tunnelSub === 'disconnect') {
1633
+ if (tunnelClient) {
1634
+ tunnelClient.disconnect();
1635
+ tunnelClient = null;
1636
+ }
1637
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1638
+ res.end(JSON.stringify({ success: true }));
1639
+ return;
1640
+ }
1641
+ // GET status
1642
+ if (req.method === 'GET' && tunnelSub === 'status') {
1643
+ let config = null;
1644
+ try {
1645
+ config = readCloudConfig();
1646
+ }
1647
+ catch {
1648
+ // Config file may be corrupted — treat as unregistered
1649
+ }
1650
+ const state = tunnelClient?.getState() ?? 'disconnected';
1651
+ const uptime = tunnelClient?.getUptime() ?? null;
1652
+ const response = {
1653
+ registered: config !== null,
1654
+ state,
1655
+ uptime,
1656
+ };
1657
+ if (config) {
1658
+ response.towerId = config.tower_id;
1659
+ response.towerName = config.tower_name;
1660
+ response.serverUrl = config.server_url;
1661
+ response.accessUrl = `${config.server_url}/t/${config.tower_name}/`;
1662
+ }
1663
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1664
+ res.end(JSON.stringify(response));
1665
+ return;
1666
+ }
1667
+ // Unknown tunnel endpoint
1668
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1669
+ res.end(JSON.stringify({ error: 'Not found' }));
1670
+ }
1601
1671
  // Create server
1602
1672
  const server = http.createServer(async (req, res) => {
1603
1673
  // Security: Validate Host and Origin headers
@@ -1606,13 +1676,15 @@ const server = http.createServer(async (req, res) => {
1606
1676
  res.end('Forbidden');
1607
1677
  return;
1608
1678
  }
1609
- // CORS headers
1679
+ // CORS headers — allow localhost and tunnel proxy origins
1610
1680
  const origin = req.headers.origin;
1611
- if (origin && (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:'))) {
1681
+ if (origin && (origin.startsWith('http://localhost:') ||
1682
+ origin.startsWith('http://127.0.0.1:') ||
1683
+ origin.startsWith('https://'))) {
1612
1684
  res.setHeader('Access-Control-Allow-Origin', origin);
1613
1685
  }
1614
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
1615
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
1686
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
1687
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
1616
1688
  res.setHeader('Cache-Control', 'no-store');
1617
1689
  if (req.method === 'OPTIONS') {
1618
1690
  res.writeHead(200);
@@ -1641,64 +1713,11 @@ const server = http.createServer(async (req, res) => {
1641
1713
  }
1642
1714
  // =========================================================================
1643
1715
  // Tunnel Management Endpoints (Spec 0097 Phase 4)
1716
+ // Also reachable from /project/<encoded>/api/tunnel/* (see project router)
1644
1717
  // =========================================================================
1645
- // POST /api/tunnel/connect — Connect or reconnect tunnel to codevos.ai
1646
- if (req.method === 'POST' && url.pathname === '/api/tunnel/connect') {
1647
- try {
1648
- const config = readCloudConfig();
1649
- if (!config) {
1650
- res.writeHead(400, { 'Content-Type': 'application/json' });
1651
- res.end(JSON.stringify({ success: false, error: 'Not registered. Run \'af tower register\' first.' }));
1652
- return;
1653
- }
1654
- // Reset circuit breaker if in auth_failed state
1655
- if (tunnelClient)
1656
- tunnelClient.resetCircuitBreaker();
1657
- const client = await connectTunnel(config);
1658
- res.writeHead(200, { 'Content-Type': 'application/json' });
1659
- res.end(JSON.stringify({ success: true, state: client.getState() }));
1660
- }
1661
- catch (err) {
1662
- log('ERROR', `Tunnel connect failed: ${err.message}`);
1663
- res.writeHead(500, { 'Content-Type': 'application/json' });
1664
- res.end(JSON.stringify({ success: false, error: err.message }));
1665
- }
1666
- return;
1667
- }
1668
- // POST /api/tunnel/disconnect — Disconnect tunnel from codevos.ai
1669
- if (req.method === 'POST' && url.pathname === '/api/tunnel/disconnect') {
1670
- if (tunnelClient) {
1671
- tunnelClient.disconnect();
1672
- tunnelClient = null;
1673
- }
1674
- res.writeHead(200, { 'Content-Type': 'application/json' });
1675
- res.end(JSON.stringify({ success: true }));
1676
- return;
1677
- }
1678
- // GET /api/tunnel/status — Return tunnel connection status
1679
- if (req.method === 'GET' && url.pathname === '/api/tunnel/status') {
1680
- let config = null;
1681
- try {
1682
- config = readCloudConfig();
1683
- }
1684
- catch {
1685
- // Config file may be corrupted — treat as unregistered
1686
- }
1687
- const state = tunnelClient?.getState() ?? 'disconnected';
1688
- const uptime = tunnelClient?.getUptime() ?? null;
1689
- const response = {
1690
- registered: config !== null,
1691
- state,
1692
- uptime,
1693
- };
1694
- if (config) {
1695
- response.towerId = config.tower_id;
1696
- response.towerName = config.tower_name;
1697
- response.serverUrl = config.server_url;
1698
- response.accessUrl = `${config.server_url}/t/${config.tower_name}/`;
1699
- }
1700
- res.writeHead(200, { 'Content-Type': 'application/json' });
1701
- res.end(JSON.stringify(response));
1718
+ if (url.pathname.startsWith('/api/tunnel/')) {
1719
+ const tunnelSub = url.pathname.slice('/api/tunnel/'.length);
1720
+ await handleTunnelEndpoint(req, res, tunnelSub);
1702
1721
  return;
1703
1722
  }
1704
1723
  // API: List all projects (Spec 0090 Phase 1)
@@ -2167,6 +2186,15 @@ const server = http.createServer(async (req, res) => {
2167
2186
  // Phase 4 (Spec 0090): Tower handles everything directly
2168
2187
  const isApiCall = subPath.startsWith('api/') || subPath === 'api';
2169
2188
  const isWsPath = subPath.startsWith('ws/') || subPath === 'ws';
2189
+ // Tunnel endpoints are tower-level, not project-scoped, but the React
2190
+ // dashboard uses relative paths (./api/tunnel/...) which resolve to
2191
+ // /project/<encoded>/api/tunnel/... in project context. Handle here by
2192
+ // extracting the tunnel sub-path and dispatching to handleTunnelEndpoint().
2193
+ if (subPath.startsWith('api/tunnel/')) {
2194
+ const tunnelSub = subPath.slice('api/tunnel/'.length); // e.g. "status", "connect", "disconnect"
2195
+ await handleTunnelEndpoint(req, res, tunnelSub);
2196
+ return;
2197
+ }
2170
2198
  // GET /file?path=<relative-path> — Read project file by path (for StatusPanel project list)
2171
2199
  if (req.method === 'GET' && subPath === 'file' && url.searchParams.has('path')) {
2172
2200
  const relPath = url.searchParams.get('path');