@cluesmith/codev 2.0.0-rc.60 → 2.0.0-rc.61

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.
@@ -218,11 +218,23 @@ function checkTmux() {
218
218
  return false;
219
219
  }
220
220
  }
221
+ /**
222
+ * Sanitize a tmux session name to match what tmux actually creates.
223
+ * tmux replaces dots with underscores and strips colons from session names.
224
+ * Without this, stored names won't match actual tmux session names,
225
+ * causing reconnection to fail (e.g., "builder-codevos.ai-0001" vs "builder-codevos_ai-0001").
226
+ */
227
+ function sanitizeTmuxSessionName(name) {
228
+ return name.replace(/\./g, '_').replace(/:/g, '');
229
+ }
221
230
  /**
222
231
  * Create a tmux session with the given command.
223
- * Returns true if created successfully, false on failure.
232
+ * Returns the sanitized session name if created successfully, null on failure.
233
+ * Session names are sanitized to match tmux behavior (dots → underscores, colons stripped).
224
234
  */
225
235
  function createTmuxSession(sessionName, command, args, cwd, cols, rows) {
236
+ // Sanitize to match what tmux actually creates (dots → underscores, colons stripped)
237
+ sessionName = sanitizeTmuxSessionName(sessionName);
226
238
  // Kill any stale session with this name
227
239
  if (tmuxSessionExists(sessionName)) {
228
240
  killTmuxSession(sessionName);
@@ -240,26 +252,28 @@ function createTmuxSession(sessionName, command, args, cwd, cols, rows) {
240
252
  const result = spawnSync('tmux', tmuxArgs, { stdio: 'ignore' });
241
253
  if (result.status !== 0) {
242
254
  log('WARN', `tmux new-session exited with code ${result.status} for "${sessionName}"`);
243
- return false;
255
+ return null;
244
256
  }
245
257
  // Hide tmux status bar (dashboard has its own tabs), enable mouse, and
246
258
  // use aggressive-resize so tmux sizes to the largest client (not smallest)
247
259
  spawnSync('tmux', ['set-option', '-t', sessionName, 'status', 'off'], { stdio: 'ignore' });
248
260
  spawnSync('tmux', ['set-option', '-t', sessionName, 'mouse', 'on'], { stdio: 'ignore' });
249
261
  spawnSync('tmux', ['set-option', '-t', sessionName, 'aggressive-resize', 'on'], { stdio: 'ignore' });
250
- return true;
262
+ return sessionName;
251
263
  }
252
264
  catch (err) {
253
265
  log('WARN', `Failed to create tmux session "${sessionName}": ${err.message}`);
254
- return false;
266
+ return null;
255
267
  }
256
268
  }
257
269
  /**
258
- * Check if a tmux session exists
270
+ * Check if a tmux session exists.
271
+ * Sanitizes the name to handle legacy entries stored before dot-replacement fix.
259
272
  */
260
273
  function tmuxSessionExists(sessionName) {
274
+ const sanitized = sanitizeTmuxSessionName(sessionName);
261
275
  try {
262
- execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { stdio: 'ignore' });
276
+ execSync(`tmux has-session -t "${sanitized}" 2>/dev/null`, { stdio: 'ignore' });
263
277
  return true;
264
278
  }
265
279
  catch {
@@ -291,90 +305,218 @@ function killTmuxSession(sessionName) {
291
305
  }
292
306
  }
293
307
  /**
294
- * Reconcile terminal sessions from SQLite against reality on startup.
308
+ * Parse a codev tmux session name to extract type, project, and role.
309
+ * Returns null if the name doesn't match any known codev pattern.
295
310
  *
296
- * For sessions with surviving tmux sessions: re-attach via new node-pty,
297
- * register in projectTerminals, and update SQLite with new terminal ID.
298
- * For dead sessions: clean up SQLite rows and kill orphaned processes.
311
+ * Examples:
312
+ * "architect-codev-public" → { type: 'architect', projectBasename: 'codev-public', roleId: null }
313
+ * "builder-codevos_ai-0001" → { type: 'builder', projectBasename: 'codevos_ai', roleId: '0001' }
314
+ * "shell-codev-public-shell-1" → { type: 'shell', projectBasename: 'codev-public', roleId: 'shell-1' }
299
315
  */
300
- async function reconcileTerminalSessions() {
301
- const db = getGlobalDb();
302
- let sessions;
316
+ function parseTmuxSessionName(name) {
317
+ // architect-{basename}
318
+ const architectMatch = name.match(/^architect-(.+)$/);
319
+ if (architectMatch) {
320
+ return { type: 'architect', projectBasename: architectMatch[1], roleId: null };
321
+ }
322
+ // builder-{basename}-{specId} — specId is always the last segment (digits like "0001")
323
+ const builderMatch = name.match(/^builder-(.+)-(\d{4,})$/);
324
+ if (builderMatch) {
325
+ return { type: 'builder', projectBasename: builderMatch[1], roleId: builderMatch[2] };
326
+ }
327
+ // shell-{basename}-{shellId} — shellId is "shell-N" (last two segments)
328
+ const shellMatch = name.match(/^shell-(.+)-(shell-\d+)$/);
329
+ if (shellMatch) {
330
+ return { type: 'shell', projectBasename: shellMatch[1], roleId: shellMatch[2] };
331
+ }
332
+ return null;
333
+ }
334
+ /**
335
+ * List all tmux sessions that match codev naming conventions.
336
+ * Returns an array of { tmuxName, parsed } for each matching session.
337
+ */
338
+ // Cache for listCodevTmuxSessions — avoid shelling out on every dashboard poll
339
+ let _tmuxListCache = [];
340
+ let _tmuxListCacheTime = 0;
341
+ const TMUX_LIST_CACHE_TTL = 10_000; // 10 seconds
342
+ function listCodevTmuxSessions(bypassCache = false) {
343
+ if (!tmuxAvailable)
344
+ return [];
345
+ const now = Date.now();
346
+ if (!bypassCache && now - _tmuxListCacheTime < TMUX_LIST_CACHE_TTL) {
347
+ return _tmuxListCache;
348
+ }
303
349
  try {
304
- sessions = db.prepare('SELECT * FROM terminal_sessions').all();
350
+ const result = execSync('tmux list-sessions -F "#{session_name}" 2>/dev/null', { encoding: 'utf-8' });
351
+ const sessions = result.trim().split('\n').filter(Boolean);
352
+ const codevSessions = [];
353
+ for (const name of sessions) {
354
+ const parsed = parseTmuxSessionName(name);
355
+ if (parsed) {
356
+ codevSessions.push({ tmuxName: name, parsed });
357
+ }
358
+ }
359
+ _tmuxListCache = codevSessions;
360
+ _tmuxListCacheTime = now;
361
+ return codevSessions;
305
362
  }
306
- catch (err) {
307
- log('WARN', `Failed to read terminal sessions for reconciliation: ${err.message}`);
308
- return;
363
+ catch {
364
+ _tmuxListCache = [];
365
+ _tmuxListCacheTime = now;
366
+ return [];
309
367
  }
310
- if (sessions.length === 0) {
311
- log('INFO', 'No terminal sessions to reconcile');
312
- return;
368
+ }
369
+ /**
370
+ * Find the SQLite row that matches a given tmux session name.
371
+ * Looks up by tmux_session column directly.
372
+ */
373
+ function findSqliteRowForTmuxSession(tmuxName) {
374
+ try {
375
+ const db = getGlobalDb();
376
+ return db.prepare('SELECT * FROM terminal_sessions WHERE tmux_session = ?').get(tmuxName) || null;
313
377
  }
314
- log('INFO', `Reconciling ${sessions.length} terminal sessions from previous run...`);
378
+ catch {
379
+ return null;
380
+ }
381
+ }
382
+ /**
383
+ * Find the full project path for a tmux session's project basename.
384
+ * Checks active port allocations (which have full paths) for a matching basename.
385
+ * Returns null if no match found.
386
+ */
387
+ function resolveProjectPathFromBasename(projectBasename) {
388
+ const allocations = loadPortAllocations();
389
+ for (const alloc of allocations) {
390
+ if (path.basename(alloc.project_path) === projectBasename) {
391
+ return normalizeProjectPath(alloc.project_path);
392
+ }
393
+ }
394
+ // Also check projectTerminals cache (may have entries not yet in allocations)
395
+ for (const [projectPath] of projectTerminals) {
396
+ if (path.basename(projectPath) === projectBasename) {
397
+ return projectPath;
398
+ }
399
+ }
400
+ return null;
401
+ }
402
+ /**
403
+ * Reconcile terminal sessions on startup.
404
+ *
405
+ * STRATEGY: tmux is the source of truth for existence.
406
+ *
407
+ * Phase 1 — tmux-first discovery:
408
+ * List all codev tmux sessions. For each, look up SQLite for metadata.
409
+ * If SQLite has a matching row → reconnect with full metadata.
410
+ * If SQLite has no row (orphaned tmux) → derive metadata from session name, reconnect.
411
+ *
412
+ * Phase 2 — SQLite sweep:
413
+ * Any SQLite rows not matched to a tmux session are stale → clean up.
414
+ * (Also kills orphaned processes that have no tmux backing.)
415
+ */
416
+ async function reconcileTerminalSessions() {
315
417
  const manager = getTerminalManager();
418
+ const db = getGlobalDb();
419
+ // Phase 1: Discover living tmux sessions (bypass cache on startup)
420
+ const liveTmuxSessions = listCodevTmuxSessions(/* bypassCache */ true);
421
+ // Track which SQLite rows we matched (by tmux_session name)
422
+ const matchedTmuxNames = new Set();
316
423
  let reconnected = 0;
317
- let killed = 0;
318
- let cleaned = 0;
319
- for (const session of sessions) {
320
- // Can we reconnect to a surviving tmux session?
321
- if (session.tmux_session && tmuxAvailable && tmuxSessionExists(session.tmux_session)) {
322
- try {
323
- // Create new node-pty that attaches to the surviving tmux session
324
- const newSession = await manager.createSession({
325
- command: 'tmux',
326
- args: ['attach-session', '-t', session.tmux_session],
327
- cwd: session.project_path,
328
- label: session.type === 'architect' ? 'Architect' : `${session.type} ${session.role_id || session.id}`,
329
- });
330
- // Register in projectTerminals Map
331
- const entry = getProjectTerminalsEntry(session.project_path);
332
- if (session.type === 'architect') {
333
- entry.architect = newSession.id;
334
- }
335
- else if (session.type === 'builder') {
336
- const builderId = session.role_id || session.id;
337
- entry.builders.set(builderId, newSession.id);
338
- }
339
- else if (session.type === 'shell') {
340
- const shellId = session.role_id || session.id;
341
- entry.shells.set(shellId, newSession.id);
342
- }
343
- // Update SQLite: delete old row, insert new with new terminal ID
344
- db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(session.id);
345
- saveTerminalSession(newSession.id, session.project_path, session.type, session.role_id, newSession.pid, session.tmux_session);
346
- log('INFO', `Reconnected to tmux session "${session.tmux_session}" → terminal ${newSession.id} (${session.type} for ${path.basename(session.project_path)})`);
424
+ let orphanReconnected = 0;
425
+ if (liveTmuxSessions.length > 0) {
426
+ log('INFO', `Found ${liveTmuxSessions.length} live codev tmux session(s) reconnecting...`);
427
+ }
428
+ for (const { tmuxName, parsed } of liveTmuxSessions) {
429
+ // Look up SQLite for this tmux session's metadata
430
+ const dbRow = findSqliteRowForTmuxSession(tmuxName);
431
+ matchedTmuxNames.add(tmuxName);
432
+ // Determine metadata — prefer SQLite, fall back to parsed name
433
+ const projectPath = dbRow?.project_path || resolveProjectPathFromBasename(parsed.projectBasename);
434
+ const type = dbRow?.type || parsed.type;
435
+ const roleId = dbRow?.role_id || parsed.roleId;
436
+ if (!projectPath) {
437
+ log('WARN', `Cannot resolve project path for tmux session "${tmuxName}" (basename: ${parsed.projectBasename}) — skipping`);
438
+ continue;
439
+ }
440
+ try {
441
+ const label = type === 'architect' ? 'Architect' : `${type} ${roleId || 'unknown'}`;
442
+ const newSession = await manager.createSession({
443
+ command: 'tmux',
444
+ args: ['attach-session', '-t', tmuxName],
445
+ cwd: projectPath,
446
+ label,
447
+ });
448
+ // Register in projectTerminals Map
449
+ const entry = getProjectTerminalsEntry(projectPath);
450
+ if (type === 'architect') {
451
+ entry.architect = newSession.id;
452
+ }
453
+ else if (type === 'builder') {
454
+ entry.builders.set(roleId || tmuxName, newSession.id);
455
+ }
456
+ else if (type === 'shell') {
457
+ entry.shells.set(roleId || tmuxName, newSession.id);
458
+ }
459
+ // Update SQLite: delete old row (if any), insert fresh one
460
+ if (dbRow) {
461
+ db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbRow.id);
462
+ }
463
+ saveTerminalSession(newSession.id, projectPath, type, roleId, newSession.pid, tmuxName);
464
+ if (dbRow) {
465
+ log('INFO', `Reconnected tmux "${tmuxName}" → terminal ${newSession.id} (${type} for ${path.basename(projectPath)})`);
347
466
  reconnected++;
348
- continue;
349
467
  }
350
- catch (err) {
351
- log('WARN', `Failed to reconnect to tmux session "${session.tmux_session}": ${err.message}`);
352
- // Fall through to cleanup
353
- killTmuxSession(session.tmux_session);
354
- killed++;
468
+ else {
469
+ log('INFO', `Recovered orphaned tmux "${tmuxName}" terminal ${newSession.id} (${type} for ${path.basename(projectPath)}) [no SQLite row]`);
470
+ orphanReconnected++;
355
471
  }
356
472
  }
357
- // No tmux or tmux session dead — check for orphaned processes
358
- else if (session.tmux_session && tmuxSessionExists(session.tmux_session)) {
359
- // tmux exists but tmuxAvailable is false (shouldn't happen, but be safe)
360
- killTmuxSession(session.tmux_session);
361
- killed++;
473
+ catch (err) {
474
+ log('WARN', `Failed to reconnect to tmux "${tmuxName}": ${err.message}`);
362
475
  }
363
- else if (session.pid && processExists(session.pid)) {
364
- log('INFO', `Found orphaned process: PID ${session.pid} (${session.type} for ${path.basename(session.project_path)})`);
476
+ }
477
+ // Phase 2: Sweep stale SQLite rows (those with no matching live tmux session)
478
+ let killed = 0;
479
+ let cleaned = 0;
480
+ let allDbSessions;
481
+ try {
482
+ allDbSessions = db.prepare('SELECT * FROM terminal_sessions').all();
483
+ }
484
+ catch (err) {
485
+ log('WARN', `Failed to read terminal sessions for sweep: ${err.message}`);
486
+ allDbSessions = [];
487
+ }
488
+ for (const session of allDbSessions) {
489
+ // Skip rows that were already reconnected in Phase 1
490
+ if (session.tmux_session && matchedTmuxNames.has(session.tmux_session)) {
491
+ continue;
492
+ }
493
+ // Also skip rows whose terminal is still alive in PtyManager
494
+ // (non-tmux sessions created during this Tower run)
495
+ const existing = manager.getSession(session.id);
496
+ if (existing && existing.status !== 'exited') {
497
+ continue;
498
+ }
499
+ // Stale row — kill orphaned process if any, then delete
500
+ if (session.pid && processExists(session.pid)) {
501
+ log('INFO', `Killing orphaned process: PID ${session.pid} (${session.type} for ${path.basename(session.project_path)})`);
365
502
  try {
366
503
  process.kill(session.pid, 'SIGTERM');
367
504
  killed++;
368
505
  }
369
506
  catch {
370
- // Process may not be killable (different user, etc)
507
+ // Process may not be killable
371
508
  }
372
509
  }
373
- // Clean up the DB row for sessions we couldn't reconnect
374
510
  db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(session.id);
375
511
  cleaned++;
376
512
  }
377
- log('INFO', `Reconciliation complete: ${reconnected} reconnected, ${killed} orphaned killed, ${cleaned} cleaned up`);
513
+ const total = reconnected + orphanReconnected;
514
+ if (total > 0 || killed > 0 || cleaned > 0) {
515
+ log('INFO', `Reconciliation complete: ${reconnected} reconnected, ${orphanReconnected} orphan-recovered, ${killed} killed, ${cleaned} stale rows cleaned`);
516
+ }
517
+ else {
518
+ log('INFO', 'No terminal sessions to reconcile');
519
+ }
378
520
  }
379
521
  /**
380
522
  * Get terminal sessions from SQLite for a project.
@@ -719,7 +861,7 @@ async function getGateStatusForProject(basePort) {
719
861
  async function getTerminalsForProject(projectPath, proxyUrl) {
720
862
  const manager = getTerminalManager();
721
863
  const terminals = [];
722
- // SQLite is authoritative - query it first (Spec 0090 requirement)
864
+ // Query SQLite first, then augment with tmux discovery
723
865
  const dbSessions = getTerminalSessionsForProject(projectPath);
724
866
  // Use normalized path for cache consistency
725
867
  const normalizedPath = normalizeProjectPath(projectPath);
@@ -736,22 +878,23 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
736
878
  for (const dbSession of dbSessions) {
737
879
  // Verify session still exists in TerminalManager (runtime state)
738
880
  let session = manager.getSession(dbSession.id);
739
- if (!session && dbSession.tmux_session && tmuxAvailable && tmuxSessionExists(dbSession.tmux_session)) {
881
+ const sanitizedTmux = dbSession.tmux_session ? sanitizeTmuxSessionName(dbSession.tmux_session) : null;
882
+ if (!session && sanitizedTmux && tmuxAvailable && tmuxSessionExists(sanitizedTmux)) {
740
883
  // PTY session gone but tmux session survives — reconnect on-the-fly
741
884
  try {
742
885
  const newSession = await manager.createSession({
743
886
  command: 'tmux',
744
- args: ['attach-session', '-t', dbSession.tmux_session],
887
+ args: ['attach-session', '-t', sanitizedTmux],
745
888
  cwd: dbSession.project_path,
746
889
  label: dbSession.type === 'architect' ? 'Architect' : `${dbSession.type} ${dbSession.role_id || dbSession.id}`,
747
890
  env: process.env,
748
891
  });
749
- // Update SQLite with new terminal ID
892
+ // Update SQLite with new terminal ID (use sanitized tmux name)
750
893
  deleteTerminalSession(dbSession.id);
751
- saveTerminalSession(newSession.id, dbSession.project_path, dbSession.type, dbSession.role_id, newSession.pid, dbSession.tmux_session);
894
+ saveTerminalSession(newSession.id, dbSession.project_path, dbSession.type, dbSession.role_id, newSession.pid, sanitizedTmux);
752
895
  dbSession.id = newSession.id;
753
896
  session = manager.getSession(newSession.id);
754
- log('INFO', `Reconnected to tmux "${dbSession.tmux_session}" on-the-fly → ${newSession.id}`);
897
+ log('INFO', `Reconnected to tmux "${sanitizedTmux}" on-the-fly → ${newSession.id}`);
755
898
  }
756
899
  catch (err) {
757
900
  log('WARN', `Failed to reconnect to tmux "${dbSession.tmux_session}": ${err.message} — will retry on next poll`);
@@ -843,6 +986,52 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
843
986
  }
844
987
  }
845
988
  }
989
+ // Phase 3: tmux discovery — find tmux sessions for this project that are
990
+ // missing from both SQLite and the in-memory cache.
991
+ // This is the safety net: if SQLite rows got deleted but tmux survived,
992
+ // the session will still appear in the dashboard.
993
+ const projectBasename = sanitizeTmuxSessionName(path.basename(normalizedPath));
994
+ const liveTmux = listCodevTmuxSessions();
995
+ for (const { tmuxName, parsed } of liveTmux) {
996
+ // Only process sessions whose sanitized project basename matches
997
+ if (parsed.projectBasename !== projectBasename)
998
+ continue;
999
+ // Skip if we already have this session registered (from SQLite or in-memory)
1000
+ const alreadyRegistered = (parsed.type === 'architect' && freshEntry.architect) ||
1001
+ (parsed.type === 'builder' && parsed.roleId && freshEntry.builders.has(parsed.roleId)) ||
1002
+ (parsed.type === 'shell' && parsed.roleId && freshEntry.shells.has(parsed.roleId));
1003
+ if (alreadyRegistered)
1004
+ continue;
1005
+ // Orphaned tmux session — reconnect it
1006
+ try {
1007
+ const label = parsed.type === 'architect' ? 'Architect' : `${parsed.type} ${parsed.roleId || 'unknown'}`;
1008
+ const newSession = await manager.createSession({
1009
+ command: 'tmux',
1010
+ args: ['attach-session', '-t', tmuxName],
1011
+ cwd: normalizedPath,
1012
+ label,
1013
+ });
1014
+ const roleId = parsed.roleId;
1015
+ if (parsed.type === 'architect') {
1016
+ freshEntry.architect = newSession.id;
1017
+ terminals.push({ type: 'architect', id: 'architect', label: 'Architect', url: `${proxyUrl}?tab=architect`, active: true });
1018
+ }
1019
+ else if (parsed.type === 'builder' && roleId) {
1020
+ freshEntry.builders.set(roleId, newSession.id);
1021
+ terminals.push({ type: 'builder', id: roleId, label: `Builder ${roleId}`, url: `${proxyUrl}?tab=builder-${roleId}`, active: true });
1022
+ }
1023
+ else if (parsed.type === 'shell' && roleId) {
1024
+ freshEntry.shells.set(roleId, newSession.id);
1025
+ terminals.push({ type: 'shell', id: roleId, label: `Shell ${roleId.replace('shell-', '')}`, url: `${proxyUrl}?tab=shell-${roleId}`, active: true });
1026
+ }
1027
+ // Persist to SQLite so future polls find it directly
1028
+ saveTerminalSession(newSession.id, normalizedPath, parsed.type, roleId, newSession.pid, tmuxName);
1029
+ log('INFO', `[tmux-discovery] Recovered orphaned tmux "${tmuxName}" → ${newSession.id} (${parsed.type})`);
1030
+ }
1031
+ catch (err) {
1032
+ log('WARN', `[tmux-discovery] Failed to recover tmux "${tmuxName}": ${err.message}`);
1033
+ }
1034
+ }
846
1035
  // Atomically replace the cache entry
847
1036
  projectTerminals.set(normalizedPath, freshEntry);
848
1037
  // Gate status - builders don't have gate tracking yet in tower
@@ -943,6 +1132,10 @@ async function getDirectorySuggestions(inputPath) {
943
1132
  if (inputPath.startsWith('~')) {
944
1133
  inputPath = inputPath.replace('~', homedir());
945
1134
  }
1135
+ // Relative paths are meaningless for the tower daemon — only absolute paths
1136
+ if (!path.isAbsolute(inputPath)) {
1137
+ return [];
1138
+ }
946
1139
  // Determine the directory to list and the prefix to filter by
947
1140
  let dirToList;
948
1141
  let prefix;
@@ -1088,12 +1281,12 @@ async function launchInstance(projectPath) {
1088
1281
  const tmuxName = `architect-${path.basename(projectPath)}`;
1089
1282
  let activeTmuxSession = null;
1090
1283
  if (tmuxAvailable) {
1091
- const tmuxCreated = createTmuxSession(tmuxName, cmd, cmdArgs, projectPath, 200, 50);
1092
- if (tmuxCreated) {
1284
+ const sanitizedName = createTmuxSession(tmuxName, cmd, cmdArgs, projectPath, 200, 50);
1285
+ if (sanitizedName) {
1093
1286
  cmd = 'tmux';
1094
- cmdArgs = ['attach-session', '-t', tmuxName];
1095
- activeTmuxSession = tmuxName;
1096
- log('INFO', `Created tmux session "${tmuxName}" for architect`);
1287
+ cmdArgs = ['attach-session', '-t', sanitizedName];
1288
+ activeTmuxSession = sanitizedName;
1289
+ log('INFO', `Created tmux session "${sanitizedName}" for architect`);
1097
1290
  }
1098
1291
  }
1099
1292
  const session = await manager.createSession({
@@ -1433,13 +1626,13 @@ const server = http.createServer(async (req, res) => {
1433
1626
  const tmuxSession = typeof body.tmuxSession === 'string' ? body.tmuxSession : null;
1434
1627
  let activeTmuxSession = null;
1435
1628
  if (tmuxSession && tmuxAvailable && command && cwd) {
1436
- const tmuxCreated = createTmuxSession(tmuxSession, command, args || [], cwd, cols || 200, rows || 50);
1437
- if (tmuxCreated) {
1438
- // Override: node-pty attaches to the tmux session
1629
+ const sanitizedName = createTmuxSession(tmuxSession, command, args || [], cwd, cols || 200, rows || 50);
1630
+ if (sanitizedName) {
1631
+ // Override: node-pty attaches to the tmux session (use sanitized name)
1439
1632
  command = 'tmux';
1440
- args = ['attach-session', '-t', tmuxSession];
1441
- activeTmuxSession = tmuxSession;
1442
- log('INFO', `Created tmux session "${tmuxSession}" for terminal`);
1633
+ args = ['attach-session', '-t', sanitizedName];
1634
+ activeTmuxSession = sanitizedName;
1635
+ log('INFO', `Created tmux session "${sanitizedName}" for terminal`);
1443
1636
  }
1444
1637
  // If tmux creation failed, fall through to bare node-pty
1445
1638
  }
@@ -1699,12 +1892,27 @@ const server = http.createServer(async (req, res) => {
1699
1892
  // API: Launch new instance
1700
1893
  if (req.method === 'POST' && url.pathname === '/api/launch') {
1701
1894
  const body = await parseJsonBody(req);
1702
- const projectPath = body.projectPath;
1895
+ let projectPath = body.projectPath;
1703
1896
  if (!projectPath) {
1704
1897
  res.writeHead(400, { 'Content-Type': 'application/json' });
1705
1898
  res.end(JSON.stringify({ success: false, error: 'Missing projectPath' }));
1706
1899
  return;
1707
1900
  }
1901
+ // Expand ~ to home directory
1902
+ if (projectPath.startsWith('~')) {
1903
+ projectPath = projectPath.replace('~', homedir());
1904
+ }
1905
+ // Reject relative paths — tower daemon CWD is unpredictable
1906
+ if (!path.isAbsolute(projectPath)) {
1907
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1908
+ res.end(JSON.stringify({
1909
+ success: false,
1910
+ error: `Relative paths are not supported. Use an absolute path (e.g., /Users/.../project or ~/Development/project).`,
1911
+ }));
1912
+ return;
1913
+ }
1914
+ // Normalize path (resolve .. segments, trailing slashes)
1915
+ projectPath = path.resolve(projectPath);
1708
1916
  const result = await launchInstance(projectPath);
1709
1917
  res.writeHead(result.success ? 200 : 400, { 'Content-Type': 'application/json' });
1710
1918
  res.end(JSON.stringify(result));
@@ -1854,6 +2062,12 @@ const server = http.createServer(async (req, res) => {
1854
2062
  const apiPath = subPath.replace(/^api\/?/, '');
1855
2063
  // GET /api/state - Return project state (architect, builders, shells)
1856
2064
  if (req.method === 'GET' && (apiPath === 'state' || apiPath === '')) {
2065
+ // Refresh cache via getTerminalsForProject (handles SQLite sync,
2066
+ // tmux reconnection, and tmux discovery in one place)
2067
+ const encodedPath = Buffer.from(projectPath).toString('base64url');
2068
+ const proxyUrl = `/project/${encodedPath}/`;
2069
+ await getTerminalsForProject(projectPath, proxyUrl);
2070
+ // Now read from the refreshed cache
1857
2071
  const entry = getProjectTerminalsEntry(projectPath);
1858
2072
  const manager = getTerminalManager();
1859
2073
  // Build state response compatible with React dashboard
@@ -1867,53 +2081,45 @@ const server = http.createServer(async (req, res) => {
1867
2081
  // Add architect if exists
1868
2082
  if (entry.architect) {
1869
2083
  const session = manager.getSession(entry.architect);
1870
- state.architect = {
1871
- port: basePort || 0,
1872
- pid: session?.pid || 0,
1873
- terminalId: entry.architect,
1874
- };
2084
+ if (session) {
2085
+ state.architect = {
2086
+ port: basePort || 0,
2087
+ pid: session.pid || 0,
2088
+ terminalId: entry.architect,
2089
+ };
2090
+ }
1875
2091
  }
1876
- // Add shells (skip stale entries whose terminal session is gone or exited)
1877
- const staleShellIds = [];
2092
+ // Add shells from refreshed cache
1878
2093
  for (const [shellId, terminalId] of entry.shells) {
1879
2094
  const session = manager.getSession(terminalId);
1880
- if (!session || session.status === 'exited') {
1881
- staleShellIds.push(shellId);
1882
- continue;
2095
+ if (session) {
2096
+ state.utils.push({
2097
+ id: shellId,
2098
+ name: `Shell ${shellId.replace('shell-', '')}`,
2099
+ port: basePort || 0,
2100
+ pid: session.pid || 0,
2101
+ terminalId,
2102
+ });
1883
2103
  }
1884
- state.utils.push({
1885
- id: shellId,
1886
- name: `Shell ${shellId.replace('shell-', '')}`,
1887
- port: basePort || 0,
1888
- pid: session?.pid || 0,
1889
- terminalId,
1890
- });
1891
2104
  }
1892
- for (const id of staleShellIds)
1893
- entry.shells.delete(id);
1894
- // Add builders (skip stale entries whose terminal session is gone or exited)
1895
- const staleBuilderIds = [];
2105
+ // Add builders from refreshed cache
1896
2106
  for (const [builderId, terminalId] of entry.builders) {
1897
2107
  const session = manager.getSession(terminalId);
1898
- if (!session || session.status === 'exited') {
1899
- staleBuilderIds.push(builderId);
1900
- continue;
2108
+ if (session) {
2109
+ state.builders.push({
2110
+ id: builderId,
2111
+ name: `Builder ${builderId}`,
2112
+ port: basePort || 0,
2113
+ pid: session.pid || 0,
2114
+ status: 'running',
2115
+ phase: '',
2116
+ worktree: '',
2117
+ branch: '',
2118
+ type: 'spec',
2119
+ terminalId,
2120
+ });
1901
2121
  }
1902
- state.builders.push({
1903
- id: builderId,
1904
- name: `Builder ${builderId}`,
1905
- port: basePort || 0,
1906
- pid: session?.pid || 0,
1907
- status: 'running',
1908
- phase: '',
1909
- worktree: '',
1910
- branch: '',
1911
- type: 'spec',
1912
- terminalId,
1913
- });
1914
2122
  }
1915
- for (const id of staleBuilderIds)
1916
- entry.builders.delete(id);
1917
2123
  // Add file tabs (Spec 0092 - served through Tower, no separate ports)
1918
2124
  for (const [tabId, tab] of entry.fileTabs) {
1919
2125
  state.annotations.push({
@@ -1938,11 +2144,11 @@ const server = http.createServer(async (req, res) => {
1938
2144
  const tmuxName = `shell-${path.basename(projectPath)}-${shellId}`;
1939
2145
  let activeTmuxSession = null;
1940
2146
  if (tmuxAvailable) {
1941
- const tmuxCreated = createTmuxSession(tmuxName, shellCmd, shellArgs, projectPath, 200, 50);
1942
- if (tmuxCreated) {
2147
+ const sanitizedName = createTmuxSession(tmuxName, shellCmd, shellArgs, projectPath, 200, 50);
2148
+ if (sanitizedName) {
1943
2149
  shellCmd = 'tmux';
1944
- shellArgs = ['attach-session', '-t', tmuxName];
1945
- activeTmuxSession = tmuxName;
2150
+ shellArgs = ['attach-session', '-t', sanitizedName];
2151
+ activeTmuxSession = sanitizedName;
1946
2152
  }
1947
2153
  }
1948
2154
  // Create terminal session