@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.
Files changed (139) hide show
  1. package/dashboard/dist/assets/{index-CG7nUttd.js → index-CDAINZKT.js} +21 -21
  2. package/dashboard/dist/assets/{index-CG7nUttd.js.map → index-CDAINZKT.js.map} +1 -1
  3. package/dashboard/dist/index.html +1 -1
  4. package/dist/agent-farm/cli.js +2 -2
  5. package/dist/agent-farm/cli.js.map +1 -1
  6. package/dist/agent-farm/commands/architect.d.ts +3 -3
  7. package/dist/agent-farm/commands/architect.d.ts.map +1 -1
  8. package/dist/agent-farm/commands/architect.js +20 -142
  9. package/dist/agent-farm/commands/architect.js.map +1 -1
  10. package/dist/agent-farm/commands/attach.d.ts.map +1 -1
  11. package/dist/agent-farm/commands/attach.js +13 -50
  12. package/dist/agent-farm/commands/attach.js.map +1 -1
  13. package/dist/agent-farm/commands/cleanup.d.ts.map +1 -1
  14. package/dist/agent-farm/commands/cleanup.js +1 -11
  15. package/dist/agent-farm/commands/cleanup.js.map +1 -1
  16. package/dist/agent-farm/commands/send.d.ts +1 -1
  17. package/dist/agent-farm/commands/send.d.ts.map +1 -1
  18. package/dist/agent-farm/commands/send.js +35 -92
  19. package/dist/agent-farm/commands/send.js.map +1 -1
  20. package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
  21. package/dist/agent-farm/commands/spawn.js +26 -58
  22. package/dist/agent-farm/commands/spawn.js.map +1 -1
  23. package/dist/agent-farm/commands/status.js +1 -1
  24. package/dist/agent-farm/commands/status.js.map +1 -1
  25. package/dist/agent-farm/commands/stop.d.ts.map +1 -1
  26. package/dist/agent-farm/commands/stop.js +9 -44
  27. package/dist/agent-farm/commands/stop.js.map +1 -1
  28. package/dist/agent-farm/commands/tower-cloud.d.ts.map +1 -1
  29. package/dist/agent-farm/commands/tower-cloud.js +18 -1
  30. package/dist/agent-farm/commands/tower-cloud.js.map +1 -1
  31. package/dist/agent-farm/db/index.d.ts.map +1 -1
  32. package/dist/agent-farm/db/index.js +61 -0
  33. package/dist/agent-farm/db/index.js.map +1 -1
  34. package/dist/agent-farm/db/migrate.d.ts.map +1 -1
  35. package/dist/agent-farm/db/migrate.js +6 -9
  36. package/dist/agent-farm/db/migrate.js.map +1 -1
  37. package/dist/agent-farm/db/schema.d.ts +2 -2
  38. package/dist/agent-farm/db/schema.d.ts.map +1 -1
  39. package/dist/agent-farm/db/schema.js +3 -4
  40. package/dist/agent-farm/db/schema.js.map +1 -1
  41. package/dist/agent-farm/db/types.d.ts +0 -3
  42. package/dist/agent-farm/db/types.d.ts.map +1 -1
  43. package/dist/agent-farm/db/types.js +0 -3
  44. package/dist/agent-farm/db/types.js.map +1 -1
  45. package/dist/agent-farm/lib/tower-client.d.ts +4 -0
  46. package/dist/agent-farm/lib/tower-client.d.ts.map +1 -1
  47. package/dist/agent-farm/lib/tower-client.js +10 -0
  48. package/dist/agent-farm/lib/tower-client.js.map +1 -1
  49. package/dist/agent-farm/lib/tunnel-client.d.ts.map +1 -1
  50. package/dist/agent-farm/lib/tunnel-client.js +6 -13
  51. package/dist/agent-farm/lib/tunnel-client.js.map +1 -1
  52. package/dist/agent-farm/servers/tower-server.js +482 -494
  53. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  54. package/dist/agent-farm/state.d.ts.map +1 -1
  55. package/dist/agent-farm/state.js +6 -10
  56. package/dist/agent-farm/state.js.map +1 -1
  57. package/dist/agent-farm/types.d.ts +0 -7
  58. package/dist/agent-farm/types.d.ts.map +1 -1
  59. package/dist/agent-farm/utils/deps.d.ts.map +1 -1
  60. package/dist/agent-farm/utils/deps.js +0 -16
  61. package/dist/agent-farm/utils/deps.js.map +1 -1
  62. package/dist/agent-farm/utils/gate-watcher.js +4 -4
  63. package/dist/agent-farm/utils/gate-watcher.js.map +1 -1
  64. package/dist/agent-farm/utils/session.d.ts +6 -6
  65. package/dist/agent-farm/utils/session.d.ts.map +1 -1
  66. package/dist/agent-farm/utils/session.js +4 -4
  67. package/dist/agent-farm/utils/session.js.map +1 -1
  68. package/dist/agent-farm/utils/shell.d.ts +4 -4
  69. package/dist/agent-farm/utils/shell.js +4 -4
  70. package/dist/cli.d.ts.map +1 -1
  71. package/dist/cli.js +2 -0
  72. package/dist/cli.js.map +1 -1
  73. package/dist/commands/consult/index.d.ts +1 -0
  74. package/dist/commands/consult/index.d.ts.map +1 -1
  75. package/dist/commands/consult/index.js +19 -5
  76. package/dist/commands/consult/index.js.map +1 -1
  77. package/dist/commands/doctor.d.ts.map +1 -1
  78. package/dist/commands/doctor.js +0 -12
  79. package/dist/commands/doctor.js.map +1 -1
  80. package/dist/commands/porch/next.d.ts.map +1 -1
  81. package/dist/commands/porch/next.js +90 -8
  82. package/dist/commands/porch/next.js.map +1 -1
  83. package/dist/commands/porch/prompts.d.ts.map +1 -1
  84. package/dist/commands/porch/prompts.js +26 -4
  85. package/dist/commands/porch/prompts.js.map +1 -1
  86. package/dist/commands/porch/verdict.d.ts +0 -8
  87. package/dist/commands/porch/verdict.d.ts.map +1 -1
  88. package/dist/commands/porch/verdict.js +0 -13
  89. package/dist/commands/porch/verdict.js.map +1 -1
  90. package/dist/terminal/pty-manager.d.ts +9 -0
  91. package/dist/terminal/pty-manager.d.ts.map +1 -1
  92. package/dist/terminal/pty-manager.js +45 -2
  93. package/dist/terminal/pty-manager.js.map +1 -1
  94. package/dist/terminal/pty-session.d.ts +27 -4
  95. package/dist/terminal/pty-session.d.ts.map +1 -1
  96. package/dist/terminal/pty-session.js +112 -4
  97. package/dist/terminal/pty-session.js.map +1 -1
  98. package/dist/terminal/ring-buffer.d.ts +10 -3
  99. package/dist/terminal/ring-buffer.d.ts.map +1 -1
  100. package/dist/terminal/ring-buffer.js +25 -5
  101. package/dist/terminal/ring-buffer.js.map +1 -1
  102. package/dist/terminal/session-manager.d.ts +115 -0
  103. package/dist/terminal/session-manager.d.ts.map +1 -0
  104. package/dist/terminal/session-manager.js +582 -0
  105. package/dist/terminal/session-manager.js.map +1 -0
  106. package/dist/terminal/shepherd-client.d.ts +58 -0
  107. package/dist/terminal/shepherd-client.d.ts.map +1 -0
  108. package/dist/terminal/shepherd-client.js +212 -0
  109. package/dist/terminal/shepherd-client.js.map +1 -0
  110. package/dist/terminal/shepherd-main.d.ts +19 -0
  111. package/dist/terminal/shepherd-main.d.ts.map +1 -0
  112. package/dist/terminal/shepherd-main.js +153 -0
  113. package/dist/terminal/shepherd-main.js.map +1 -0
  114. package/dist/terminal/shepherd-process.d.ts +75 -0
  115. package/dist/terminal/shepherd-process.d.ts.map +1 -0
  116. package/dist/terminal/shepherd-process.js +279 -0
  117. package/dist/terminal/shepherd-process.js.map +1 -0
  118. package/dist/terminal/shepherd-protocol.d.ts +115 -0
  119. package/dist/terminal/shepherd-protocol.d.ts.map +1 -0
  120. package/dist/terminal/shepherd-protocol.js +214 -0
  121. package/dist/terminal/shepherd-protocol.js.map +1 -0
  122. package/dist/terminal/shepherd-replay-buffer.d.ts +38 -0
  123. package/dist/terminal/shepherd-replay-buffer.d.ts.map +1 -0
  124. package/dist/terminal/shepherd-replay-buffer.js +94 -0
  125. package/dist/terminal/shepherd-replay-buffer.js.map +1 -0
  126. package/package.json +1 -1
  127. package/skeleton/.claude/skills/codev/SKILL.md +1 -1
  128. package/skeleton/DEPENDENCIES.md +2 -34
  129. package/skeleton/consult-types/impl-review.md +9 -9
  130. package/skeleton/consult-types/integration-review.md +1 -1
  131. package/skeleton/consult-types/plan-review.md +1 -1
  132. package/skeleton/consult-types/pr-ready.md +1 -1
  133. package/skeleton/consult-types/spec-review.md +1 -1
  134. package/skeleton/protocols/bugfix/prompts/pr.md +23 -4
  135. package/skeleton/protocols/maintain/protocol.md +3 -3
  136. package/skeleton/resources/commands/agent-farm.md +2 -3
  137. package/skeleton/resources/commands/codev.md +0 -2
  138. package/skeleton/roles/architect.md +1 -1
  139. 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, spawnSync } from 'node:child_process';
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 { parseTmuxSessionName } from '../utils/session.js';
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: 1000,
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, tmuxSession) {
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, tmux_session)
347
- VALUES (?, ?, ?, ?, ?, ?)
348
- `).run(terminalId, normalizedPath, type, roleId, pid, tmuxSession);
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
- // Whether tmux is available on this system (checked once at startup)
425
- let tmuxAvailable = false;
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 (tmux + SQLite):
448
+ * DUAL-SOURCE STRATEGY (shepherd + SQLite):
613
449
  *
614
- * tmux is the source of truth for LIVENESS (process existence).
615
- * SQLite is the source of truth for METADATA (project association, type, role ID).
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
- * This is intentional: tmux sessions survive Tower restarts because they are
618
- * OS-level processes independent of Tower. SQLite rows, on the other hand,
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
- // Phase 1: Discover living tmux sessions (bypass cache on startup)
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
- if (liveTmuxSessions.length > 0) {
646
- log('INFO', `Found ${liveTmuxSessions.length} live codev tmux session(s) — reconnecting...`);
647
- }
648
- for (const { tmuxName, parsed } of liveTmuxSessions) {
649
- // Look up SQLite for this tmux session's metadata
650
- const dbRow = findSqliteRowForTmuxSession(tmuxName);
651
- matchedTmuxNames.add(tmuxName);
652
- // Determine metadata prefer SQLite, fall back to parsed name
653
- const projectPath = dbRow?.project_path || (parsed && resolveProjectPathFromBasename(parsed.projectBasename));
654
- const type = (dbRow?.type || parsed?.type);
655
- const roleId = dbRow?.role_id ?? parsed?.roleId ?? null;
656
- if (!projectPath || !type) {
657
- log('WARN', `Cannot resolve ${!projectPath ? 'project path' : 'type'} for tmux session "${tmuxName}"${parsed ? ` (basename: ${parsed.projectBasename})` : ''} — skipping`);
658
- continue;
659
- }
660
- // Skip sessions whose project path doesn't exist on disk or is in a
661
- // temp directory (left over from E2E tests that share global.db/tmux).
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 tmux "${tmuxName}" — project path no longer exists: ${projectPath}`);
664
- killTmuxSession(tmuxName);
665
- if (dbRow)
666
- db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbRow.id);
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 tmux "${tmuxName}" — project is in temp directory: ${projectPath}`);
672
- killTmuxSession(tmuxName);
673
- if (dbRow)
674
- db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbRow.id);
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
- const label = type === 'architect' ? 'Architect' : `${type} ${roleId || 'unknown'}`;
679
- const newSession = await manager.createSession({
680
- command: 'tmux',
681
- args: ['attach-session', '-t', tmuxName],
682
- cwd: projectPath,
683
- label,
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 = newSession.id;
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 === 'shell') {
694
- entry.shells.set(roleId || tmuxName, newSession.id);
564
+ else if (dbSession.type === 'builder') {
565
+ entry.builders.set(dbSession.role_id || dbSession.id, session.id);
695
566
  }
696
- // Update SQLite: delete old row (if any), insert fresh one
697
- if (dbRow) {
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
- saveTerminalSession(newSession.id, projectPath, type, roleId, newSession.pid, tmuxName);
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
- // Ensure correct tmux options on reconnected sessions
703
- spawnSync('tmux', ['set-option', '-t', tmuxName, 'mouse', 'off'], { stdio: 'ignore' });
704
- spawnSync('tmux', ['set-option', '-t', tmuxName, 'alternate-screen', 'off'], { stdio: 'ignore' });
705
- if (dbRow) {
706
- log('INFO', `Reconnected tmux "${tmuxName}" → terminal ${newSession.id} (${type} for ${path.basename(projectPath)})`);
707
- reconnected++;
708
- }
709
- else {
710
- log('INFO', `Recovered orphaned tmux "${tmuxName}" → terminal ${newSession.id} (${type} for ${path.basename(projectPath)}) [no SQLite row]`);
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 to tmux "${tmuxName}": ${err.message}`);
589
+ log('WARN', `Failed to reconnect shepherd session ${dbSession.id}: ${err.message}`);
716
590
  }
