@cluesmith/codev 2.0.0-rc.69 → 2.0.0-rc.70
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/dashboard/dist/assets/{index-CG7nUttd.js → index-CDAINZKT.js} +21 -21
- package/dashboard/dist/assets/{index-CG7nUttd.js.map → index-CDAINZKT.js.map} +1 -1
- package/dashboard/dist/index.html +1 -1
- package/dist/agent-farm/cli.js +2 -2
- package/dist/agent-farm/cli.js.map +1 -1
- package/dist/agent-farm/commands/architect.d.ts +3 -3
- package/dist/agent-farm/commands/architect.d.ts.map +1 -1
- package/dist/agent-farm/commands/architect.js +20 -142
- package/dist/agent-farm/commands/architect.js.map +1 -1
- package/dist/agent-farm/commands/attach.d.ts.map +1 -1
- package/dist/agent-farm/commands/attach.js +13 -50
- package/dist/agent-farm/commands/attach.js.map +1 -1
- package/dist/agent-farm/commands/cleanup.d.ts.map +1 -1
- package/dist/agent-farm/commands/cleanup.js +1 -11
- package/dist/agent-farm/commands/cleanup.js.map +1 -1
- package/dist/agent-farm/commands/send.d.ts +1 -1
- package/dist/agent-farm/commands/send.d.ts.map +1 -1
- package/dist/agent-farm/commands/send.js +35 -92
- package/dist/agent-farm/commands/send.js.map +1 -1
- package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
- package/dist/agent-farm/commands/spawn.js +26 -58
- package/dist/agent-farm/commands/spawn.js.map +1 -1
- package/dist/agent-farm/commands/status.js +1 -1
- package/dist/agent-farm/commands/status.js.map +1 -1
- package/dist/agent-farm/commands/stop.d.ts.map +1 -1
- package/dist/agent-farm/commands/stop.js +9 -44
- package/dist/agent-farm/commands/stop.js.map +1 -1
- package/dist/agent-farm/commands/tower-cloud.d.ts.map +1 -1
- package/dist/agent-farm/commands/tower-cloud.js +18 -1
- package/dist/agent-farm/commands/tower-cloud.js.map +1 -1
- package/dist/agent-farm/db/index.d.ts.map +1 -1
- package/dist/agent-farm/db/index.js +61 -0
- package/dist/agent-farm/db/index.js.map +1 -1
- package/dist/agent-farm/db/migrate.d.ts.map +1 -1
- package/dist/agent-farm/db/migrate.js +6 -9
- package/dist/agent-farm/db/migrate.js.map +1 -1
- package/dist/agent-farm/db/schema.d.ts +2 -2
- package/dist/agent-farm/db/schema.d.ts.map +1 -1
- package/dist/agent-farm/db/schema.js +3 -4
- package/dist/agent-farm/db/schema.js.map +1 -1
- package/dist/agent-farm/db/types.d.ts +0 -3
- package/dist/agent-farm/db/types.d.ts.map +1 -1
- package/dist/agent-farm/db/types.js +0 -3
- package/dist/agent-farm/db/types.js.map +1 -1
- package/dist/agent-farm/lib/tower-client.d.ts +4 -0
- package/dist/agent-farm/lib/tower-client.d.ts.map +1 -1
- package/dist/agent-farm/lib/tower-client.js +10 -0
- package/dist/agent-farm/lib/tower-client.js.map +1 -1
- package/dist/agent-farm/lib/tunnel-client.d.ts.map +1 -1
- package/dist/agent-farm/lib/tunnel-client.js +6 -13
- package/dist/agent-farm/lib/tunnel-client.js.map +1 -1
- package/dist/agent-farm/servers/tower-server.js +482 -494
- package/dist/agent-farm/servers/tower-server.js.map +1 -1
- package/dist/agent-farm/state.d.ts.map +1 -1
- package/dist/agent-farm/state.js +6 -10
- package/dist/agent-farm/state.js.map +1 -1
- package/dist/agent-farm/types.d.ts +0 -7
- package/dist/agent-farm/types.d.ts.map +1 -1
- package/dist/agent-farm/utils/deps.d.ts.map +1 -1
- package/dist/agent-farm/utils/deps.js +0 -16
- package/dist/agent-farm/utils/deps.js.map +1 -1
- package/dist/agent-farm/utils/gate-watcher.js +4 -4
- package/dist/agent-farm/utils/gate-watcher.js.map +1 -1
- package/dist/agent-farm/utils/session.d.ts +6 -6
- package/dist/agent-farm/utils/session.d.ts.map +1 -1
- package/dist/agent-farm/utils/session.js +4 -4
- package/dist/agent-farm/utils/session.js.map +1 -1
- package/dist/agent-farm/utils/shell.d.ts +4 -4
- package/dist/agent-farm/utils/shell.js +4 -4
- 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 +19 -5
- package/dist/commands/consult/index.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +0 -12
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/porch/next.d.ts.map +1 -1
- package/dist/commands/porch/next.js +90 -8
- package/dist/commands/porch/next.js.map +1 -1
- package/dist/commands/porch/prompts.d.ts.map +1 -1
- package/dist/commands/porch/prompts.js +26 -4
- package/dist/commands/porch/prompts.js.map +1 -1
- package/dist/commands/porch/verdict.d.ts +0 -8
- package/dist/commands/porch/verdict.d.ts.map +1 -1
- package/dist/commands/porch/verdict.js +0 -13
- package/dist/commands/porch/verdict.js.map +1 -1
- package/dist/terminal/pty-manager.d.ts +9 -0
- package/dist/terminal/pty-manager.d.ts.map +1 -1
- package/dist/terminal/pty-manager.js +45 -2
- package/dist/terminal/pty-manager.js.map +1 -1
- package/dist/terminal/pty-session.d.ts +27 -4
- package/dist/terminal/pty-session.d.ts.map +1 -1
- package/dist/terminal/pty-session.js +112 -4
- package/dist/terminal/pty-session.js.map +1 -1
- package/dist/terminal/ring-buffer.d.ts +10 -3
- package/dist/terminal/ring-buffer.d.ts.map +1 -1
- package/dist/terminal/ring-buffer.js +25 -5
- package/dist/terminal/ring-buffer.js.map +1 -1
- package/dist/terminal/session-manager.d.ts +115 -0
- package/dist/terminal/session-manager.d.ts.map +1 -0
- package/dist/terminal/session-manager.js +582 -0
- package/dist/terminal/session-manager.js.map +1 -0
- package/dist/terminal/shepherd-client.d.ts +58 -0
- package/dist/terminal/shepherd-client.d.ts.map +1 -0
- package/dist/terminal/shepherd-client.js +212 -0
- package/dist/terminal/shepherd-client.js.map +1 -0
- package/dist/terminal/shepherd-main.d.ts +19 -0
- package/dist/terminal/shepherd-main.d.ts.map +1 -0
- package/dist/terminal/shepherd-main.js +153 -0
- package/dist/terminal/shepherd-main.js.map +1 -0
- package/dist/terminal/shepherd-process.d.ts +75 -0
- package/dist/terminal/shepherd-process.d.ts.map +1 -0
- package/dist/terminal/shepherd-process.js +279 -0
- package/dist/terminal/shepherd-process.js.map +1 -0
- package/dist/terminal/shepherd-protocol.d.ts +115 -0
- package/dist/terminal/shepherd-protocol.d.ts.map +1 -0
- package/dist/terminal/shepherd-protocol.js +214 -0
- package/dist/terminal/shepherd-protocol.js.map +1 -0
- package/dist/terminal/shepherd-replay-buffer.d.ts +38 -0
- package/dist/terminal/shepherd-replay-buffer.d.ts.map +1 -0
- package/dist/terminal/shepherd-replay-buffer.js +94 -0
- package/dist/terminal/shepherd-replay-buffer.js.map +1 -0
- package/package.json +1 -1
- package/skeleton/.claude/skills/codev/SKILL.md +1 -1
- package/skeleton/DEPENDENCIES.md +2 -34
- package/skeleton/consult-types/impl-review.md +9 -9
- package/skeleton/consult-types/integration-review.md +1 -1
- package/skeleton/consult-types/plan-review.md +1 -1
- package/skeleton/consult-types/pr-ready.md +1 -1
- package/skeleton/consult-types/spec-review.md +1 -1
- package/skeleton/protocols/bugfix/prompts/pr.md +23 -4
- package/skeleton/protocols/maintain/protocol.md +3 -3
- package/skeleton/resources/commands/agent-farm.md +2 -3
- package/skeleton/resources/commands/codev.md +0 -2
- package/skeleton/roles/architect.md +1 -1
- package/skeleton/roles/consultant.md +6 -6
|
@@ -7,7 +7,7 @@ import http from 'node:http';
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import crypto from 'node:crypto';
|
|
10
|
-
import { execSync
|
|
10
|
+
import { execSync } from 'node:child_process';
|
|
11
11
|
import { homedir, tmpdir } from 'node:os';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { Command } from 'commander';
|
|
@@ -21,7 +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 {
|
|
24
|
+
import { SessionManager } from '../../terminal/session-manager.js';
|
|
25
25
|
const __filename = fileURLToPath(import.meta.url);
|
|
26
26
|
const __dirname = path.dirname(__filename);
|
|
27
27
|
// Default port for tower dashboard
|
|
@@ -307,7 +307,7 @@ function getTerminalManager() {
|
|
|
307
307
|
projectRoot,
|
|
308
308
|
logDir: path.join(homedir(), '.agent-farm', 'logs'),
|
|
309
309
|
maxSessions: 100,
|
|
310
|
-
ringBufferLines:
|
|
310
|
+
ringBufferLines: 10000,
|
|
311
311
|
diskLogEnabled: true,
|
|
312
312
|
diskLogMaxBytes: 50 * 1024 * 1024,
|
|
313
313
|
reconnectTimeoutMs: 300_000,
|
|
@@ -332,7 +332,7 @@ function normalizeProjectPath(projectPath) {
|
|
|
332
332
|
* Save a terminal session to SQLite.
|
|
333
333
|
* Guards against race conditions by checking if project is still active.
|
|
334
334
|
*/
|
|
335
|
-
function saveTerminalSession(terminalId, projectPath, type, roleId, pid,
|
|
335
|
+
function saveTerminalSession(terminalId, projectPath, type, roleId, pid, shepherdSocket = null, shepherdPid = null, shepherdStartTime = null) {
|
|
336
336
|
try {
|
|
337
337
|
const normalizedPath = normalizeProjectPath(projectPath);
|
|
338
338
|
// Race condition guard: only save if project is still in the active registry
|
|
@@ -343,15 +343,22 @@ function saveTerminalSession(terminalId, projectPath, type, roleId, pid, tmuxSes
|
|
|
343
343
|
}
|
|
344
344
|
const db = getGlobalDb();
|
|
345
345
|
db.prepare(`
|
|
346
|
-
INSERT OR REPLACE INTO terminal_sessions (id, project_path, type, role_id, pid,
|
|
347
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
348
|
-
`).run(terminalId, normalizedPath, type, roleId, pid,
|
|
346
|
+
INSERT OR REPLACE INTO terminal_sessions (id, project_path, type, role_id, pid, shepherd_socket, shepherd_pid, shepherd_start_time)
|
|
347
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
348
|
+
`).run(terminalId, normalizedPath, type, roleId, pid, shepherdSocket, shepherdPid, shepherdStartTime);
|
|
349
349
|
log('INFO', `Saved terminal session to SQLite: ${terminalId} (${type}) for ${path.basename(normalizedPath)}`);
|
|
350
350
|
}
|
|
351
351
|
catch (err) {
|
|
352
352
|
log('WARN', `Failed to save terminal session: ${err.message}`);
|
|
353
353
|
}
|
|
354
354
|
}
|
|
355
|
+
/**
|
|
356
|
+
* Check if a terminal session is persistent (shepherd-backed).
|
|
357
|
+
* A session is persistent if it can survive a Tower restart.
|
|
358
|
+
*/
|
|
359
|
+
function isSessionPersistent(_terminalId, session) {
|
|
360
|
+
return session.shepherdBacked;
|
|
361
|
+
}
|
|
355
362
|
/**
|
|
356
363
|
* Delete a terminal session from SQLite
|
|
357
364
|
*/
|
|
@@ -421,96 +428,8 @@ function loadFileTabsForProject(projectPath) {
|
|
|
421
428
|
}
|
|
422
429
|
return new Map();
|
|
423
430
|
}
|
|
424
|
-
//
|
|
425
|
-
let
|
|
426
|
-
/**
|
|
427
|
-
* Check if tmux is installed and available
|
|
428
|
-
*/
|
|
429
|
-
function checkTmux() {
|
|
430
|
-
try {
|
|
431
|
-
execSync('tmux -V', { stdio: 'ignore' });
|
|
432
|
-
return true;
|
|
433
|
-
}
|
|
434
|
-
catch {
|
|
435
|
-
return false;
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
/**
|
|
439
|
-
* Sanitize a tmux session name to match what tmux actually creates.
|
|
440
|
-
* tmux replaces dots with underscores and strips colons from session names.
|
|
441
|
-
* Without this, stored names won't match actual tmux session names,
|
|
442
|
-
* causing reconnection to fail (e.g., "builder-codevos.ai-0001" vs "builder-codevos_ai-0001").
|
|
443
|
-
*/
|
|
444
|
-
function sanitizeTmuxSessionName(name) {
|
|
445
|
-
return name.replace(/\./g, '_').replace(/:/g, '');
|
|
446
|
-
}
|
|
447
|
-
/**
|
|
448
|
-
* Create a tmux session with the given command.
|
|
449
|
-
* Returns the sanitized session name if created successfully, null on failure.
|
|
450
|
-
* Session names are sanitized to match tmux behavior (dots → underscores, colons stripped).
|
|
451
|
-
*/
|
|
452
|
-
function createTmuxSession(sessionName, command, args, cwd, cols, rows) {
|
|
453
|
-
// Sanitize to match what tmux actually creates (dots → underscores, colons stripped)
|
|
454
|
-
sessionName = sanitizeTmuxSessionName(sessionName);
|
|
455
|
-
// Kill any stale session with this name
|
|
456
|
-
if (tmuxSessionExists(sessionName)) {
|
|
457
|
-
killTmuxSession(sessionName);
|
|
458
|
-
}
|
|
459
|
-
try {
|
|
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.
|
|
463
|
-
const tmuxArgs = [
|
|
464
|
-
'new-session', '-d',
|
|
465
|
-
'-s', sessionName,
|
|
466
|
-
'-c', cwd,
|
|
467
|
-
'-x', String(cols),
|
|
468
|
-
'-y', String(rows),
|
|
469
|
-
'env', '-u', 'CLAUDECODE', command, ...args,
|
|
470
|
-
];
|
|
471
|
-
const result = spawnSync('tmux', tmuxArgs, { stdio: 'ignore' });
|
|
472
|
-
if (result.status !== 0) {
|
|
473
|
-
log('WARN', `tmux new-session exited with code ${result.status} for "${sessionName}"`);
|
|
474
|
-
return null;
|
|
475
|
-
}
|
|
476
|
-
// Hide tmux status bar (dashboard has its own tabs) and enable mouse.
|
|
477
|
-
// NOTE: aggressive-resize was removed — it caused resize bouncing and
|
|
478
|
-
// visual flashing (dots/redraws) when the dashboard sent multiple resize
|
|
479
|
-
// events during layout settling. Default tmux behavior (size to smallest
|
|
480
|
-
// client) is more stable since we only have one client per session.
|
|
481
|
-
spawnSync('tmux', ['set-option', '-t', sessionName, 'status', 'off'], { 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' });
|
|
493
|
-
return sessionName;
|
|
494
|
-
}
|
|
495
|
-
catch (err) {
|
|
496
|
-
log('WARN', `Failed to create tmux session "${sessionName}": ${err.message}`);
|
|
497
|
-
return null;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
/**
|
|
501
|
-
* Check if a tmux session exists.
|
|
502
|
-
* Sanitizes the name to handle legacy entries stored before dot-replacement fix.
|
|
503
|
-
*/
|
|
504
|
-
function tmuxSessionExists(sessionName) {
|
|
505
|
-
const sanitized = sanitizeTmuxSessionName(sessionName);
|
|
506
|
-
try {
|
|
507
|
-
execSync(`tmux has-session -t "${sanitized}" 2>/dev/null`, { stdio: 'ignore' });
|
|
508
|
-
return true;
|
|
509
|
-
}
|
|
510
|
-
catch {
|
|
511
|
-
return false;
|
|
512
|
-
}
|
|
513
|
-
}
|
|
431
|
+
// Shepherd session manager (initialized at startup)
|
|
432
|
+
let shepherdManager = null;
|
|
514
433
|
/**
|
|
515
434
|
* Check if a process is running
|
|
516
435
|
*/
|
|
@@ -523,220 +442,160 @@ function processExists(pid) {
|
|
|
523
442
|
return false;
|
|
524
443
|
}
|
|
525
444
|
}
|
|
526
|
-
/**
|
|
527
|
-
* Kill a tmux session by name
|
|
528
|
-
*/
|
|
529
|
-
function killTmuxSession(sessionName) {
|
|
530
|
-
try {
|
|
531
|
-
execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { stdio: 'ignore' });
|
|
532
|
-
log('INFO', `Killed orphaned tmux session: ${sessionName}`);
|
|
533
|
-
}
|
|
534
|
-
catch {
|
|
535
|
-
// Session may have already died
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
// ============================================================================
|
|
539
|
-
// Tmux-First Discovery (tmux is source of truth for existence)
|
|
540
|
-
// ============================================================================
|
|
541
|
-
/**
|
|
542
|
-
* List all tmux sessions that match codev naming conventions.
|
|
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.
|
|
546
|
-
*/
|
|
547
|
-
// Cache for listCodevTmuxSessions — avoid shelling out on every dashboard poll
|
|
548
|
-
let _tmuxListCache = [];
|
|
549
|
-
let _tmuxListCacheTime = 0;
|
|
550
|
-
const TMUX_LIST_CACHE_TTL = 10_000; // 10 seconds
|
|
551
|
-
function listCodevTmuxSessions(bypassCache = false) {
|
|
552
|
-
if (!tmuxAvailable)
|
|
553
|
-
return [];
|
|
554
|
-
const now = Date.now();
|
|
555
|
-
if (!bypassCache && now - _tmuxListCacheTime < TMUX_LIST_CACHE_TTL) {
|
|
556
|
-
return _tmuxListCache;
|
|
557
|
-
}
|
|
558
|
-
try {
|
|
559
|
-
const result = execSync('tmux list-sessions -F "#{session_name}" 2>/dev/null', { encoding: 'utf-8' });
|
|
560
|
-
const sessions = result.trim().split('\n').filter(Boolean);
|
|
561
|
-
const codevSessions = [];
|
|
562
|
-
for (const name of sessions) {
|
|
563
|
-
const parsed = parseTmuxSessionName(name);
|
|
564
|
-
if (parsed) {
|
|
565
|
-
codevSessions.push({ tmuxName: name, parsed });
|
|
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
|
-
}
|
|
571
|
-
}
|
|
572
|
-
_tmuxListCache = codevSessions;
|
|
573
|
-
_tmuxListCacheTime = now;
|
|
574
|
-
return codevSessions;
|
|
575
|
-
}
|
|
576
|
-
catch {
|
|
577
|
-
_tmuxListCache = [];
|
|
578
|
-
_tmuxListCacheTime = now;
|
|
579
|
-
return [];
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
/**
|
|
583
|
-
* Find the SQLite row that matches a given tmux session name.
|
|
584
|
-
* Looks up by tmux_session column directly.
|
|
585
|
-
*/
|
|
586
|
-
function findSqliteRowForTmuxSession(tmuxName) {
|
|
587
|
-
try {
|
|
588
|
-
const db = getGlobalDb();
|
|
589
|
-
return db.prepare('SELECT * FROM terminal_sessions WHERE tmux_session = ?').get(tmuxName) || null;
|
|
590
|
-
}
|
|
591
|
-
catch {
|
|
592
|
-
return null;
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
/**
|
|
596
|
-
* Find the full project path for a tmux session's project basename.
|
|
597
|
-
* Checks known projects (terminal_sessions + in-memory cache) for a matching basename.
|
|
598
|
-
* Returns null if no match found.
|
|
599
|
-
*/
|
|
600
|
-
function resolveProjectPathFromBasename(projectBasename) {
|
|
601
|
-
const knownPaths = getKnownProjectPaths();
|
|
602
|
-
for (const projectPath of knownPaths) {
|
|
603
|
-
if (path.basename(projectPath) === projectBasename) {
|
|
604
|
-
return normalizeProjectPath(projectPath);
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
return null;
|
|
608
|
-
}
|
|
609
445
|
/**
|
|
610
446
|
* Reconcile terminal sessions on startup.
|
|
611
447
|
*
|
|
612
|
-
* DUAL-SOURCE STRATEGY (
|
|
448
|
+
* DUAL-SOURCE STRATEGY (shepherd + SQLite):
|
|
613
449
|
*
|
|
614
|
-
*
|
|
615
|
-
* SQLite
|
|
450
|
+
* Phase 1 — Shepherd reconnection:
|
|
451
|
+
* For SQLite rows with shepherd_socket IS NOT NULL, attempt to reconnect
|
|
452
|
+
* via SessionManager.reconnectSession(). Shepherd processes survive Tower
|
|
453
|
+
* restarts as detached OS processes.
|
|
616
454
|
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
* cannot track process liveness — a row may exist for a terminal whose process
|
|
620
|
-
* has long since exited. Therefore:
|
|
621
|
-
* - We NEVER trust SQLite alone to determine if a terminal is running.
|
|
622
|
-
* - We ALWAYS check tmux for liveness, then use SQLite for enrichment.
|
|
455
|
+
* Phase 2 — SQLite sweep:
|
|
456
|
+
* Any rows not matched in Phase 1 are stale → clean up.
|
|
623
457
|
*
|
|
624
458
|
* File tabs are the exception: they have no backing process, so SQLite is
|
|
625
459
|
* the sole source of truth for their persistence (see file_tabs table).
|
|
626
|
-
*
|
|
627
|
-
* Phase 1 — tmux-first discovery:
|
|
628
|
-
* List all codev tmux sessions. For each, look up SQLite for metadata.
|
|
629
|
-
* If SQLite has a matching row → reconnect with full metadata.
|
|
630
|
-
* If SQLite has no row (orphaned tmux) → derive metadata from session name, reconnect.
|
|
631
|
-
*
|
|
632
|
-
* Phase 2 — SQLite sweep:
|
|
633
|
-
* Any SQLite rows not matched to a tmux session are stale → clean up.
|
|
634
|
-
* (Also kills orphaned processes that have no tmux backing.)
|
|
635
460
|
*/
|
|
636
461
|
async function reconcileTerminalSessions() {
|
|
637
462
|
const manager = getTerminalManager();
|
|
638
463
|
const db = getGlobalDb();
|
|
639
|
-
|
|
640
|
-
const liveTmuxSessions = listCodevTmuxSessions(/* bypassCache */ true);
|
|
641
|
-
// Track which SQLite rows we matched (by tmux_session name)
|
|
642
|
-
const matchedTmuxNames = new Set();
|
|
643
|
-
let reconnected = 0;
|
|
464
|
+
let shepherdReconnected = 0;
|
|
644
465
|
let orphanReconnected = 0;
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
466
|
+
let killed = 0;
|
|
467
|
+
let cleaned = 0;
|
|
468
|
+
// Track matched session IDs across all phases
|
|
469
|
+
const matchedSessionIds = new Set();
|
|
470
|
+
// ---- Phase 1: Shepherd reconnection ----
|
|
471
|
+
let allDbSessions;
|
|
472
|
+
try {
|
|
473
|
+
allDbSessions = db.prepare('SELECT * FROM terminal_sessions').all();
|
|
474
|
+
}
|
|
475
|
+
catch (err) {
|
|
476
|
+
log('WARN', `Failed to read terminal sessions: ${err.message}`);
|
|
477
|
+
allDbSessions = [];
|
|
478
|
+
}
|
|
479
|
+
const shepherdSessions = allDbSessions.filter(s => s.shepherd_socket !== null);
|
|
480
|
+
if (shepherdSessions.length > 0) {
|
|
481
|
+
log('INFO', `Found ${shepherdSessions.length} shepherd session(s) in SQLite — reconnecting...`);
|
|
482
|
+
}
|
|
483
|
+
for (const dbSession of shepherdSessions) {
|
|
484
|
+
const projectPath = dbSession.project_path;
|
|
485
|
+
// Skip sessions whose project path doesn't exist or is in temp directory
|
|
662
486
|
if (!fs.existsSync(projectPath)) {
|
|
663
|
-
log('INFO', `Skipping
|
|
664
|
-
|
|
665
|
-
if (
|
|
666
|
-
|
|
487
|
+
log('INFO', `Skipping shepherd session ${dbSession.id} — project path no longer exists: ${projectPath}`);
|
|
488
|
+
// Kill orphaned shepherd process before removing row
|
|
489
|
+
if (dbSession.shepherd_pid && processExists(dbSession.shepherd_pid)) {
|
|
490
|
+
try {
|
|
491
|
+
process.kill(dbSession.shepherd_pid, 'SIGTERM');
|
|
492
|
+
killed++;
|
|
493
|
+
}
|
|
494
|
+
catch { /* not killable */ }
|
|
495
|
+
}
|
|
496
|
+
db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbSession.id);
|
|
497
|
+
cleaned++;
|
|
667
498
|
continue;
|
|
668
499
|
}
|
|
669
500
|
const tmpDirs = ['/tmp', '/private/tmp', '/var/folders', '/private/var/folders'];
|
|
670
|
-
if (tmpDirs.some(d => projectPath.startsWith(d))) {
|
|
671
|
-
log('INFO', `Skipping
|
|
672
|
-
|
|
673
|
-
if (
|
|
674
|
-
|
|
501
|
+
if (tmpDirs.some(d => projectPath === d || projectPath.startsWith(d + '/'))) {
|
|
502
|
+
log('INFO', `Skipping shepherd session ${dbSession.id} — project is in temp directory: ${projectPath}`);
|
|
503
|
+
// Kill orphaned shepherd process before removing row
|
|
504
|
+
if (dbSession.shepherd_pid && processExists(dbSession.shepherd_pid)) {
|
|
505
|
+
try {
|
|
506
|
+
process.kill(dbSession.shepherd_pid, 'SIGTERM');
|
|
507
|
+
killed++;
|
|
508
|
+
}
|
|
509
|
+
catch { /* not killable */ }
|
|
510
|
+
}
|
|
511
|
+
db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbSession.id);
|
|
512
|
+
cleaned++;
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (!shepherdManager) {
|
|
516
|
+
log('WARN', `Shepherd manager not initialized — cannot reconnect ${dbSession.id}`);
|
|
675
517
|
continue;
|
|
676
518
|
}
|
|
677
519
|
try {
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
520
|
+
// For architect sessions, restore auto-restart behavior after reconnection
|
|
521
|
+
let restartOptions;
|
|
522
|
+
if (dbSession.type === 'architect') {
|
|
523
|
+
let architectCmd = 'claude';
|
|
524
|
+
const configPath = path.join(projectPath, 'af-config.json');
|
|
525
|
+
if (fs.existsSync(configPath)) {
|
|
526
|
+
try {
|
|
527
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
528
|
+
if (config.shell?.architect) {
|
|
529
|
+
architectCmd = config.shell.architect;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
catch { /* use default */ }
|
|
533
|
+
}
|
|
534
|
+
const cmdParts = architectCmd.split(/\s+/);
|
|
535
|
+
const cleanEnv = { ...process.env };
|
|
536
|
+
delete cleanEnv['CLAUDECODE'];
|
|
537
|
+
restartOptions = {
|
|
538
|
+
command: cmdParts[0],
|
|
539
|
+
args: cmdParts.slice(1),
|
|
540
|
+
cwd: projectPath,
|
|
541
|
+
env: cleanEnv,
|
|
542
|
+
restartDelay: 2000,
|
|
543
|
+
maxRestarts: 50,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
const client = await shepherdManager.reconnectSession(dbSession.id, dbSession.shepherd_socket, dbSession.shepherd_pid, dbSession.shepherd_start_time, restartOptions);
|
|
547
|
+
if (!client) {
|
|
548
|
+
log('INFO', `Shepherd session ${dbSession.id} is stale (PID/socket dead) — will clean up`);
|
|
549
|
+
continue; // Will be cleaned up in Phase 3
|
|
550
|
+
}
|
|
551
|
+
const replayData = client.getReplayData() ?? Buffer.alloc(0);
|
|
552
|
+
const label = dbSession.type === 'architect' ? 'Architect' : `${dbSession.type} ${dbSession.role_id || 'unknown'}`;
|
|
553
|
+
// Create a PtySession backed by the reconnected shepherd client
|
|
554
|
+
const session = manager.createSessionRaw({ label, cwd: projectPath });
|
|
555
|
+
const ptySession = manager.getSession(session.id);
|
|
556
|
+
if (ptySession) {
|
|
557
|
+
ptySession.attachShepherd(client, replayData, dbSession.shepherd_pid, dbSession.id);
|
|
558
|
+
}
|
|
685
559
|
// Register in projectTerminals Map
|
|
686
560
|
const entry = getProjectTerminalsEntry(projectPath);
|
|
687
|
-
if (type === 'architect') {
|
|
688
|
-
entry.architect =
|
|
689
|
-
}
|
|
690
|
-
else if (type === 'builder') {
|
|
691
|
-
entry.builders.set(roleId || tmuxName, newSession.id);
|
|
561
|
+
if (dbSession.type === 'architect') {
|
|
562
|
+
entry.architect = session.id;
|
|
692
563
|
}
|
|
693
|
-
else if (type === '
|
|
694
|
-
entry.
|
|
564
|
+
else if (dbSession.type === 'builder') {
|
|
565
|
+
entry.builders.set(dbSession.role_id || dbSession.id, session.id);
|
|
695
566
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbRow.id);
|
|
567
|
+
else if (dbSession.type === 'shell') {
|
|
568
|
+
entry.shells.set(dbSession.role_id || dbSession.id, session.id);
|
|
699
569
|
}
|
|
700
|
-
|
|
570
|
+
// Update SQLite with new terminal ID
|
|
571
|
+
db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbSession.id);
|
|
572
|
+
saveTerminalSession(session.id, projectPath, dbSession.type, dbSession.role_id, dbSession.shepherd_pid, dbSession.shepherd_socket, dbSession.shepherd_pid, dbSession.shepherd_start_time);
|
|
701
573
|
registerKnownProject(projectPath);
|
|
702
|
-
//
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
orphanReconnected++;
|
|
574
|
+
// Clean up on exit
|
|
575
|
+
if (ptySession) {
|
|
576
|
+
ptySession.on('exit', () => {
|
|
577
|
+
const currentEntry = getProjectTerminalsEntry(projectPath);
|
|
578
|
+
if (dbSession.type === 'architect' && currentEntry.architect === session.id) {
|
|
579
|
+
currentEntry.architect = undefined;
|
|
580
|
+
}
|
|
581
|
+
deleteTerminalSession(session.id);
|
|
582
|
+
});
|
|
712
583
|
}
|
|
584
|
+
matchedSessionIds.add(dbSession.id);
|
|
585
|
+
shepherdReconnected++;
|
|
586
|
+
log('INFO', `Reconnected shepherd session → ${session.id} (${dbSession.type} for ${path.basename(projectPath)})`);
|
|
713
587
|
}
|
|
714
588
|
catch (err) {
|
|
715
|
-
log('WARN', `Failed to reconnect
|
|
589
|
+
log('WARN', `Failed to reconnect shepherd session ${dbSession.id}: ${err.message}`);
|
|
716
590
|
}
|
|
717
591
|
}
|
|
718
|
-
// Phase 2: Sweep stale SQLite rows
|
|
719
|
-
let killed = 0;
|
|
720
|
-
let cleaned = 0;
|
|
721
|
-
let allDbSessions;
|
|
722
|
-
try {
|
|
723
|
-
allDbSessions = db.prepare('SELECT * FROM terminal_sessions').all();
|
|
724
|
-
}
|
|
725
|
-
catch (err) {
|
|
726
|
-
log('WARN', `Failed to read terminal sessions for sweep: ${err.message}`);
|
|
727
|
-
allDbSessions = [];
|
|
728
|
-
}
|
|
592
|
+
// ---- Phase 2: Sweep stale SQLite rows ----
|
|
729
593
|
for (const session of allDbSessions) {
|
|
730
|
-
|
|
731
|
-
if (session.tmux_session && matchedTmuxNames.has(session.tmux_session)) {
|
|
594
|
+
if (matchedSessionIds.has(session.id))
|
|
732
595
|
continue;
|
|
733
|
-
}
|
|
734
|
-
// Also skip rows whose terminal is still alive in PtyManager
|
|
735
|
-
// (non-tmux sessions created during this Tower run)
|
|
736
596
|
const existing = manager.getSession(session.id);
|
|
737
|
-
if (existing && existing.status !== 'exited')
|
|
597
|
+
if (existing && existing.status !== 'exited')
|
|
738
598
|
continue;
|
|
739
|
-
}
|
|
740
599
|
// Stale row — kill orphaned process if any, then delete
|
|
741
600
|
if (session.pid && processExists(session.pid)) {
|
|
742
601
|
log('INFO', `Killing orphaned process: PID ${session.pid} (${session.type} for ${path.basename(session.project_path)})`);
|
|
@@ -744,16 +603,14 @@ async function reconcileTerminalSessions() {
|
|
|
744
603
|
process.kill(session.pid, 'SIGTERM');
|
|
745
604
|
killed++;
|
|
746
605
|
}
|
|
747
|
-
catch {
|
|
748
|
-
// Process may not be killable
|
|
749
|
-
}
|
|
606
|
+
catch { /* process not killable */ }
|
|
750
607
|
}
|
|
751
608
|
db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(session.id);
|
|
752
609
|
cleaned++;
|
|
753
610
|
}
|
|
754
|
-
const total =
|
|
611
|
+
const total = shepherdReconnected + orphanReconnected;
|
|
755
612
|
if (total > 0 || killed > 0 || cleaned > 0) {
|
|
756
|
-
log('INFO', `Reconciliation complete: ${
|
|
613
|
+
log('INFO', `Reconciliation complete: ${shepherdReconnected} shepherd, ${orphanReconnected} orphan, ${killed} killed, ${cleaned} stale rows cleaned`);
|
|
757
614
|
}
|
|
758
615
|
else {
|
|
759
616
|
log('INFO', 'No terminal sessions to reconcile');
|
|
@@ -909,6 +766,15 @@ async function gracefulShutdown(signal) {
|
|
|
909
766
|
log('INFO', 'Shutting down terminal manager...');
|
|
910
767
|
terminalManager.shutdown();
|
|
911
768
|
}
|
|
769
|
+
// 3b. Shepherd clients: do NOT call shepherdManager.shutdown() here.
|
|
770
|
+
// SessionManager.shutdown() disconnects sockets, which triggers ShepherdClient
|
|
771
|
+
// 'close' events → PtySession exit(-1) → SQLite row deletion. This would erase
|
|
772
|
+
// the rows that reconcileTerminalSessions() needs on restart.
|
|
773
|
+
// Instead, let the process exit naturally — OS closes all sockets, and shepherds
|
|
774
|
+
// detect the disconnection and keep running. SQLite rows are preserved.
|
|
775
|
+
if (shepherdManager) {
|
|
776
|
+
log('INFO', 'Shepherd sessions will continue running (sockets close on process exit)');
|
|
777
|
+
}
|
|
912
778
|
// 4. Stop gate watcher
|
|
913
779
|
if (gateWatcherInterval) {
|
|
914
780
|
clearInterval(gateWatcherInterval);
|
|
@@ -1032,7 +898,7 @@ function broadcastNotification(notification) {
|
|
|
1032
898
|
async function getTerminalsForProject(projectPath, proxyUrl) {
|
|
1033
899
|
const manager = getTerminalManager();
|
|
1034
900
|
const terminals = [];
|
|
1035
|
-
// Query SQLite first, then augment with
|
|
901
|
+
// Query SQLite first, then augment with shepherd reconnection
|
|
1036
902
|
const dbSessions = getTerminalSessionsForProject(projectPath);
|
|
1037
903
|
// Use normalized path for cache consistency
|
|
1038
904
|
const normalizedPath = normalizeProjectPath(projectPath);
|
|
@@ -1053,31 +919,65 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
|
|
|
1053
919
|
for (const dbSession of dbSessions) {
|
|
1054
920
|
// Verify session still exists in TerminalManager (runtime state)
|
|
1055
921
|
let session = manager.getSession(dbSession.id);
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
// PTY session gone but tmux session survives — reconnect on-the-fly
|
|
922
|
+
if (!session && dbSession.shepherd_socket && shepherdManager) {
|
|
923
|
+
// PTY session gone but shepherd may still be alive — reconnect on-the-fly
|
|
1059
924
|
try {
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
925
|
+
// Restore auto-restart for architect sessions (same as startup reconciliation)
|
|
926
|
+
let restartOptions;
|
|
927
|
+
if (dbSession.type === 'architect') {
|
|
928
|
+
let architectCmd = 'claude';
|
|
929
|
+
const configPath = path.join(dbSession.project_path, 'af-config.json');
|
|
930
|
+
if (fs.existsSync(configPath)) {
|
|
931
|
+
try {
|
|
932
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
933
|
+
if (config.shell?.architect) {
|
|
934
|
+
architectCmd = config.shell.architect;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
catch { /* use default */ }
|
|
938
|
+
}
|
|
939
|
+
const cmdParts = architectCmd.split(/\s+/);
|
|
940
|
+
const cleanEnv = { ...process.env };
|
|
941
|
+
delete cleanEnv['CLAUDECODE'];
|
|
942
|
+
restartOptions = {
|
|
943
|
+
command: cmdParts[0],
|
|
944
|
+
args: cmdParts.slice(1),
|
|
945
|
+
cwd: dbSession.project_path,
|
|
946
|
+
env: cleanEnv,
|
|
947
|
+
restartDelay: 2000,
|
|
948
|
+
maxRestarts: 50,
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
const client = await shepherdManager.reconnectSession(dbSession.id, dbSession.shepherd_socket, dbSession.shepherd_pid, dbSession.shepherd_start_time, restartOptions);
|
|
952
|
+
if (client) {
|
|
953
|
+
const replayData = client.getReplayData() ?? Buffer.alloc(0);
|
|
954
|
+
const label = dbSession.type === 'architect' ? 'Architect' : `${dbSession.type} ${dbSession.role_id || dbSession.id}`;
|
|
955
|
+
const newSession = manager.createSessionRaw({ label, cwd: dbSession.project_path });
|
|
956
|
+
const ptySession = manager.getSession(newSession.id);
|
|
957
|
+
if (ptySession) {
|
|
958
|
+
ptySession.attachShepherd(client, replayData, dbSession.shepherd_pid, dbSession.id);
|
|
959
|
+
// Clean up on exit (same as startup reconciliation path)
|
|
960
|
+
ptySession.on('exit', () => {
|
|
961
|
+
const currentEntry = getProjectTerminalsEntry(dbSession.project_path);
|
|
962
|
+
if (dbSession.type === 'architect' && currentEntry.architect === newSession.id) {
|
|
963
|
+
currentEntry.architect = undefined;
|
|
964
|
+
}
|
|
965
|
+
deleteTerminalSession(newSession.id);
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
deleteTerminalSession(dbSession.id);
|
|
969
|
+
saveTerminalSession(newSession.id, dbSession.project_path, dbSession.type, dbSession.role_id, dbSession.shepherd_pid, dbSession.shepherd_socket, dbSession.shepherd_pid, dbSession.shepherd_start_time);
|
|
970
|
+
dbSession.id = newSession.id;
|
|
971
|
+
session = manager.getSession(newSession.id);
|
|
972
|
+
log('INFO', `Reconnected to shepherd on-the-fly → ${newSession.id}`);
|
|
973
|
+
}
|
|
1073
974
|
}
|
|
1074
975
|
catch (err) {
|
|
1075
|
-
log('WARN', `Failed
|
|
1076
|
-
continue;
|
|
976
|
+
log('WARN', `Failed shepherd on-the-fly reconnect for ${dbSession.id}: ${err.message}`);
|
|
1077
977
|
}
|
|
1078
978
|
}
|
|
1079
|
-
|
|
1080
|
-
// Stale row
|
|
979
|
+
if (!session) {
|
|
980
|
+
// Stale row, nothing to reconnect — clean up
|
|
1081
981
|
deleteTerminalSession(dbSession.id);
|
|
1082
982
|
continue;
|
|
1083
983
|
}
|
|
@@ -1161,54 +1061,6 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
|
|
|
1161
1061
|
}
|
|
1162
1062
|
}
|
|
1163
1063
|
}
|
|
1164
|
-
// Phase 3: tmux discovery — find tmux sessions for this project that are
|
|
1165
|
-
// missing from both SQLite and the in-memory cache.
|
|
1166
|
-
// This is the safety net: if SQLite rows got deleted but tmux survived,
|
|
1167
|
-
// the session will still appear in the dashboard.
|
|
1168
|
-
const projectBasename = sanitizeTmuxSessionName(path.basename(normalizedPath));
|
|
1169
|
-
const liveTmux = listCodevTmuxSessions();
|
|
1170
|
-
for (const { tmuxName, parsed } of liveTmux) {
|
|
1171
|
-
// Skip sessions we couldn't fully parse (no projectBasename to match)
|
|
1172
|
-
if (!parsed)
|
|
1173
|
-
continue;
|
|
1174
|
-
// Only process sessions whose sanitized project basename matches
|
|
1175
|
-
if (parsed.projectBasename !== projectBasename)
|
|
1176
|
-
continue;
|
|
1177
|
-
// Skip if we already have this session registered (from SQLite or in-memory)
|
|
1178
|
-
const alreadyRegistered = (parsed.type === 'architect' && freshEntry.architect) ||
|
|
1179
|
-
(parsed.type === 'builder' && parsed.roleId && freshEntry.builders.has(parsed.roleId)) ||
|
|
1180
|
-
(parsed.type === 'shell' && parsed.roleId && freshEntry.shells.has(parsed.roleId));
|
|
1181
|
-
if (alreadyRegistered)
|
|
1182
|
-
continue;
|
|
1183
|
-
// Orphaned tmux session — reconnect it.
|
|
1184
|
-
// Skip architect sessions: launchInstance handles creation/reconnection.
|
|
1185
|
-
if (parsed.type === 'architect')
|
|
1186
|
-
continue;
|
|
1187
|
-
try {
|
|
1188
|
-
const label = `${parsed.type} ${parsed.roleId || 'unknown'}`;
|
|
1189
|
-
const newSession = await manager.createSession({
|
|
1190
|
-
command: 'tmux',
|
|
1191
|
-
args: ['attach-session', '-t', tmuxName],
|
|
1192
|
-
cwd: normalizedPath,
|
|
1193
|
-
label,
|
|
1194
|
-
});
|
|
1195
|
-
const roleId = parsed.roleId;
|
|
1196
|
-
if (parsed.type === 'builder' && roleId) {
|
|
1197
|
-
freshEntry.builders.set(roleId, newSession.id);
|
|
1198
|
-
terminals.push({ type: 'builder', id: roleId, label: `Builder ${roleId}`, url: `${proxyUrl}?tab=builder-${roleId}`, active: true });
|
|
1199
|
-
}
|
|
1200
|
-
else if (parsed.type === 'shell' && roleId) {
|
|
1201
|
-
freshEntry.shells.set(roleId, newSession.id);
|
|
1202
|
-
terminals.push({ type: 'shell', id: roleId, label: `Shell ${roleId.replace('shell-', '')}`, url: `${proxyUrl}?tab=shell-${roleId}`, active: true });
|
|
1203
|
-
}
|
|
1204
|
-
// Persist to SQLite so future polls find it directly
|
|
1205
|
-
saveTerminalSession(newSession.id, normalizedPath, parsed.type, roleId, newSession.pid, tmuxName);
|
|
1206
|
-
log('INFO', `[tmux-discovery] Recovered orphaned tmux "${tmuxName}" → ${newSession.id} (${parsed.type})`);
|
|
1207
|
-
}
|
|
1208
|
-
catch (err) {
|
|
1209
|
-
log('WARN', `[tmux-discovery] Failed to recover tmux "${tmuxName}": ${err.message}`);
|
|
1210
|
-
}
|
|
1211
|
-
}
|
|
1212
1064
|
// Atomically replace the cache entry
|
|
1213
1065
|
projectTerminals.set(normalizedPath, freshEntry);
|
|
1214
1066
|
// Read gate status from porch YAML files
|
|
@@ -1403,59 +1255,85 @@ async function launchInstance(projectPath) {
|
|
|
1403
1255
|
try {
|
|
1404
1256
|
// Parse command string to separate command and args
|
|
1405
1257
|
const cmdParts = architectCmd.split(/\s+/);
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
//
|
|
1409
|
-
|
|
1410
|
-
const
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1258
|
+
const cmd = cmdParts[0];
|
|
1259
|
+
const cmdArgs = cmdParts.slice(1);
|
|
1260
|
+
// Build env with CLAUDECODE removed so spawned Claude processes
|
|
1261
|
+
// don't detect a nested session
|
|
1262
|
+
const cleanEnv = { ...process.env };
|
|
1263
|
+
delete cleanEnv['CLAUDECODE'];
|
|
1264
|
+
// Try shepherd first for persistent session with auto-restart
|
|
1265
|
+
let shepherdCreated = false;
|
|
1266
|
+
if (shepherdManager) {
|
|
1267
|
+
try {
|
|
1268
|
+
const sessionId = crypto.randomUUID();
|
|
1269
|
+
const client = await shepherdManager.createSession({
|
|
1270
|
+
sessionId,
|
|
1271
|
+
command: cmd,
|
|
1272
|
+
args: cmdArgs,
|
|
1273
|
+
cwd: projectPath,
|
|
1274
|
+
env: cleanEnv,
|
|
1275
|
+
cols: 200,
|
|
1276
|
+
rows: 50,
|
|
1277
|
+
restartOnExit: true,
|
|
1278
|
+
restartDelay: 2000,
|
|
1279
|
+
maxRestarts: 50,
|
|
1280
|
+
});
|
|
1281
|
+
// Get replay data and shepherd info
|
|
1282
|
+
const replayData = client.getReplayData() ?? Buffer.alloc(0);
|
|
1283
|
+
const shepherdInfo = shepherdManager.getSessionInfo(sessionId);
|
|
1284
|
+
// Create a PtySession backed by the shepherd client
|
|
1285
|
+
const session = manager.createSessionRaw({
|
|
1286
|
+
label: 'Architect',
|
|
1287
|
+
cwd: projectPath,
|
|
1288
|
+
});
|
|
1289
|
+
const ptySession = manager.getSession(session.id);
|
|
1290
|
+
if (ptySession) {
|
|
1291
|
+
ptySession.attachShepherd(client, replayData, shepherdInfo.pid, sessionId);
|
|
1292
|
+
}
|
|
1293
|
+
entry.architect = session.id;
|
|
1294
|
+
saveTerminalSession(session.id, resolvedPath, 'architect', null, shepherdInfo.pid, shepherdInfo.socketPath, shepherdInfo.pid, shepherdInfo.startTime);
|
|
1295
|
+
// Clean up cache/SQLite when the shepherd session exits
|
|
1296
|
+
if (ptySession) {
|
|
1297
|
+
ptySession.on('exit', () => {
|
|
1298
|
+
const currentEntry = getProjectTerminalsEntry(resolvedPath);
|
|
1299
|
+
if (currentEntry.architect === session.id) {
|
|
1300
|
+
currentEntry.architect = undefined;
|
|
1301
|
+
}
|
|
1302
|
+
deleteTerminalSession(session.id);
|
|
1303
|
+
log('INFO', `Architect shepherd session exited for ${projectPath}`);
|
|
1304
|
+
});
|
|
1434
1305
|
}
|
|
1306
|
+
shepherdCreated = true;
|
|
1307
|
+
log('INFO', `Created shepherd-backed architect session for project: ${projectPath}`);
|
|
1308
|
+
}
|
|
1309
|
+
catch (shepherdErr) {
|
|
1310
|
+
log('WARN', `Shepherd creation failed for architect, falling back: ${shepherdErr.message}`);
|
|
1435
1311
|
}
|
|
1436
1312
|
}
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
saveTerminalSession(session.id, resolvedPath, 'architect', null, session.pid, activeTmuxSession);
|
|
1447
|
-
// Clean up cache/SQLite when the node-pty session exits.
|
|
1448
|
-
// Restart is handled by the while-true loop inside tmux (not here).
|
|
1449
|
-
const ptySession = manager.getSession(session.id);
|
|
1450
|
-
if (ptySession) {
|
|
1451
|
-
ptySession.on('exit', () => {
|
|
1452
|
-
const currentEntry = getProjectTerminalsEntry(resolvedPath);
|
|
1453
|
-
if (currentEntry.architect === session.id) {
|
|
1454
|
-
currentEntry.architect = undefined;
|
|
1455
|
-
}
|
|
1456
|
-
deleteTerminalSession(session.id);
|
|
1457
|
-
log('INFO', `Architect pty exited for ${projectPath} (tmux loop handles restart)`);
|
|
1313
|
+
// Fallback: non-persistent session (graceful degradation per plan)
|
|
1314
|
+
// Shepherd is the only persistence backend for new sessions.
|
|
1315
|
+
if (!shepherdCreated) {
|
|
1316
|
+
const session = await manager.createSession({
|
|
1317
|
+
command: cmd,
|
|
1318
|
+
args: cmdArgs,
|
|
1319
|
+
cwd: projectPath,
|
|
1320
|
+
label: 'Architect',
|
|
1321
|
+
env: cleanEnv,
|
|
1458
1322
|
});
|
|
1323
|
+
entry.architect = session.id;
|
|
1324
|
+
saveTerminalSession(session.id, resolvedPath, 'architect', null, session.pid);
|
|
1325
|
+
const ptySession = manager.getSession(session.id);
|
|
1326
|
+
if (ptySession) {
|
|
1327
|
+
ptySession.on('exit', () => {
|
|
1328
|
+
const currentEntry = getProjectTerminalsEntry(resolvedPath);
|
|
1329
|
+
if (currentEntry.architect === session.id) {
|
|
1330
|
+
currentEntry.architect = undefined;
|
|
1331
|
+
}
|
|
1332
|
+
deleteTerminalSession(session.id);
|
|
1333
|
+
log('INFO', `Architect pty exited for ${projectPath}`);
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
log('WARN', `Architect terminal for ${projectPath} is non-persistent (shepherd unavailable)`);
|
|
1459
1337
|
}
|
|
1460
1338
|
log('INFO', `Created architect terminal for project: ${projectPath}`);
|
|
1461
1339
|
}
|
|
@@ -1470,6 +1348,22 @@ async function launchInstance(projectPath) {
|
|
|
1470
1348
|
return { success: false, error: `Failed to launch: ${err.message}` };
|
|
1471
1349
|
}
|
|
1472
1350
|
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Kill a terminal session, including its shepherd auto-restart if applicable.
|
|
1353
|
+
* For shepherd-backed sessions, calls SessionManager.killSession() which clears
|
|
1354
|
+
* the restart timer and removes the session before sending SIGTERM, preventing
|
|
1355
|
+
* the shepherd from auto-restarting the process.
|
|
1356
|
+
*/
|
|
1357
|
+
async function killTerminalWithShepherd(manager, terminalId) {
|
|
1358
|
+
const session = manager.getSession(terminalId);
|
|
1359
|
+
if (!session)
|
|
1360
|
+
return false;
|
|
1361
|
+
// If shepherd-backed, disable auto-restart via SessionManager before killing the PtySession
|
|
1362
|
+
if (session.shepherdBacked && session.shepherdSessionId && shepherdManager) {
|
|
1363
|
+
await shepherdManager.killSession(session.shepherdSessionId);
|
|
1364
|
+
}
|
|
1365
|
+
return manager.killSession(terminalId);
|
|
1366
|
+
}
|
|
1473
1367
|
/**
|
|
1474
1368
|
* Stop an agent-farm instance by killing all its terminals
|
|
1475
1369
|
* Phase 4 (Spec 0090): Tower manages terminals directly
|
|
@@ -1490,39 +1384,30 @@ async function stopInstance(projectPath) {
|
|
|
1490
1384
|
// Get project terminals
|
|
1491
1385
|
const entry = projectTerminals.get(resolvedPath) || projectTerminals.get(projectPath);
|
|
1492
1386
|
if (entry) {
|
|
1493
|
-
//
|
|
1494
|
-
const dbSessions = getTerminalSessionsForProject(resolvedPath);
|
|
1495
|
-
const tmuxSessions = dbSessions
|
|
1496
|
-
.filter(s => s.tmux_session)
|
|
1497
|
-
.map(s => s.tmux_session);
|
|
1498
|
-
// Kill architect
|
|
1387
|
+
// Kill architect (disable shepherd auto-restart if applicable)
|
|
1499
1388
|
if (entry.architect) {
|
|
1500
1389
|
const session = manager.getSession(entry.architect);
|
|
1501
1390
|
if (session) {
|
|
1502
|
-
manager
|
|
1391
|
+
await killTerminalWithShepherd(manager, entry.architect);
|
|
1503
1392
|
stopped.push(session.pid);
|
|
1504
1393
|
}
|
|
1505
1394
|
}
|
|
1506
|
-
// Kill all shells
|
|
1395
|
+
// Kill all shells (disable shepherd auto-restart if applicable)
|
|
1507
1396
|
for (const terminalId of entry.shells.values()) {
|
|
1508
1397
|
const session = manager.getSession(terminalId);
|
|
1509
1398
|
if (session) {
|
|
1510
|
-
manager
|
|
1399
|
+
await killTerminalWithShepherd(manager, terminalId);
|
|
1511
1400
|
stopped.push(session.pid);
|
|
1512
1401
|
}
|
|
1513
1402
|
}
|
|
1514
|
-
// Kill all builders
|
|
1403
|
+
// Kill all builders (disable shepherd auto-restart if applicable)
|
|
1515
1404
|
for (const terminalId of entry.builders.values()) {
|
|
1516
1405
|
const session = manager.getSession(terminalId);
|
|
1517
1406
|
if (session) {
|
|
1518
|
-
manager
|
|
1407
|
+
await killTerminalWithShepherd(manager, terminalId);
|
|
1519
1408
|
stopped.push(session.pid);
|
|
1520
1409
|
}
|
|
1521
1410
|
}
|
|
1522
|
-
// Kill tmux sessions (node-pty kill only detaches, tmux keeps running)
|
|
1523
|
-
for (const tmuxName of tmuxSessions) {
|
|
1524
|
-
killTmuxSession(tmuxName);
|
|
1525
|
-
}
|
|
1526
1411
|
// Clear project from registry
|
|
1527
1412
|
projectTerminals.delete(resolvedPath);
|
|
1528
1413
|
projectTerminals.delete(projectPath);
|
|
@@ -1826,50 +1711,77 @@ const server = http.createServer(async (req, res) => {
|
|
|
1826
1711
|
const cwd = typeof body.cwd === 'string' ? body.cwd : undefined;
|
|
1827
1712
|
const env = typeof body.env === 'object' && body.env !== null ? body.env : undefined;
|
|
1828
1713
|
const label = typeof body.label === 'string' ? body.label : undefined;
|
|
1829
|
-
// Optional
|
|
1830
|
-
const tmuxSession = typeof body.tmuxSession === 'string' ? body.tmuxSession : null;
|
|
1831
|
-
let activeTmuxSession = null;
|
|
1832
|
-
if (tmuxSession && tmuxAvailable && command && cwd) {
|
|
1833
|
-
const sanitizedName = createTmuxSession(tmuxSession, command, args || [], cwd, cols || 200, rows || 50);
|
|
1834
|
-
if (sanitizedName) {
|
|
1835
|
-
// Override: node-pty attaches to the tmux session (use sanitized name)
|
|
1836
|
-
command = 'tmux';
|
|
1837
|
-
args = ['attach-session', '-t', sanitizedName];
|
|
1838
|
-
activeTmuxSession = sanitizedName;
|
|
1839
|
-
log('INFO', `Created tmux session "${sanitizedName}" for terminal`);
|
|
1840
|
-
}
|
|
1841
|
-
// If tmux creation failed, fall through to bare node-pty
|
|
1842
|
-
}
|
|
1843
|
-
let info;
|
|
1844
|
-
try {
|
|
1845
|
-
info = await manager.createSession({ command, args, cols, rows, cwd, env, label });
|
|
1846
|
-
}
|
|
1847
|
-
catch (createErr) {
|
|
1848
|
-
// Clean up orphaned tmux session if node-pty creation failed
|
|
1849
|
-
if (activeTmuxSession) {
|
|
1850
|
-
killTmuxSession(activeTmuxSession);
|
|
1851
|
-
log('WARN', `Cleaned up orphaned tmux session "${activeTmuxSession}" after node-pty failure`);
|
|
1852
|
-
}
|
|
1853
|
-
throw createErr;
|
|
1854
|
-
}
|
|
1855
|
-
// Optional project association: register terminal with project state
|
|
1714
|
+
// Optional session persistence via shepherd
|
|
1856
1715
|
const projectPath = typeof body.projectPath === 'string' ? body.projectPath : null;
|
|
1857
1716
|
const termType = typeof body.type === 'string' && ['builder', 'shell'].includes(body.type) ? body.type : null;
|
|
1858
1717
|
const roleId = typeof body.roleId === 'string' ? body.roleId : null;
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1718
|
+
const requestPersistence = body.persistent === true;
|
|
1719
|
+
let info;
|
|
1720
|
+
let persistent = false;
|
|
1721
|
+
// Try shepherd if persistence was requested
|
|
1722
|
+
if (requestPersistence && shepherdManager && command && cwd) {
|
|
1723
|
+
try {
|
|
1724
|
+
const sessionId = crypto.randomUUID();
|
|
1725
|
+
// Strip CLAUDECODE so spawned Claude processes don't detect nesting
|
|
1726
|
+
const sessionEnv = { ...(env || process.env) };
|
|
1727
|
+
delete sessionEnv['CLAUDECODE'];
|
|
1728
|
+
const client = await shepherdManager.createSession({
|
|
1729
|
+
sessionId,
|
|
1730
|
+
command,
|
|
1731
|
+
args: args || [],
|
|
1732
|
+
cwd,
|
|
1733
|
+
env: sessionEnv,
|
|
1734
|
+
cols: cols || 200,
|
|
1735
|
+
rows: 50,
|
|
1736
|
+
restartOnExit: false,
|
|
1737
|
+
});
|
|
1738
|
+
const replayData = client.getReplayData() ?? Buffer.alloc(0);
|
|
1739
|
+
const shepherdInfo = shepherdManager.getSessionInfo(sessionId);
|
|
1740
|
+
const session = manager.createSessionRaw({
|
|
1741
|
+
label: label || `terminal-${sessionId.slice(0, 8)}`,
|
|
1742
|
+
cwd,
|
|
1743
|
+
});
|
|
1744
|
+
const ptySession = manager.getSession(session.id);
|
|
1745
|
+
if (ptySession) {
|
|
1746
|
+
ptySession.attachShepherd(client, replayData, shepherdInfo.pid, sessionId);
|
|
1747
|
+
}
|
|
1748
|
+
info = session;
|
|
1749
|
+
persistent = true;
|
|
1750
|
+
if (projectPath && termType && roleId) {
|
|
1751
|
+
const entry = getProjectTerminalsEntry(normalizeProjectPath(projectPath));
|
|
1752
|
+
if (termType === 'builder') {
|
|
1753
|
+
entry.builders.set(roleId, session.id);
|
|
1754
|
+
}
|
|
1755
|
+
else {
|
|
1756
|
+
entry.shells.set(roleId, session.id);
|
|
1757
|
+
}
|
|
1758
|
+
saveTerminalSession(session.id, projectPath, termType, roleId, shepherdInfo.pid, shepherdInfo.socketPath, shepherdInfo.pid, shepherdInfo.startTime);
|
|
1759
|
+
log('INFO', `Registered shepherd terminal ${session.id} as ${termType} "${roleId}" for project ${projectPath}`);
|
|
1760
|
+
}
|
|
1863
1761
|
}
|
|
1864
|
-
|
|
1865
|
-
|
|
1762
|
+
catch (shepherdErr) {
|
|
1763
|
+
log('WARN', `Shepherd creation failed for terminal, falling back: ${shepherdErr.message}`);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
// Fallback: non-persistent session (graceful degradation per plan)
|
|
1767
|
+
// Shepherd is the only persistence backend for new sessions.
|
|
1768
|
+
if (!info) {
|
|
1769
|
+
info = await manager.createSession({ command, args, cols, rows, cwd, env, label });
|
|
1770
|
+
persistent = false;
|
|
1771
|
+
if (projectPath && termType && roleId) {
|
|
1772
|
+
const entry = getProjectTerminalsEntry(normalizeProjectPath(projectPath));
|
|
1773
|
+
if (termType === 'builder') {
|
|
1774
|
+
entry.builders.set(roleId, info.id);
|
|
1775
|
+
}
|
|
1776
|
+
else {
|
|
1777
|
+
entry.shells.set(roleId, info.id);
|
|
1778
|
+
}
|
|
1779
|
+
saveTerminalSession(info.id, projectPath, termType, roleId, info.pid);
|
|
1780
|
+
log('WARN', `Terminal ${info.id} for ${projectPath} is non-persistent (shepherd unavailable)`);
|
|
1866
1781
|
}
|
|
1867
|
-
saveTerminalSession(info.id, projectPath, termType, roleId, info.pid, activeTmuxSession);
|
|
1868
|
-
log('INFO', `Registered terminal ${info.id} as ${termType} "${roleId}" for project ${projectPath}${activeTmuxSession ? ` (tmux: ${activeTmuxSession})` : ''}`);
|
|
1869
1782
|
}
|
|
1870
|
-
// Return tmuxSession so caller knows whether tmux is backing this terminal
|
|
1871
1783
|
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
1872
|
-
res.end(JSON.stringify({ ...info, wsPath: `/ws/terminal/${info.id}`,
|
|
1784
|
+
res.end(JSON.stringify({ ...info, wsPath: `/ws/terminal/${info.id}`, persistent }));
|
|
1873
1785
|
}
|
|
1874
1786
|
catch (err) {
|
|
1875
1787
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
@@ -1904,9 +1816,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1904
1816
|
res.end(JSON.stringify(session.info));
|
|
1905
1817
|
return;
|
|
1906
1818
|
}
|
|
1907
|
-
// DELETE /api/terminals/:id - Kill terminal
|
|
1819
|
+
// DELETE /api/terminals/:id - Kill terminal (disable shepherd auto-restart if applicable)
|
|
1908
1820
|
if (req.method === 'DELETE' && (!subpath || subpath === '')) {
|
|
1909
|
-
if (!manager
|
|
1821
|
+
if (!(await killTerminalWithShepherd(manager, terminalId))) {
|
|
1910
1822
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1911
1823
|
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1912
1824
|
return;
|
|
@@ -1917,6 +1829,31 @@ const server = http.createServer(async (req, res) => {
|
|
|
1917
1829
|
res.end();
|
|
1918
1830
|
return;
|
|
1919
1831
|
}
|
|
1832
|
+
// POST /api/terminals/:id/write - Write data to terminal (Spec 0104)
|
|
1833
|
+
if (req.method === 'POST' && subpath === '/write') {
|
|
1834
|
+
try {
|
|
1835
|
+
const body = await parseJsonBody(req);
|
|
1836
|
+
if (typeof body.data !== 'string') {
|
|
1837
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1838
|
+
res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'data must be a string' }));
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
const session = manager.getSession(terminalId);
|
|
1842
|
+
if (!session) {
|
|
1843
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1844
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
session.write(body.data);
|
|
1848
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1849
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1850
|
+
}
|
|
1851
|
+
catch {
|
|
1852
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1853
|
+
res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'Invalid JSON body' }));
|
|
1854
|
+
}
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1920
1857
|
// POST /api/terminals/:id/resize - Resize terminal
|
|
1921
1858
|
if (req.method === 'POST' && subpath === '/resize') {
|
|
1922
1859
|
try {
|
|
@@ -2246,8 +2183,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
2246
2183
|
const apiPath = subPath.replace(/^api\/?/, '');
|
|
2247
2184
|
// GET /api/state - Return project state (architect, builders, shells)
|
|
2248
2185
|
if (req.method === 'GET' && (apiPath === 'state' || apiPath === '')) {
|
|
2249
|
-
// Refresh cache via getTerminalsForProject (handles SQLite sync
|
|
2250
|
-
//
|
|
2186
|
+
// Refresh cache via getTerminalsForProject (handles SQLite sync
|
|
2187
|
+
// and shepherd reconnection in one place)
|
|
2251
2188
|
const encodedPath = Buffer.from(projectPath).toString('base64url');
|
|
2252
2189
|
const proxyUrl = `/project/${encodedPath}/`;
|
|
2253
2190
|
const { gateStatus } = await getTerminalsForProject(projectPath, proxyUrl);
|
|
@@ -2270,6 +2207,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2270
2207
|
port: 0,
|
|
2271
2208
|
pid: session.pid || 0,
|
|
2272
2209
|
terminalId: entry.architect,
|
|
2210
|
+
persistent: isSessionPersistent(entry.architect, session),
|
|
2273
2211
|
};
|
|
2274
2212
|
}
|
|
2275
2213
|
}
|
|
@@ -2283,6 +2221,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2283
2221
|
port: 0,
|
|
2284
2222
|
pid: session.pid || 0,
|
|
2285
2223
|
terminalId,
|
|
2224
|
+
persistent: isSessionPersistent(terminalId, session),
|
|
2286
2225
|
});
|
|
2287
2226
|
}
|
|
2288
2227
|
}
|
|
@@ -2301,6 +2240,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2301
2240
|
branch: '',
|
|
2302
2241
|
type: 'spec',
|
|
2303
2242
|
terminalId,
|
|
2243
|
+
persistent: isSessionPersistent(terminalId, session),
|
|
2304
2244
|
});
|
|
2305
2245
|
}
|
|
2306
2246
|
}
|
|
@@ -2322,39 +2262,76 @@ const server = http.createServer(async (req, res) => {
|
|
|
2322
2262
|
try {
|
|
2323
2263
|
const manager = getTerminalManager();
|
|
2324
2264
|
const shellId = getNextShellId(projectPath);
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
let
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2265
|
+
const shellCmd = process.env.SHELL || '/bin/bash';
|
|
2266
|
+
const shellArgs = [];
|
|
2267
|
+
let shellCreated = false;
|
|
2268
|
+
// Try shepherd first for persistent shell session
|
|
2269
|
+
if (shepherdManager) {
|
|
2270
|
+
try {
|
|
2271
|
+
const sessionId = crypto.randomUUID();
|
|
2272
|
+
// Strip CLAUDECODE so spawned Claude processes don't detect nesting
|
|
2273
|
+
const shellEnv = { ...process.env };
|
|
2274
|
+
delete shellEnv['CLAUDECODE'];
|
|
2275
|
+
const client = await shepherdManager.createSession({
|
|
2276
|
+
sessionId,
|
|
2277
|
+
command: shellCmd,
|
|
2278
|
+
args: shellArgs,
|
|
2279
|
+
cwd: projectPath,
|
|
2280
|
+
env: shellEnv,
|
|
2281
|
+
cols: 200,
|
|
2282
|
+
rows: 50,
|
|
2283
|
+
restartOnExit: false,
|
|
2284
|
+
});
|
|
2285
|
+
const replayData = client.getReplayData() ?? Buffer.alloc(0);
|
|
2286
|
+
const shepherdInfo = shepherdManager.getSessionInfo(sessionId);
|
|
2287
|
+
const session = manager.createSessionRaw({
|
|
2288
|
+
label: `Shell ${shellId.replace('shell-', '')}`,
|
|
2289
|
+
cwd: projectPath,
|
|
2290
|
+
});
|
|
2291
|
+
const ptySession = manager.getSession(session.id);
|
|
2292
|
+
if (ptySession) {
|
|
2293
|
+
ptySession.attachShepherd(client, replayData, shepherdInfo.pid, sessionId);
|
|
2294
|
+
}
|
|
2295
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2296
|
+
entry.shells.set(shellId, session.id);
|
|
2297
|
+
saveTerminalSession(session.id, projectPath, 'shell', shellId, shepherdInfo.pid, shepherdInfo.socketPath, shepherdInfo.pid, shepherdInfo.startTime);
|
|
2298
|
+
shellCreated = true;
|
|
2299
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2300
|
+
res.end(JSON.stringify({
|
|
2301
|
+
id: shellId,
|
|
2302
|
+
port: 0,
|
|
2303
|
+
name: `Shell ${shellId.replace('shell-', '')}`,
|
|
2304
|
+
terminalId: session.id,
|
|
2305
|
+
persistent: true,
|
|
2306
|
+
}));
|
|
2307
|
+
}
|
|
2308
|
+
catch (shepherdErr) {
|
|
2309
|
+
log('WARN', `Shepherd creation failed for shell, falling back: ${shepherdErr.message}`);
|
|
2336
2310
|
}
|
|
2337
2311
|
}
|
|
2338
|
-
//
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2312
|
+
// Fallback: non-persistent session (graceful degradation per plan)
|
|
2313
|
+
// Shepherd is the only persistence backend for new sessions.
|
|
2314
|
+
if (!shellCreated) {
|
|
2315
|
+
const session = await manager.createSession({
|
|
2316
|
+
command: shellCmd,
|
|
2317
|
+
args: shellArgs,
|
|
2318
|
+
cwd: projectPath,
|
|
2319
|
+
label: `Shell ${shellId.replace('shell-', '')}`,
|
|
2320
|
+
env: process.env,
|
|
2321
|
+
});
|
|
2322
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2323
|
+
entry.shells.set(shellId, session.id);
|
|
2324
|
+
saveTerminalSession(session.id, projectPath, 'shell', shellId, session.pid);
|
|
2325
|
+
log('WARN', `Shell ${shellId} for ${projectPath} is non-persistent (shepherd unavailable)`);
|
|
2326
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2327
|
+
res.end(JSON.stringify({
|
|
2328
|
+
id: shellId,
|
|
2329
|
+
port: 0,
|
|
2330
|
+
name: `Shell ${shellId.replace('shell-', '')}`,
|
|
2331
|
+
terminalId: session.id,
|
|
2332
|
+
persistent: false,
|
|
2333
|
+
}));
|
|
2334
|
+
}
|
|
2358
2335
|
}
|
|
2359
2336
|
catch (err) {
|
|
2360
2337
|
log('ERROR', `Failed to create shell: ${err.message}`);
|
|
@@ -2609,7 +2586,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
2609
2586
|
}
|
|
2610
2587
|
}
|
|
2611
2588
|
if (terminalId) {
|
|
2612
|
-
|
|
2589
|
+
// Disable shepherd auto-restart if applicable, then kill the PtySession
|
|
2590
|
+
await killTerminalWithShepherd(manager, terminalId);
|
|
2613
2591
|
// TICK-001: Delete from SQLite
|
|
2614
2592
|
deleteTerminalSession(terminalId);
|
|
2615
2593
|
res.writeHead(204);
|
|
@@ -2625,15 +2603,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
2625
2603
|
if (req.method === 'POST' && apiPath === 'stop') {
|
|
2626
2604
|
const entry = getProjectTerminalsEntry(projectPath);
|
|
2627
2605
|
const manager = getTerminalManager();
|
|
2628
|
-
// Kill all terminals
|
|
2606
|
+
// Kill all terminals (disable shepherd auto-restart if applicable)
|
|
2629
2607
|
if (entry.architect) {
|
|
2630
|
-
manager
|
|
2608
|
+
await killTerminalWithShepherd(manager, entry.architect);
|
|
2631
2609
|
}
|
|
2632
2610
|
for (const terminalId of entry.shells.values()) {
|
|
2633
|
-
manager
|
|
2611
|
+
await killTerminalWithShepherd(manager, terminalId);
|
|
2634
2612
|
}
|
|
2635
2613
|
for (const terminalId of entry.builders.values()) {
|
|
2636
|
-
manager
|
|
2614
|
+
await killTerminalWithShepherd(manager, terminalId);
|
|
2637
2615
|
}
|
|
2638
2616
|
// Clear registry
|
|
2639
2617
|
projectTerminals.delete(projectPath);
|
|
@@ -2924,9 +2902,19 @@ const server = http.createServer(async (req, res) => {
|
|
|
2924
2902
|
// SECURITY: Bind to localhost only to prevent network exposure
|
|
2925
2903
|
server.listen(port, '127.0.0.1', async () => {
|
|
2926
2904
|
log('INFO', `Tower server listening at http://localhost:${port}`);
|
|
2927
|
-
//
|
|
2928
|
-
|
|
2929
|
-
|
|
2905
|
+
// Initialize shepherd session manager for persistent terminals
|
|
2906
|
+
const socketDir = path.join(homedir(), '.codev', 'run');
|
|
2907
|
+
const shepherdScript = path.join(__dirname, '..', '..', 'terminal', 'shepherd-main.js');
|
|
2908
|
+
shepherdManager = new SessionManager({
|
|
2909
|
+
socketDir,
|
|
2910
|
+
shepherdScript,
|
|
2911
|
+
nodeExecutable: process.execPath,
|
|
2912
|
+
});
|
|
2913
|
+
const staleCleaned = await shepherdManager.cleanupStaleSockets();
|
|
2914
|
+
if (staleCleaned > 0) {
|
|
2915
|
+
log('INFO', `Cleaned up ${staleCleaned} stale shepherd socket(s)`);
|
|
2916
|
+
}
|
|
2917
|
+
log('INFO', 'Shepherd session manager initialized');
|
|
2930
2918
|
// TICK-001: Reconcile terminal sessions from previous run
|
|
2931
2919
|
await reconcileTerminalSessions();
|
|
2932
2920
|
// Spec 0100: Start background gate watcher for af send notifications
|