@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.
- package/dist/agent-farm/commands/open.d.ts.map +1 -1
- package/dist/agent-farm/commands/open.js +2 -5
- package/dist/agent-farm/commands/open.js.map +1 -1
- package/dist/agent-farm/commands/shell.d.ts +3 -3
- package/dist/agent-farm/commands/shell.d.ts.map +1 -1
- package/dist/agent-farm/commands/shell.js +31 -65
- package/dist/agent-farm/commands/shell.js.map +1 -1
- package/dist/agent-farm/commands/start.d.ts.map +1 -1
- package/dist/agent-farm/commands/start.js +5 -2
- package/dist/agent-farm/commands/start.js.map +1 -1
- package/dist/agent-farm/servers/tower-server.js +333 -127
- package/dist/agent-farm/servers/tower-server.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/consult/index.d.ts +1 -0
- package/dist/commands/consult/index.d.ts.map +1 -1
- package/dist/commands/consult/index.js +25 -7
- package/dist/commands/consult/index.js.map +1 -1
- package/dist/commands/porch/next.js +4 -2
- package/dist/commands/porch/next.js.map +1 -1
- package/package.json +1 -1
- package/skeleton/consult-types/impl-review.md +9 -0
|
@@ -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
|
|
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
|
|
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
|
|
262
|
+
return sessionName;
|
|
251
263
|
}
|
|
252
264
|
catch (err) {
|
|
253
265
|
log('WARN', `Failed to create tmux session "${sessionName}": ${err.message}`);
|
|
254
|
-
return
|
|
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 "${
|
|
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
|
-
*
|
|
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
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
|
307
|
-
|
|
308
|
-
|
|
363
|
+
catch {
|
|
364
|
+
_tmuxListCache = [];
|
|
365
|
+
_tmuxListCacheTime = now;
|
|
366
|
+
return [];
|
|
309
367
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
351
|
-
log('
|
|
352
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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',
|
|
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,
|
|
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 "${
|
|
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
|
|
1092
|
-
if (
|
|
1284
|
+
const sanitizedName = createTmuxSession(tmuxName, cmd, cmdArgs, projectPath, 200, 50);
|
|
1285
|
+
if (sanitizedName) {
|
|
1093
1286
|
cmd = 'tmux';
|
|
1094
|
-
cmdArgs = ['attach-session', '-t',
|
|
1095
|
-
activeTmuxSession =
|
|
1096
|
-
log('INFO', `Created tmux session "${
|
|
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
|
|
1437
|
-
if (
|
|
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',
|
|
1441
|
-
activeTmuxSession =
|
|
1442
|
-
log('INFO', `Created tmux session "${
|
|
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
|
-
|
|
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
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
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
|
|
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 (
|
|
1881
|
-
|
|
1882
|
-
|
|
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
|
-
|
|
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 (
|
|
1899
|
-
|
|
1900
|
-
|
|
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
|
|
1942
|
-
if (
|
|
2147
|
+
const sanitizedName = createTmuxSession(tmuxName, shellCmd, shellArgs, projectPath, 200, 50);
|
|
2148
|
+
if (sanitizedName) {
|
|
1943
2149
|
shellCmd = 'tmux';
|
|
1944
|
-
shellArgs = ['attach-session', '-t',
|
|
1945
|
-
activeTmuxSession =
|
|
2150
|
+
shellArgs = ['attach-session', '-t', sanitizedName];
|
|
2151
|
+
activeTmuxSession = sanitizedName;
|
|
1946
2152
|
}
|
|
1947
2153
|
}
|
|
1948
2154
|
// Create terminal session
|