717
591
  }
718
- // Phase 2: Sweep stale SQLite rows (those with no matching live tmux session)
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
- // Skip rows that were already reconnected in Phase 1
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 = reconnected + orphanReconnected;
611
+ const total = shepherdReconnected + orphanReconnected;
755
612
  if (total > 0 || killed > 0 || cleaned > 0) {
756
- log('INFO', `Reconciliation complete: ${reconnected} reconnected, ${orphanReconnected} orphan-recovered, ${killed} killed, ${cleaned} stale rows cleaned`);
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 tmux discovery
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
- const sanitizedTmux = dbSession.tmux_session ? sanitizeTmuxSessionName(dbSession.tmux_session) : null;
1057
- if (!session && sanitizedTmux && tmuxAvailable && tmuxSessionExists(sanitizedTmux)) {
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
- const newSession = await manager.createSession({
1061
- command: 'tmux',
1062
- args: ['attach-session', '-t', sanitizedTmux],
1063
- cwd: dbSession.project_path,
1064
- label: dbSession.type === 'architect' ? 'Architect' : `${dbSession.type} ${dbSession.role_id || dbSession.id}`,
1065
- env: process.env,
1066
- });
1067
- // Update SQLite with new terminal ID (use sanitized tmux name)
1068
- deleteTerminalSession(dbSession.id);
1069
- saveTerminalSession(newSession.id, dbSession.project_path, dbSession.type, dbSession.role_id, newSession.pid, sanitizedTmux);
1070
- dbSession.id = newSession.id;
1071
- session = manager.getSession(newSession.id);
1072
- log('INFO', `Reconnected to tmux "${sanitizedTmux}" on-the-fly → ${newSession.id}`);
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 to reconnect to tmux "${dbSession.tmux_session}": ${err.message} — will retry on next poll`);
1076
- continue;
976
+ log('WARN', `Failed shepherd on-the-fly reconnect for ${dbSession.id}: ${err.message}`);
1077
977
  }
1078
978
  }
1079
- else if (!session) {
1080
- // Stale row in SQLite, no tmux to reconnect — clean it up
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
- let cmd = cmdParts[0];
1407
- let cmdArgs = cmdParts.slice(1);
1408
- // Wrap in tmux for session persistence across Tower restarts
1409
- const tmuxName = `architect-${path.basename(projectPath)}`;
1410
- const sanitizedTmuxName = sanitizeTmuxSessionName(tmuxName);
1411
- let activeTmuxSession = null;
1412
- if (tmuxAvailable) {
1413
- // Reuse existing tmux session if it's still alive (e.g., after
1414
- // disconnect timeout killed the `tmux attach` process but the
1415
- // architect process inside tmux kept running).
1416
- if (tmuxSessionExists(sanitizedTmuxName)) {
1417
- cmd = 'tmux';
1418
- cmdArgs = ['attach-session', '-t', sanitizedTmuxName];
1419
- activeTmuxSession = sanitizedTmuxName;
1420
- log('INFO', `Reconnecting to existing tmux session "${sanitizedTmuxName}" for architect`);
1421
- }
1422
- else {
1423
- // Wrap architect in a restart loop inside tmux so it auto-restarts
1424
- // when the user exits Claude Code (e.g., /exit). The loop runs
1425
- // inside tmux itself, independent of Tower's node-pty exit handler.
1426
- const innerCmd = [cmd, ...cmdArgs].map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' ');
1427
- const loopCmd = `while true; do ${innerCmd}; echo "Architect exited. Restarting in 2s..."; sleep 2; done`;
1428
- const createdName = createTmuxSession(tmuxName, 'sh', ['-c', loopCmd], projectPath, 200, 50);
1429
- if (createdName) {
1430
- cmd = 'tmux';
1431
- cmdArgs = ['attach-session', '-t', createdName];
1432
- activeTmuxSession = createdName;
1433
- log('INFO', `Created tmux session "${createdName}" for architect (with restart loop)`);
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
- const session = await manager.createSession({
1438
- command: cmd,
1439
- args: cmdArgs,
1440
- cwd: projectPath,
1441
- label: 'Architect',
1442
- env: process.env,
1443
- });
1444
- entry.architect = session.id;
1445
- // TICK-001: Save to SQLite for persistence (with tmux session name)
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
- // Query SQLite for tmux session names BEFORE deleting rows
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.killSession(entry.architect);
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.killSession(terminalId);
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.killSession(terminalId);
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 tmux wrapping: create tmux session, then node-pty attaches to it
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
- if (projectPath && termType && roleId) {
1860
- const entry = getProjectTerminalsEntry(normalizeProjectPath(projectPath));
1861
- if (termType === 'builder') {
1862
- entry.builders.set(roleId, info.id);
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
- else {
1865
- entry.shells.set(roleId, info.id);
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}`, tmuxSession: activeTmuxSession }));
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.killSession(terminalId)) {
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
- // tmux reconnection, and tmux discovery in one place)
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
- // Wrap in tmux for session persistence
2326
- let shellCmd = process.env.SHELL || '/bin/bash';
2327
- let shellArgs = [];
2328
- const tmuxName = `shell-${path.basename(projectPath)}-${shellId}`;
2329
- let activeTmuxSession = null;
2330
- if (tmuxAvailable) {
2331
- const sanitizedName = createTmuxSession(tmuxName, shellCmd, shellArgs, projectPath, 200, 50);
2332
- if (sanitizedName) {
2333
- shellCmd = 'tmux';
2334
- shellArgs = ['attach-session', '-t', sanitizedName];
2335
- activeTmuxSession = sanitizedName;
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
- // Create terminal session
2339
- const session = await manager.createSession({
2340
- command: shellCmd,
2341
- args: shellArgs,
2342
- cwd: projectPath,
2343
- label: `Shell ${shellId.replace('shell-', '')}`,
2344
- env: process.env,
2345
- });
2346
- // Register terminal with project
2347
- const entry = getProjectTerminalsEntry(projectPath);
2348
- entry.shells.set(shellId, session.id);
2349
- // TICK-001: Save to SQLite for persistence
2350
- saveTerminalSession(session.id, projectPath, 'shell', shellId, session.pid, activeTmuxSession);
2351
- res.writeHead(200, { 'Content-Type': 'application/json' });
2352
- res.end(JSON.stringify({
2353
- id: shellId,
2354
- port: 0,
2355
- name: `Shell ${shellId.replace('shell-', '')}`,
2356
- terminalId: session.id,
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
- manager.killSession(terminalId);
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.killSession(entry.architect);
2608
+ await killTerminalWithShepherd(manager, entry.architect);
2631
2609
  }
2632
2610
  for (const terminalId of entry.shells.values()) {
2633
- manager.killSession(terminalId);
2611
+ await killTerminalWithShepherd(manager, terminalId);
2634
2612
  }
2635
2613
  for (const terminalId of entry.builders.values()) {
2636
- manager.killSession(terminalId);
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
- // Check tmux availability once at startup
2928
- tmuxAvailable = checkTmux();
2929
- log('INFO', `tmux available: ${tmuxAvailable}${tmuxAvailable ? '' : ' (terminals will not persist across restarts)'}`);
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