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

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 (156) hide show
  1. package/dashboard/dist/assets/index-C7FtNK6Y.css +32 -0
  2. package/dashboard/dist/assets/index-D6VqWAaI.js +131 -0
  3. package/dashboard/dist/assets/index-D6VqWAaI.js.map +1 -0
  4. package/dashboard/dist/index.html +2 -2
  5. package/dist/agent-farm/cli.d.ts.map +1 -1
  6. package/dist/agent-farm/cli.js +75 -50
  7. package/dist/agent-farm/cli.js.map +1 -1
  8. package/dist/agent-farm/commands/architect.d.ts.map +1 -1
  9. package/dist/agent-farm/commands/architect.js +38 -48
  10. package/dist/agent-farm/commands/architect.js.map +1 -1
  11. package/dist/agent-farm/commands/attach.d.ts.map +1 -1
  12. package/dist/agent-farm/commands/attach.js +14 -35
  13. package/dist/agent-farm/commands/attach.js.map +1 -1
  14. package/dist/agent-farm/commands/cleanup.d.ts.map +1 -1
  15. package/dist/agent-farm/commands/cleanup.js +17 -18
  16. package/dist/agent-farm/commands/cleanup.js.map +1 -1
  17. package/dist/agent-farm/commands/consult.d.ts +3 -4
  18. package/dist/agent-farm/commands/consult.d.ts.map +1 -1
  19. package/dist/agent-farm/commands/consult.js +27 -37
  20. package/dist/agent-farm/commands/consult.js.map +1 -1
  21. package/dist/agent-farm/commands/open.d.ts.map +1 -1
  22. package/dist/agent-farm/commands/open.js +19 -36
  23. package/dist/agent-farm/commands/open.js.map +1 -1
  24. package/dist/agent-farm/commands/shell.d.ts +3 -3
  25. package/dist/agent-farm/commands/shell.d.ts.map +1 -1
  26. package/dist/agent-farm/commands/shell.js +33 -78
  27. package/dist/agent-farm/commands/shell.js.map +1 -1
  28. package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
  29. package/dist/agent-farm/commands/spawn.js +113 -90
  30. package/dist/agent-farm/commands/spawn.js.map +1 -1
  31. package/dist/agent-farm/commands/start.d.ts +7 -20
  32. package/dist/agent-farm/commands/start.d.ts.map +1 -1
  33. package/dist/agent-farm/commands/start.js +7 -243
  34. package/dist/agent-farm/commands/start.js.map +1 -1
  35. package/dist/agent-farm/commands/status.d.ts.map +1 -1
  36. package/dist/agent-farm/commands/status.js +22 -29
  37. package/dist/agent-farm/commands/status.js.map +1 -1
  38. package/dist/agent-farm/commands/stop.d.ts.map +1 -1
  39. package/dist/agent-farm/commands/stop.js +43 -172
  40. package/dist/agent-farm/commands/stop.js.map +1 -1
  41. package/dist/agent-farm/commands/tower-cloud.d.ts +47 -0
  42. package/dist/agent-farm/commands/tower-cloud.d.ts.map +1 -0
  43. package/dist/agent-farm/commands/tower-cloud.js +316 -0
  44. package/dist/agent-farm/commands/tower-cloud.js.map +1 -0
  45. package/dist/agent-farm/db/index.d.ts +6 -2
  46. package/dist/agent-farm/db/index.d.ts.map +1 -1
  47. package/dist/agent-farm/db/index.js +56 -31
  48. package/dist/agent-farm/db/index.js.map +1 -1
  49. package/dist/agent-farm/db/migrate.d.ts +0 -4
  50. package/dist/agent-farm/db/migrate.d.ts.map +1 -1
  51. package/dist/agent-farm/db/migrate.js +0 -46
  52. package/dist/agent-farm/db/migrate.js.map +1 -1
  53. package/dist/agent-farm/db/schema.d.ts +3 -3
  54. package/dist/agent-farm/db/schema.d.ts.map +1 -1
  55. package/dist/agent-farm/db/schema.js +3 -17
  56. package/dist/agent-farm/db/schema.js.map +1 -1
  57. package/dist/agent-farm/db/types.d.ts +0 -10
  58. package/dist/agent-farm/db/types.d.ts.map +1 -1
  59. package/dist/agent-farm/db/types.js +0 -8
  60. package/dist/agent-farm/db/types.js.map +1 -1
  61. package/dist/agent-farm/hq-connector.d.ts +1 -1
  62. package/dist/agent-farm/hq-connector.js +1 -1
  63. package/dist/agent-farm/lib/cloud-config.d.ts +46 -0
  64. package/dist/agent-farm/lib/cloud-config.d.ts.map +1 -0
  65. package/dist/agent-farm/lib/cloud-config.js +106 -0
  66. package/dist/agent-farm/lib/cloud-config.js.map +1 -0
  67. package/dist/agent-farm/lib/tower-client.d.ts +7 -5
  68. package/dist/agent-farm/lib/tower-client.d.ts.map +1 -1
  69. package/dist/agent-farm/lib/tower-client.js.map +1 -1
  70. package/dist/agent-farm/lib/tunnel-client.d.ts +117 -0
  71. package/dist/agent-farm/lib/tunnel-client.d.ts.map +1 -0
  72. package/dist/agent-farm/lib/tunnel-client.js +502 -0
  73. package/dist/agent-farm/lib/tunnel-client.js.map +1 -0
  74. package/dist/agent-farm/servers/tower-server.js +865 -441
  75. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  76. package/dist/agent-farm/state.d.ts +2 -2
  77. package/dist/agent-farm/state.d.ts.map +1 -1
  78. package/dist/agent-farm/state.js +6 -16
  79. package/dist/agent-farm/state.js.map +1 -1
  80. package/dist/agent-farm/types.d.ts +1 -18
  81. package/dist/agent-farm/types.d.ts.map +1 -1
  82. package/dist/agent-farm/utils/config.d.ts +0 -5
  83. package/dist/agent-farm/utils/config.d.ts.map +1 -1
  84. package/dist/agent-farm/utils/config.js +0 -31
  85. package/dist/agent-farm/utils/config.js.map +1 -1
  86. package/dist/agent-farm/utils/file-tabs.d.ts +27 -0
  87. package/dist/agent-farm/utils/file-tabs.d.ts.map +1 -0
  88. package/dist/agent-farm/utils/file-tabs.js +46 -0
  89. package/dist/agent-farm/utils/file-tabs.js.map +1 -0
  90. package/dist/agent-farm/utils/gate-status.d.ts +16 -0
  91. package/dist/agent-farm/utils/gate-status.d.ts.map +1 -0
  92. package/dist/agent-farm/utils/gate-status.js +79 -0
  93. package/dist/agent-farm/utils/gate-status.js.map +1 -0
  94. package/dist/agent-farm/utils/gate-watcher.d.ts +38 -0
  95. package/dist/agent-farm/utils/gate-watcher.d.ts.map +1 -0
  96. package/dist/agent-farm/utils/gate-watcher.js +122 -0
  97. package/dist/agent-farm/utils/gate-watcher.js.map +1 -0
  98. package/dist/agent-farm/utils/index.d.ts +0 -1
  99. package/dist/agent-farm/utils/index.d.ts.map +1 -1
  100. package/dist/agent-farm/utils/index.js +0 -1
  101. package/dist/agent-farm/utils/index.js.map +1 -1
  102. package/dist/agent-farm/utils/notifications.js +1 -1
  103. package/dist/agent-farm/utils/notifications.js.map +1 -1
  104. package/dist/agent-farm/utils/server-utils.d.ts +1 -1
  105. package/dist/agent-farm/utils/server-utils.js +1 -1
  106. package/dist/agent-farm/utils/session.d.ts +10 -0
  107. package/dist/agent-farm/utils/session.d.ts.map +1 -0
  108. package/dist/agent-farm/utils/session.js +12 -0
  109. package/dist/agent-farm/utils/session.js.map +1 -0
  110. package/dist/cli.d.ts.map +1 -1
  111. package/dist/cli.js +2 -0
  112. package/dist/cli.js.map +1 -1
  113. package/dist/commands/adopt.js +1 -1
  114. package/dist/commands/adopt.js.map +1 -1
  115. package/dist/commands/consult/index.d.ts +1 -0
  116. package/dist/commands/consult/index.d.ts.map +1 -1
  117. package/dist/commands/consult/index.js +42 -15
  118. package/dist/commands/consult/index.js.map +1 -1
  119. package/dist/commands/init.js +1 -1
  120. package/dist/commands/init.js.map +1 -1
  121. package/dist/commands/porch/index.d.ts.map +1 -1
  122. package/dist/commands/porch/index.js +35 -12
  123. package/dist/commands/porch/index.js.map +1 -1
  124. package/dist/commands/porch/next.js +15 -5
  125. package/dist/commands/porch/next.js.map +1 -1
  126. package/dist/commands/porch/verdict.d.ts +8 -0
  127. package/dist/commands/porch/verdict.d.ts.map +1 -1
  128. package/dist/commands/porch/verdict.js +13 -0
  129. package/dist/commands/porch/verdict.js.map +1 -1
  130. package/dist/terminal/pty-session.d.ts +2 -0
  131. package/dist/terminal/pty-session.d.ts.map +1 -1
  132. package/dist/terminal/pty-session.js +4 -0
  133. package/dist/terminal/pty-session.js.map +1 -1
  134. package/package.json +1 -1
  135. package/skeleton/.claude/skills/af/SKILL.md +15 -0
  136. package/skeleton/consult-types/impl-review.md +9 -0
  137. package/skeleton/protocols/spir/prompts/review.md +15 -16
  138. package/skeleton/protocols/spir/protocol.json +4 -0
  139. package/skeleton/protocols/spir/templates/review.md +81 -199
  140. package/skeleton/resources/commands/agent-farm.md +38 -2
  141. package/templates/tower.html +7 -150
  142. package/dashboard/dist/assets/index-CXloFYpB.css +0 -32
  143. package/dashboard/dist/assets/index-Ca2fjOJf.js +0 -131
  144. package/dashboard/dist/assets/index-Ca2fjOJf.js.map +0 -1
  145. package/dist/agent-farm/utils/orphan-handler.d.ts +0 -27
  146. package/dist/agent-farm/utils/orphan-handler.d.ts.map +0 -1
  147. package/dist/agent-farm/utils/orphan-handler.js +0 -149
  148. package/dist/agent-farm/utils/orphan-handler.js.map +0 -1
  149. package/dist/agent-farm/utils/port-registry.d.ts +0 -57
  150. package/dist/agent-farm/utils/port-registry.d.ts.map +0 -1
  151. package/dist/agent-farm/utils/port-registry.js +0 -166
  152. package/dist/agent-farm/utils/port-registry.js.map +0 -1
  153. package/dist/agent-farm/utils/terminal-ports.d.ts +0 -18
  154. package/dist/agent-farm/utils/terminal-ports.d.ts.map +0 -1
  155. package/dist/agent-farm/utils/terminal-ports.js +0 -35
  156. package/dist/agent-farm/utils/terminal-ports.js.map +0 -1
@@ -6,18 +6,21 @@
6
6
  import http from 'node:http';
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
- import net from 'node:net';
10
9
  import crypto from 'node:crypto';
11
- import { spawn, execSync, spawnSync } from 'node:child_process';
10
+ import { execSync, spawnSync } from 'node:child_process';
12
11
  import { homedir, tmpdir } from 'node:os';
13
12
  import { fileURLToPath } from 'node:url';
14
13
  import { Command } from 'commander';
15
14
  import { WebSocketServer, WebSocket } from 'ws';
16
15
  import { getGlobalDb } from '../db/index.js';
17
- import { cleanupStaleEntries } from '../utils/port-registry.js';
18
16
  import { parseJsonBody, isRequestAllowed } from '../utils/server-utils.js';
17
+ import { getGateStatusForProject } from '../utils/gate-status.js';
18
+ import { GateWatcher } from '../utils/gate-watcher.js';
19
+ import { saveFileTab as saveFileTabToDb, deleteFileTab as deleteFileTabFromDb, loadFileTabsForProject as loadFileTabsFromDb, } from '../utils/file-tabs.js';
19
20
  import { TerminalManager } from '../../terminal/pty-manager.js';
20
21
  import { encodeData, encodeControl, decodeFrame } from '../../terminal/ws-protocol.js';
22
+ import { TunnelClient } from '../lib/tunnel-client.js';
23
+ import { readCloudConfig, getCloudConfigPath, maskApiKey } from '../lib/cloud-config.js';
21
24
  const __filename = fileURLToPath(import.meta.url);
22
25
  const __dirname = path.dirname(__filename);
23
26
  // Default port for tower dashboard
@@ -59,6 +62,178 @@ function cleanupRateLimits() {
59
62
  // Cleanup stale rate limit entries every 5 minutes
60
63
  setInterval(cleanupRateLimits, 5 * 60 * 1000);
61
64
  // ============================================================================
65
+ // Cloud Tunnel Client (Spec 0097 Phase 4)
66
+ // ============================================================================
67
+ /** Tunnel client instance — created on startup or via POST /api/tunnel/connect */
68
+ let tunnelClient = null;
69
+ /** Config file watcher — watches cloud-config.json for changes */
70
+ let configWatcher = null;
71
+ /** Debounce timer for config file watcher events */
72
+ let configWatchDebounce = null;
73
+ /** Default tunnel port for codevos.ai */
74
+ // TICK-001: tunnelPort is no longer needed — WebSocket connects on the same port
75
+ /** Periodic metadata refresh interval (re-sends metadata to codevos.ai) */
76
+ let metadataRefreshInterval = null;
77
+ /** Metadata refresh period in milliseconds (30 seconds) */
78
+ const METADATA_REFRESH_MS = 30_000;
79
+ /**
80
+ * Gather current tower metadata (projects + terminals) for codevos.ai.
81
+ */
82
+ async function gatherMetadata() {
83
+ const instances = await getInstances();
84
+ const projects = instances.map((i) => ({
85
+ path: i.projectPath,
86
+ name: i.projectName,
87
+ }));
88
+ // Build reverse mapping: terminal ID → project path
89
+ const terminalToProject = new Map();
90
+ for (const [projectPath, entry] of projectTerminals) {
91
+ if (entry.architect)
92
+ terminalToProject.set(entry.architect, projectPath);
93
+ for (const termId of entry.builders.values())
94
+ terminalToProject.set(termId, projectPath);
95
+ for (const termId of entry.shells.values())
96
+ terminalToProject.set(termId, projectPath);
97
+ }
98
+ const manager = terminalManager;
99
+ const terminals = [];
100
+ if (manager) {
101
+ for (const session of manager.listSessions()) {
102
+ terminals.push({
103
+ id: session.id,
104
+ projectPath: terminalToProject.get(session.id) ?? '',
105
+ });
106
+ }
107
+ }
108
+ return { projects, terminals };
109
+ }
110
+ /**
111
+ * Start periodic metadata refresh — re-gathers metadata and pushes to codevos.ai
112
+ * every METADATA_REFRESH_MS while the tunnel is connected.
113
+ */
114
+ function startMetadataRefresh() {
115
+ stopMetadataRefresh();
116
+ metadataRefreshInterval = setInterval(async () => {
117
+ try {
118
+ if (tunnelClient && tunnelClient.getState() === 'connected') {
119
+ const metadata = await gatherMetadata();
120
+ tunnelClient.sendMetadata(metadata);
121
+ }
122
+ }
123
+ catch (err) {
124
+ log('WARN', `Metadata refresh failed: ${err.message}`);
125
+ }
126
+ }, METADATA_REFRESH_MS);
127
+ }
128
+ /**
129
+ * Stop the periodic metadata refresh.
130
+ */
131
+ function stopMetadataRefresh() {
132
+ if (metadataRefreshInterval) {
133
+ clearInterval(metadataRefreshInterval);
134
+ metadataRefreshInterval = null;
135
+ }
136
+ }
137
+ /**
138
+ * Create or reconnect the tunnel client using the given config.
139
+ * Sets up state change listeners and sends initial metadata.
140
+ */
141
+ async function connectTunnel(config) {
142
+ // Disconnect existing client if any
143
+ if (tunnelClient) {
144
+ tunnelClient.disconnect();
145
+ }
146
+ const client = new TunnelClient({
147
+ serverUrl: config.server_url,
148
+ apiKey: config.api_key,
149
+ towerId: config.tower_id,
150
+ localPort: port,
151
+ });
152
+ client.onStateChange((state, prev) => {
153
+ log('INFO', `Tunnel: ${prev} → ${state}`);
154
+ if (state === 'connected') {
155
+ startMetadataRefresh();
156
+ }
157
+ else if (prev === 'connected') {
158
+ stopMetadataRefresh();
159
+ }
160
+ if (state === 'auth_failed') {
161
+ log('ERROR', 'Cloud connection failed: API key is invalid or revoked. Run \'af tower register --reauth\' to update credentials.');
162
+ }
163
+ });
164
+ // Gather and set initial metadata before connecting
165
+ const metadata = await gatherMetadata();
166
+ client.sendMetadata(metadata);
167
+ tunnelClient = client;
168
+ client.connect();
169
+ // Ensure config watcher is running — the config directory now exists.
170
+ // Handles the case where Tower booted before registration (directory didn't
171
+ // exist, so startConfigWatcher() silently failed at boot time).
172
+ startConfigWatcher();
173
+ return client;
174
+ }
175
+ /**
176
+ * Start watching cloud-config.json for changes.
177
+ * On change: reconnect with new credentials.
178
+ * On delete: disconnect tunnel.
179
+ */
180
+ function startConfigWatcher() {
181
+ stopConfigWatcher();
182
+ const configPath = getCloudConfigPath();
183
+ const configDir = path.dirname(configPath);
184
+ const configFile = path.basename(configPath);
185
+ // Watch the directory (more reliable than watching the file directly)
186
+ try {
187
+ configWatcher = fs.watch(configDir, (eventType, filename) => {
188
+ if (filename !== configFile)
189
+ return;
190
+ // Debounce: multiple events fire for a single write
191
+ if (configWatchDebounce)
192
+ clearTimeout(configWatchDebounce);
193
+ configWatchDebounce = setTimeout(async () => {
194
+ configWatchDebounce = null;
195
+ try {
196
+ const config = readCloudConfig();
197
+ if (config) {
198
+ log('INFO', `Cloud config changed, reconnecting tunnel (key: ${maskApiKey(config.api_key)})`);
199
+ // Reset circuit breaker in case previous key was invalid
200
+ if (tunnelClient)
201
+ tunnelClient.resetCircuitBreaker();
202
+ await connectTunnel(config);
203
+ }
204
+ else {
205
+ // Config deleted or invalid
206
+ log('INFO', 'Cloud config removed or invalid, disconnecting tunnel');
207
+ if (tunnelClient) {
208
+ tunnelClient.disconnect();
209
+ tunnelClient = null;
210
+ }
211
+ }
212
+ }
213
+ catch (err) {
214
+ log('WARN', `Error handling config change: ${err.message}`);
215
+ }
216
+ }, 500);
217
+ });
218
+ }
219
+ catch {
220
+ // Directory doesn't exist yet — that's fine, user hasn't registered
221
+ }
222
+ }
223
+ /**
224
+ * Stop watching cloud-config.json.
225
+ */
226
+ function stopConfigWatcher() {
227
+ if (configWatcher) {
228
+ configWatcher.close();
229
+ configWatcher = null;
230
+ }
231
+ if (configWatchDebounce) {
232
+ clearTimeout(configWatchDebounce);
233
+ configWatchDebounce = null;
234
+ }
235
+ }
236
+ // ============================================================================
62
237
  // PHASE 2 & 4: Terminal Management (Spec 0090)
63
238
  // ============================================================================
64
239
  // Global TerminalManager instance for tower-managed terminals
@@ -66,12 +241,14 @@ setInterval(cleanupRateLimits, 5 * 60 * 1000);
66
241
  let terminalManager = null;
67
242
  const projectTerminals = new Map();
68
243
  /**
69
- * Get or create project terminal registry entry
244
+ * Get or create project terminal registry entry.
245
+ * On first access for a project, hydrates file tabs from SQLite so
246
+ * persisted tabs are available immediately (not just after /api/state).
70
247
  */
71
248
  function getProjectTerminalsEntry(projectPath) {
72
249
  let entry = projectTerminals.get(projectPath);
73
250
  if (!entry) {
74
- entry = { builders: new Map(), shells: new Map(), fileTabs: new Map() };
251
+ entry = { builders: new Map(), shells: new Map(), fileTabs: loadFileTabsForProject(projectPath) };
75
252
  projectTerminals.set(projectPath, entry);
76
253
  }
77
254
  // Migration: ensure fileTabs exists for older entries
@@ -204,6 +381,45 @@ function deleteProjectTerminalSessions(projectPath) {
204
381
  log('WARN', `Failed to delete project terminal sessions: ${err.message}`);
205
382
  }
206
383
  }
384
+ /**
385
+ * Save a file tab to SQLite for persistence across Tower restarts.
386
+ * Thin wrapper around utils/file-tabs.ts with error handling and path normalization.
387
+ */
388
+ function saveFileTab(id, projectPath, filePath, createdAt) {
389
+ try {
390
+ const normalizedPath = normalizeProjectPath(projectPath);
391
+ saveFileTabToDb(getGlobalDb(), id, normalizedPath, filePath, createdAt);
392
+ }
393
+ catch (err) {
394
+ log('WARN', `Failed to save file tab: ${err.message}`);
395
+ }
396
+ }
397
+ /**
398
+ * Delete a file tab from SQLite.
399
+ * Thin wrapper around utils/file-tabs.ts with error handling.
400
+ */
401
+ function deleteFileTab(id) {
402
+ try {
403
+ deleteFileTabFromDb(getGlobalDb(), id);
404
+ }
405
+ catch (err) {
406
+ log('WARN', `Failed to delete file tab: ${err.message}`);
407
+ }
408
+ }
409
+ /**
410
+ * Load file tabs for a project from SQLite.
411
+ * Thin wrapper around utils/file-tabs.ts with error handling and path normalization.
412
+ */
413
+ function loadFileTabsForProject(projectPath) {
414
+ try {
415
+ const normalizedPath = normalizeProjectPath(projectPath);
416
+ return loadFileTabsFromDb(getGlobalDb(), normalizedPath);
417
+ }
418
+ catch (err) {
419
+ log('WARN', `Failed to load file tabs: ${err.message}`);
420
+ }
421
+ return new Map();
422
+ }
207
423
  // Whether tmux is available on this system (checked once at startup)
208
424
  let tmuxAvailable = false;
209
425
  /**
@@ -218,11 +434,23 @@ function checkTmux() {
218
434
  return false;
219
435
  }
220
436
  }
437
+ /**
438
+ * Sanitize a tmux session name to match what tmux actually creates.
439
+ * tmux replaces dots with underscores and strips colons from session names.
440
+ * Without this, stored names won't match actual tmux session names,
441
+ * causing reconnection to fail (e.g., "builder-codevos.ai-0001" vs "builder-codevos_ai-0001").
442
+ */
443
+ function sanitizeTmuxSessionName(name) {
444
+ return name.replace(/\./g, '_').replace(/:/g, '');
445
+ }
221
446
  /**
222
447
  * Create a tmux session with the given command.
223
- * Returns true if created successfully, false on failure.
448
+ * Returns the sanitized session name if created successfully, null on failure.
449
+ * Session names are sanitized to match tmux behavior (dots → underscores, colons stripped).
224
450
  */
225
451
  function createTmuxSession(sessionName, command, args, cwd, cols, rows) {
452
+ // Sanitize to match what tmux actually creates (dots → underscores, colons stripped)
453
+ sessionName = sanitizeTmuxSessionName(sessionName);
226
454
  // Kill any stale session with this name
227
455
  if (tmuxSessionExists(sessionName)) {
228
456
  killTmuxSession(sessionName);
@@ -240,26 +468,30 @@ function createTmuxSession(sessionName, command, args, cwd, cols, rows) {
240
468
  const result = spawnSync('tmux', tmuxArgs, { stdio: 'ignore' });
241
469
  if (result.status !== 0) {
242
470
  log('WARN', `tmux new-session exited with code ${result.status} for "${sessionName}"`);
243
- return false;
471
+ return null;
244
472
  }
245
- // Hide tmux status bar (dashboard has its own tabs), enable mouse, and
246
- // use aggressive-resize so tmux sizes to the largest client (not smallest)
473
+ // Hide tmux status bar (dashboard has its own tabs) and enable mouse.
474
+ // NOTE: aggressive-resize was removed it caused resize bouncing and
475
+ // visual flashing (dots/redraws) when the dashboard sent multiple resize
476
+ // events during layout settling. Default tmux behavior (size to smallest
477
+ // client) is more stable since we only have one client per session.
247
478
  spawnSync('tmux', ['set-option', '-t', sessionName, 'status', 'off'], { stdio: 'ignore' });
248
479
  spawnSync('tmux', ['set-option', '-t', sessionName, 'mouse', 'on'], { stdio: 'ignore' });
249
- spawnSync('tmux', ['set-option', '-t', sessionName, 'aggressive-resize', 'on'], { stdio: 'ignore' });
250
- return true;
480
+ return sessionName;
251
481
  }
252
482
  catch (err) {
253
483
  log('WARN', `Failed to create tmux session "${sessionName}": ${err.message}`);
254
- return false;
484
+ return null;
255
485
  }
256
486
  }
257
487
  /**
258
- * Check if a tmux session exists
488
+ * Check if a tmux session exists.
489
+ * Sanitizes the name to handle legacy entries stored before dot-replacement fix.
259
490
  */
260
491
  function tmuxSessionExists(sessionName) {
492
+ const sanitized = sanitizeTmuxSessionName(sessionName);
261
493
  try {
262
- execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { stdio: 'ignore' });
494
+ execSync(`tmux has-session -t "${sanitized}" 2>/dev/null`, { stdio: 'ignore' });
263
495
  return true;
264
496
  }
265
497
  catch {
@@ -291,90 +523,242 @@ function killTmuxSession(sessionName) {
291
523
  }
292
524
  }
293
525
  /**
294
- * Reconcile terminal sessions from SQLite against reality on startup.
526
+ * Parse a codev tmux session name to extract type, project, and role.
527
+ * Returns null if the name doesn't match any known codev pattern.
295
528
  *
296
- * For sessions with surviving tmux sessions: re-attach via new node-pty,
297
- * register in projectTerminals, and update SQLite with new terminal ID.
298
- * For dead sessions: clean up SQLite rows and kill orphaned processes.
529
+ * Examples:
530
+ * "architect-codev-public" → { type: 'architect', projectBasename: 'codev-public', roleId: null }
531
+ * "builder-codevos_ai-0001" → { type: 'builder', projectBasename: 'codevos_ai', roleId: '0001' }
532
+ * "shell-codev-public-shell-1" → { type: 'shell', projectBasename: 'codev-public', roleId: 'shell-1' }
299
533
  */
300
- async function reconcileTerminalSessions() {
301
- const db = getGlobalDb();
302
- let sessions;
534
+ function parseTmuxSessionName(name) {
535
+ // architect-{basename}
536
+ const architectMatch = name.match(/^architect-(.+)$/);
537
+ if (architectMatch) {
538
+ return { type: 'architect', projectBasename: architectMatch[1], roleId: null };
539
+ }
540
+ // builder-{basename}-{specId} — specId is always the last segment (digits like "0001")
541
+ const builderMatch = name.match(/^builder-(.+)-(\d{4,})$/);
542
+ if (builderMatch) {
543
+ return { type: 'builder', projectBasename: builderMatch[1], roleId: builderMatch[2] };
544
+ }
545
+ // shell-{basename}-{shellId} — shellId is "shell-N" (last two segments)
546
+ const shellMatch = name.match(/^shell-(.+)-(shell-\d+)$/);
547
+ if (shellMatch) {
548
+ return { type: 'shell', projectBasename: shellMatch[1], roleId: shellMatch[2] };
549
+ }
550
+ return null;
551
+ }
552
+ /**
553
+ * List all tmux sessions that match codev naming conventions.
554
+ * Returns an array of { tmuxName, parsed } for each matching session.
555
+ */
556
+ // Cache for listCodevTmuxSessions — avoid shelling out on every dashboard poll
557
+ let _tmuxListCache = [];
558
+ let _tmuxListCacheTime = 0;
559
+ const TMUX_LIST_CACHE_TTL = 10_000; // 10 seconds
560
+ function listCodevTmuxSessions(bypassCache = false) {
561
+ if (!tmuxAvailable)
562
+ return [];
563
+ const now = Date.now();
564
+ if (!bypassCache && now - _tmuxListCacheTime < TMUX_LIST_CACHE_TTL) {
565
+ return _tmuxListCache;
566
+ }
303
567
  try {
304
- sessions = db.prepare('SELECT * FROM terminal_sessions').all();
568
+ const result = execSync('tmux list-sessions -F "#{session_name}" 2>/dev/null', { encoding: 'utf-8' });
569
+ const sessions = result.trim().split('\n').filter(Boolean);
570
+ const codevSessions = [];
571
+ for (const name of sessions) {
572
+ const parsed = parseTmuxSessionName(name);
573
+ if (parsed) {
574
+ codevSessions.push({ tmuxName: name, parsed });
575
+ }
576
+ }
577
+ _tmuxListCache = codevSessions;
578
+ _tmuxListCacheTime = now;
579
+ return codevSessions;
305
580
  }
306
- catch (err) {
307
- log('WARN', `Failed to read terminal sessions for reconciliation: ${err.message}`);
308
- return;
581
+ catch {
582
+ _tmuxListCache = [];
583
+ _tmuxListCacheTime = now;
584
+ return [];
309
585
  }
310
- if (sessions.length === 0) {
311
- log('INFO', 'No terminal sessions to reconcile');
312
- return;
586
+ }
587
+ /**
588
+ * Find the SQLite row that matches a given tmux session name.
589
+ * Looks up by tmux_session column directly.
590
+ */
591
+ function findSqliteRowForTmuxSession(tmuxName) {
592
+ try {
593
+ const db = getGlobalDb();
594
+ return db.prepare('SELECT * FROM terminal_sessions WHERE tmux_session = ?').get(tmuxName) || null;
595
+ }
596
+ catch {
597
+ return null;
313
598
  }
314
- log('INFO', `Reconciling ${sessions.length} terminal sessions from previous run...`);
599
+ }
600
+ /**
601
+ * Find the full project path for a tmux session's project basename.
602
+ * Checks known projects (terminal_sessions + in-memory cache) for a matching basename.
603
+ * Returns null if no match found.
604
+ */
605
+ function resolveProjectPathFromBasename(projectBasename) {
606
+ const knownPaths = getKnownProjectPaths();
607
+ for (const projectPath of knownPaths) {
608
+ if (path.basename(projectPath) === projectBasename) {
609
+ return normalizeProjectPath(projectPath);
610
+ }
611
+ }
612
+ return null;
613
+ }
614
+ /**
615
+ * Reconcile terminal sessions on startup.
616
+ *
617
+ * DUAL-SOURCE STRATEGY (tmux + SQLite):
618
+ *
619
+ * tmux is the source of truth for LIVENESS (process existence).
620
+ * SQLite is the source of truth for METADATA (project association, type, role ID).
621
+ *
622
+ * This is intentional: tmux sessions survive Tower restarts because they are
623
+ * OS-level processes independent of Tower. SQLite rows, on the other hand,
624
+ * cannot track process liveness — a row may exist for a terminal whose process
625
+ * has long since exited. Therefore:
626
+ * - We NEVER trust SQLite alone to determine if a terminal is running.
627
+ * - We ALWAYS check tmux for liveness, then use SQLite for enrichment.
628
+ *
629
+ * File tabs are the exception: they have no backing process, so SQLite is
630
+ * the sole source of truth for their persistence (see file_tabs table).
631
+ *
632
+ * Phase 1 — tmux-first discovery:
633
+ * List all codev tmux sessions. For each, look up SQLite for metadata.
634
+ * If SQLite has a matching row → reconnect with full metadata.
635
+ * If SQLite has no row (orphaned tmux) → derive metadata from session name, reconnect.
636
+ *
637
+ * Phase 2 — SQLite sweep:
638
+ * Any SQLite rows not matched to a tmux session are stale → clean up.
639
+ * (Also kills orphaned processes that have no tmux backing.)
640
+ */
641
+ async function reconcileTerminalSessions() {
315
642
  const manager = getTerminalManager();
643
+ const db = getGlobalDb();
644
+ // Phase 1: Discover living tmux sessions (bypass cache on startup)
645
+ const liveTmuxSessions = listCodevTmuxSessions(/* bypassCache */ true);
646
+ // Track which SQLite rows we matched (by tmux_session name)
647
+ const matchedTmuxNames = new Set();
316
648
  let reconnected = 0;
317
- let killed = 0;
318
- let cleaned = 0;
319
- for (const session of sessions) {
320
- // Can we reconnect to a surviving tmux session?
321
- if (session.tmux_session && tmuxAvailable && tmuxSessionExists(session.tmux_session)) {
322
- try {
323
- // Create new node-pty that attaches to the surviving tmux session
324
- const newSession = await manager.createSession({
325
- command: 'tmux',
326
- args: ['attach-session', '-t', session.tmux_session],
327
- cwd: session.project_path,
328
- label: session.type === 'architect' ? 'Architect' : `${session.type} ${session.role_id || session.id}`,
329
- });
330
- // Register in projectTerminals Map
331
- const entry = getProjectTerminalsEntry(session.project_path);
332
- if (session.type === 'architect') {
333
- entry.architect = newSession.id;
334
- }
335
- else if (session.type === 'builder') {
336
- const builderId = session.role_id || session.id;
337
- entry.builders.set(builderId, newSession.id);
338
- }
339
- else if (session.type === 'shell') {
340
- const shellId = session.role_id || session.id;
341
- entry.shells.set(shellId, newSession.id);
342
- }
343
- // Update SQLite: delete old row, insert new with new terminal ID
344
- db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(session.id);
345
- saveTerminalSession(newSession.id, session.project_path, session.type, session.role_id, newSession.pid, session.tmux_session);
346
- log('INFO', `Reconnected to tmux session "${session.tmux_session}" → terminal ${newSession.id} (${session.type} for ${path.basename(session.project_path)})`);
649
+ let orphanReconnected = 0;
650
+ if (liveTmuxSessions.length > 0) {
651
+ log('INFO', `Found ${liveTmuxSessions.length} live codev tmux session(s) reconnecting...`);
652
+ }
653
+ for (const { tmuxName, parsed } of liveTmuxSessions) {
654
+ // Look up SQLite for this tmux session's metadata
655
+ const dbRow = findSqliteRowForTmuxSession(tmuxName);
656
+ matchedTmuxNames.add(tmuxName);
657
+ // Determine metadata — prefer SQLite, fall back to parsed name
658
+ const projectPath = dbRow?.project_path || resolveProjectPathFromBasename(parsed.projectBasename);
659
+ const type = dbRow?.type || parsed.type;
660
+ const roleId = dbRow?.role_id || parsed.roleId;
661
+ if (!projectPath) {
662
+ log('WARN', `Cannot resolve project path for tmux session "${tmuxName}" (basename: ${parsed.projectBasename}) — skipping`);
663
+ continue;
664
+ }
665
+ // Skip sessions whose project path doesn't exist on disk or is in a
666
+ // temp directory (left over from E2E tests that share global.db/tmux).
667
+ if (!fs.existsSync(projectPath)) {
668
+ log('INFO', `Skipping tmux "${tmuxName}" project path no longer exists: ${projectPath}`);
669
+ killTmuxSession(tmuxName);
670
+ if (dbRow)
671
+ db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbRow.id);
672
+ continue;
673
+ }
674
+ const tmpDirs = ['/tmp', '/private/tmp', '/var/folders', '/private/var/folders'];
675
+ if (tmpDirs.some(d => projectPath.startsWith(d))) {
676
+ log('INFO', `Skipping tmux "${tmuxName}" project is in temp directory: ${projectPath}`);
677
+ killTmuxSession(tmuxName);
678
+ if (dbRow)
679
+ db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbRow.id);
680
+ continue;
681
+ }
682
+ try {
683
+ const label = type === 'architect' ? 'Architect' : `${type} ${roleId || 'unknown'}`;
684
+ const newSession = await manager.createSession({
685
+ command: 'tmux',
686
+ args: ['attach-session', '-t', tmuxName],
687
+ cwd: projectPath,
688
+ label,
689
+ });
690
+ // Register in projectTerminals Map
691
+ const entry = getProjectTerminalsEntry(projectPath);
692
+ if (type === 'architect') {
693
+ entry.architect = newSession.id;
694
+ }
695
+ else if (type === 'builder') {
696
+ entry.builders.set(roleId || tmuxName, newSession.id);
697
+ }
698
+ else if (type === 'shell') {
699
+ entry.shells.set(roleId || tmuxName, newSession.id);
700
+ }
701
+ // Update SQLite: delete old row (if any), insert fresh one
702
+ if (dbRow) {
703
+ db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbRow.id);
704
+ }
705
+ saveTerminalSession(newSession.id, projectPath, type, roleId, newSession.pid, tmuxName);
706
+ if (dbRow) {
707
+ log('INFO', `Reconnected tmux "${tmuxName}" → terminal ${newSession.id} (${type} for ${path.basename(projectPath)})`);
347
708
  reconnected++;
348
- continue;
349
709
  }
350
- catch (err) {
351
- log('WARN', `Failed to reconnect to tmux session "${session.tmux_session}": ${err.message}`);
352
- // Fall through to cleanup
353
- killTmuxSession(session.tmux_session);
354
- killed++;
710
+ else {
711
+ log('INFO', `Recovered orphaned tmux "${tmuxName}" terminal ${newSession.id} (${type} for ${path.basename(projectPath)}) [no SQLite row]`);
712
+ orphanReconnected++;
355
713
  }
356
714
  }
357
- // No tmux or tmux session dead — check for orphaned processes
358
- else if (session.tmux_session && tmuxSessionExists(session.tmux_session)) {
359
- // tmux exists but tmuxAvailable is false (shouldn't happen, but be safe)
360
- killTmuxSession(session.tmux_session);
361
- killed++;
715
+ catch (err) {
716
+ log('WARN', `Failed to reconnect to tmux "${tmuxName}": ${err.message}`);
717
+ }
718
+ }
719
+ // Phase 2: Sweep stale SQLite rows (those with no matching live tmux session)
720
+ let killed = 0;
721
+ let cleaned = 0;
722
+ let allDbSessions;
723
+ try {
724
+ allDbSessions = db.prepare('SELECT * FROM terminal_sessions').all();
725
+ }
726
+ catch (err) {
727
+ log('WARN', `Failed to read terminal sessions for sweep: ${err.message}`);
728
+ allDbSessions = [];
729
+ }
730
+ for (const session of allDbSessions) {
731
+ // Skip rows that were already reconnected in Phase 1
732
+ if (session.tmux_session && matchedTmuxNames.has(session.tmux_session)) {
733
+ continue;
734
+ }
735
+ // Also skip rows whose terminal is still alive in PtyManager
736
+ // (non-tmux sessions created during this Tower run)
737
+ const existing = manager.getSession(session.id);
738
+ if (existing && existing.status !== 'exited') {
739
+ continue;
362
740
  }
363
- else if (session.pid && processExists(session.pid)) {
364
- log('INFO', `Found orphaned process: PID ${session.pid} (${session.type} for ${path.basename(session.project_path)})`);
741
+ // Stale row — kill orphaned process if any, then delete
742
+ if (session.pid && processExists(session.pid)) {
743
+ log('INFO', `Killing orphaned process: PID ${session.pid} (${session.type} for ${path.basename(session.project_path)})`);
365
744
  try {
366
745
  process.kill(session.pid, 'SIGTERM');
367
746
  killed++;
368
747
  }
369
748
  catch {
370
- // Process may not be killable (different user, etc)
749
+ // Process may not be killable
371
750
  }
372
751
  }
373
- // Clean up the DB row for sessions we couldn't reconnect
374
752
  db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(session.id);
375
753
  cleaned++;
376
754
  }
377
- log('INFO', `Reconciliation complete: ${reconnected} reconnected, ${killed} orphaned killed, ${cleaned} cleaned up`);
755
+ const total = reconnected + orphanReconnected;
756
+ if (total > 0 || killed > 0 || cleaned > 0) {
757
+ log('INFO', `Reconciliation complete: ${reconnected} reconnected, ${orphanReconnected} orphan-recovered, ${killed} killed, ${cleaned} stale rows cleaned`);
758
+ }
759
+ else {
760
+ log('INFO', 'No terminal sessions to reconcile');
761
+ }
378
762
  }
379
763
  /**
380
764
  * Get terminal sessions from SQLite for a project.
@@ -526,8 +910,19 @@ async function gracefulShutdown(signal) {
526
910
  log('INFO', 'Shutting down terminal manager...');
527
911
  terminalManager.shutdown();
528
912
  }
529
- // 4. Stop cloudflared tunnel if running
530
- stopTunnel();
913
+ // 4. Stop gate watcher
914
+ if (gateWatcherInterval) {
915
+ clearInterval(gateWatcherInterval);
916
+ gateWatcherInterval = null;
917
+ }
918
+ // 5. Disconnect tunnel (Spec 0097 Phase 4)
919
+ stopMetadataRefresh();
920
+ stopConfigWatcher();
921
+ if (tunnelClient) {
922
+ log('INFO', 'Disconnecting tunnel...');
923
+ tunnelClient.disconnect();
924
+ tunnelClient = null;
925
+ }
531
926
  log('INFO', 'Graceful shutdown complete');
532
927
  process.exit(0);
533
928
  }
@@ -540,38 +935,26 @@ if (isNaN(port) || port < 1 || port > 65535) {
540
935
  }
541
936
  log('INFO', `Tower server starting on port ${port}`);
542
937
  /**
543
- * Load port allocations from SQLite database
938
+ * Get all known project paths from terminal_sessions and in-memory cache
544
939
  */
545
- function loadPortAllocations() {
940
+ function getKnownProjectPaths() {
941
+ const projectPaths = new Set();
942
+ // From terminal_sessions table (persists across Tower restarts)
546
943
  try {
547
944
  const db = getGlobalDb();
548
- return db.prepare('SELECT * FROM port_allocations ORDER BY last_used_at DESC').all();
945
+ const sessions = db.prepare('SELECT DISTINCT project_path FROM terminal_sessions').all();
946
+ for (const s of sessions) {
947
+ projectPaths.add(s.project_path);
948
+ }
549
949
  }
550
- catch (err) {
551
- log('ERROR', `Error loading port allocations: ${err.message}`);
552
- return [];
950
+ catch {
951
+ // Table may not exist yet
553
952
  }
554
- }
555
- /**
556
- * Check if a port is listening
557
- */
558
- async function isPortListening(port) {
559
- return new Promise((resolve) => {
560
- const socket = new net.Socket();
561
- socket.setTimeout(1000);
562
- socket.on('connect', () => {
563
- socket.destroy();
564
- resolve(true);
565
- });
566
- socket.on('timeout', () => {
567
- socket.destroy();
568
- resolve(false);
569
- });
570
- socket.on('error', () => {
571
- resolve(false);
572
- });
573
- socket.connect(port, '127.0.0.1');
574
- });
953
+ // From in-memory cache (includes projects activated this session)
954
+ for (const [projectPath] of projectTerminals) {
955
+ projectPaths.add(projectPath);
956
+ }
957
+ return Array.from(projectPaths);
575
958
  }
576
959
  /**
577
960
  * Get project name from path
@@ -579,88 +962,22 @@ async function isPortListening(port) {
579
962
  function getProjectName(projectPath) {
580
963
  return path.basename(projectPath);
581
964
  }
582
- /**
583
- * Get the base port for a project from global.db
584
- * Returns null if project not found or not running
585
- */
586
- async function getBasePortForProject(projectPath) {
587
- try {
588
- const db = getGlobalDb();
589
- const row = db.prepare('SELECT base_port FROM port_allocations WHERE project_path = ?').get(projectPath);
590
- if (!row)
591
- return null;
592
- // Check if actually running
593
- const isRunning = await isPortListening(row.base_port);
594
- return isRunning ? row.base_port : null;
595
- }
596
- catch {
597
- return null;
598
- }
599
- }
600
- // Cloudflared tunnel management
601
- let tunnelProcess = null;
602
- let tunnelUrl = null;
603
- function isCloudflaredInstalled() {
604
- try {
605
- execSync('which cloudflared', { stdio: 'ignore' });
606
- return true;
607
- }
608
- catch {
609
- return false;
610
- }
611
- }
612
- function getTunnelStatus() {
613
- return {
614
- available: isCloudflaredInstalled(),
615
- running: tunnelProcess !== null && tunnelUrl !== null,
616
- url: tunnelUrl,
617
- };
618
- }
619
- async function startTunnel(port) {
620
- if (!isCloudflaredInstalled()) {
621
- return { success: false, error: 'cloudflared not installed. Install with: brew install cloudflared' };
622
- }
623
- if (tunnelProcess) {
624
- return { success: true, url: tunnelUrl || undefined };
625
- }
626
- return new Promise((resolve) => {
627
- tunnelProcess = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
628
- stdio: ['ignore', 'pipe', 'pipe'],
629
- });
630
- const handleOutput = (data) => {
631
- const text = data.toString();
632
- const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
633
- if (match && !tunnelUrl) {
634
- tunnelUrl = match[0];
635
- log('INFO', `Cloudflared tunnel started: ${tunnelUrl}`);
636
- resolve({ success: true, url: tunnelUrl });
637
- }
638
- };
639
- tunnelProcess.stdout?.on('data', handleOutput);
640
- tunnelProcess.stderr?.on('data', handleOutput);
641
- tunnelProcess.on('close', (code) => {
642
- log('INFO', `Cloudflared tunnel closed with code ${code}`);
643
- tunnelProcess = null;
644
- tunnelUrl = null;
645
- });
646
- // Timeout after 30 seconds
647
- setTimeout(() => {
648
- if (!tunnelUrl) {
649
- tunnelProcess?.kill();
650
- tunnelProcess = null;
651
- resolve({ success: false, error: 'Tunnel startup timed out' });
652
- }
653
- }, 30000);
654
- });
655
- }
656
- function stopTunnel() {
657
- if (tunnelProcess) {
658
- tunnelProcess.kill();
659
- tunnelProcess = null;
660
- tunnelUrl = null;
661
- log('INFO', 'Cloudflared tunnel stopped');
662
- }
663
- return { success: true };
965
+ // Spec 0100: Gate watcher for af send notifications
966
+ const gateWatcher = new GateWatcher(log);
967
+ let gateWatcherInterval = null;
968
+ function startGateWatcher() {
969
+ gateWatcherInterval = setInterval(async () => {
970
+ const projectPaths = getKnownProjectPaths();
971
+ for (const projectPath of projectPaths) {
972
+ try {
973
+ const gateStatus = getGateStatusForProject(projectPath);
974
+ await gateWatcher.checkAndNotify(gateStatus, projectPath);
975
+ }
976
+ catch (err) {
977
+ log('WARN', `Gate watcher error for ${projectPath}: ${err instanceof Error ? err.message : String(err)}`);
978
+ }
979
+ }
980
+ }, 10_000);
664
981
  }
665
982
  const sseClients = [];
666
983
  let notificationIdCounter = 0;
@@ -680,37 +997,6 @@ function broadcastNotification(notification) {
680
997
  }
681
998
  }
682
999
  }
683
- /**
684
- * Get gate status for a project by querying its dashboard API.
685
- * Uses timeout to prevent hung projects from stalling tower status.
686
- */
687
- async function getGateStatusForProject(basePort) {
688
- const controller = new AbortController();
689
- const timeout = setTimeout(() => controller.abort(), 2000); // 2-second timeout
690
- try {
691
- const response = await fetch(`http://localhost:${basePort}/api/status`, {
692
- signal: controller.signal,
693
- });
694
- clearTimeout(timeout);
695
- if (!response.ok)
696
- return { hasGate: false };
697
- const projectStatus = await response.json();
698
- // Check if any builder has a pending gate
699
- const builderWithGate = projectStatus.builders?.find((b) => b.gateStatus?.waiting || b.status === 'gate-pending');
700
- if (builderWithGate) {
701
- return {
702
- hasGate: true,
703
- gateName: builderWithGate.gateStatus?.gateName || builderWithGate.currentGate,
704
- builderId: builderWithGate.id,
705
- timestamp: builderWithGate.gateStatus?.timestamp || Date.now(),
706
- };
707
- }
708
- }
709
- catch {
710
- // Project dashboard not responding or timeout
711
- }
712
- return { hasGate: false };
713
- }
714
1000
  /**
715
1001
  * Get terminal list for a project from tower's registry.
716
1002
  * Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server fetch.
@@ -719,7 +1005,7 @@ async function getGateStatusForProject(basePort) {
719
1005
  async function getTerminalsForProject(projectPath, proxyUrl) {
720
1006
  const manager = getTerminalManager();
721
1007
  const terminals = [];
722
- // SQLite is authoritative - query it first (Spec 0090 requirement)
1008
+ // Query SQLite first, then augment with tmux discovery
723
1009
  const dbSessions = getTerminalSessionsForProject(projectPath);
724
1010
  // Use normalized path for cache consistency
725
1011
  const normalizedPath = normalizeProjectPath(projectPath);
@@ -728,30 +1014,35 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
728
1014
  // Previous approach cleared the cache then rebuilt, which lost terminals
729
1015
  // if their SQLite rows were deleted by external interference (e.g., tests).
730
1016
  const freshEntry = { builders: new Map(), shells: new Map(), fileTabs: new Map() };
731
- // Preserve file tabs from existing entry (not stored in SQLite)
1017
+ // Load file tabs from SQLite (persisted across restarts)
732
1018
  const existingEntry = projectTerminals.get(normalizedPath);
733
- if (existingEntry) {
1019
+ if (existingEntry && existingEntry.fileTabs.size > 0) {
1020
+ // Use in-memory state if already populated (avoids redundant DB reads)
734
1021
  freshEntry.fileTabs = existingEntry.fileTabs;
735
1022
  }
1023
+ else {
1024
+ freshEntry.fileTabs = loadFileTabsForProject(projectPath);
1025
+ }
736
1026
  for (const dbSession of dbSessions) {
737
1027
  // Verify session still exists in TerminalManager (runtime state)
738
1028
  let session = manager.getSession(dbSession.id);
739
- if (!session && dbSession.tmux_session && tmuxAvailable && tmuxSessionExists(dbSession.tmux_session)) {
1029
+ const sanitizedTmux = dbSession.tmux_session ? sanitizeTmuxSessionName(dbSession.tmux_session) : null;
1030
+ if (!session && sanitizedTmux && tmuxAvailable && tmuxSessionExists(sanitizedTmux)) {
740
1031
  // PTY session gone but tmux session survives — reconnect on-the-fly
741
1032
  try {
742
1033
  const newSession = await manager.createSession({
743
1034
  command: 'tmux',
744
- args: ['attach-session', '-t', dbSession.tmux_session],
1035
+ args: ['attach-session', '-t', sanitizedTmux],
745
1036
  cwd: dbSession.project_path,
746
1037
  label: dbSession.type === 'architect' ? 'Architect' : `${dbSession.type} ${dbSession.role_id || dbSession.id}`,
747
1038
  env: process.env,
748
1039
  });
749
- // Update SQLite with new terminal ID
1040
+ // Update SQLite with new terminal ID (use sanitized tmux name)
750
1041
  deleteTerminalSession(dbSession.id);
751
- saveTerminalSession(newSession.id, dbSession.project_path, dbSession.type, dbSession.role_id, newSession.pid, dbSession.tmux_session);
1042
+ saveTerminalSession(newSession.id, dbSession.project_path, dbSession.type, dbSession.role_id, newSession.pid, sanitizedTmux);
752
1043
  dbSession.id = newSession.id;
753
1044
  session = manager.getSession(newSession.id);
754
- log('INFO', `Reconnected to tmux "${dbSession.tmux_session}" on-the-fly → ${newSession.id}`);
1045
+ log('INFO', `Reconnected to tmux "${sanitizedTmux}" on-the-fly → ${newSession.id}`);
755
1046
  }
756
1047
  catch (err) {
757
1048
  log('WARN', `Failed to reconnect to tmux "${dbSession.tmux_session}": ${err.message} — will retry on next poll`);
@@ -801,7 +1092,7 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
801
1092
  if (existingEntry) {
802
1093
  if (existingEntry.architect && !freshEntry.architect) {
803
1094
  const session = manager.getSession(existingEntry.architect);
804
- if (session) {
1095
+ if (session && session.status === 'running') {
805
1096
  freshEntry.architect = existingEntry.architect;
806
1097
  terminals.push({
807
1098
  type: 'architect',
@@ -815,7 +1106,7 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
815
1106
  for (const [builderId, terminalId] of existingEntry.builders) {
816
1107
  if (!freshEntry.builders.has(builderId)) {
817
1108
  const session = manager.getSession(terminalId);
818
- if (session) {
1109
+ if (session && session.status === 'running') {
819
1110
  freshEntry.builders.set(builderId, terminalId);
820
1111
  terminals.push({
821
1112
  type: 'builder',
@@ -830,7 +1121,7 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
830
1121
  for (const [shellId, terminalId] of existingEntry.shells) {
831
1122
  if (!freshEntry.shells.has(shellId)) {
832
1123
  const session = manager.getSession(terminalId);
833
- if (session) {
1124
+ if (session && session.status === 'running') {
834
1125
  freshEntry.shells.set(shellId, terminalId);
835
1126
  terminals.push({
836
1127
  type: 'shell',
@@ -843,11 +1134,57 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
843
1134
  }
844
1135
  }
845
1136
  }
1137
+ // Phase 3: tmux discovery — find tmux sessions for this project that are
1138
+ // missing from both SQLite and the in-memory cache.
1139
+ // This is the safety net: if SQLite rows got deleted but tmux survived,
1140
+ // the session will still appear in the dashboard.
1141
+ const projectBasename = sanitizeTmuxSessionName(path.basename(normalizedPath));
1142
+ const liveTmux = listCodevTmuxSessions();
1143
+ for (const { tmuxName, parsed } of liveTmux) {
1144
+ // Only process sessions whose sanitized project basename matches
1145
+ if (parsed.projectBasename !== projectBasename)
1146
+ continue;
1147
+ // Skip if we already have this session registered (from SQLite or in-memory)
1148
+ const alreadyRegistered = (parsed.type === 'architect' && freshEntry.architect) ||
1149
+ (parsed.type === 'builder' && parsed.roleId && freshEntry.builders.has(parsed.roleId)) ||
1150
+ (parsed.type === 'shell' && parsed.roleId && freshEntry.shells.has(parsed.roleId));
1151
+ if (alreadyRegistered)
1152
+ continue;
1153
+ // Orphaned tmux session — reconnect it.
1154
+ // Skip architect sessions: launchInstance handles architect creation/reconnection
1155
+ // and has its own exit handler for auto-restart. Reconnecting here races with
1156
+ // the restart logic and can attach to a dead tmux session.
1157
+ if (parsed.type === 'architect')
1158
+ continue;
1159
+ try {
1160
+ const label = `${parsed.type} ${parsed.roleId || 'unknown'}`;
1161
+ const newSession = await manager.createSession({
1162
+ command: 'tmux',
1163
+ args: ['attach-session', '-t', tmuxName],
1164
+ cwd: normalizedPath,
1165
+ label,
1166
+ });
1167
+ const roleId = parsed.roleId;
1168
+ if (parsed.type === 'builder' && roleId) {
1169
+ freshEntry.builders.set(roleId, newSession.id);
1170
+ terminals.push({ type: 'builder', id: roleId, label: `Builder ${roleId}`, url: `${proxyUrl}?tab=builder-${roleId}`, active: true });
1171
+ }
1172
+ else if (parsed.type === 'shell' && roleId) {
1173
+ freshEntry.shells.set(roleId, newSession.id);
1174
+ terminals.push({ type: 'shell', id: roleId, label: `Shell ${roleId.replace('shell-', '')}`, url: `${proxyUrl}?tab=shell-${roleId}`, active: true });
1175
+ }
1176
+ // Persist to SQLite so future polls find it directly
1177
+ saveTerminalSession(newSession.id, normalizedPath, parsed.type, roleId, newSession.pid, tmuxName);
1178
+ log('INFO', `[tmux-discovery] Recovered orphaned tmux "${tmuxName}" → ${newSession.id} (${parsed.type})`);
1179
+ }
1180
+ catch (err) {
1181
+ log('WARN', `[tmux-discovery] Failed to recover tmux "${tmuxName}": ${err.message}`);
1182
+ }
1183
+ }
846
1184
  // Atomically replace the cache entry
847
1185
  projectTerminals.set(normalizedPath, freshEntry);
848
- // Gate status - builders don't have gate tracking yet in tower
849
- // TODO: Add gate status tracking when porch integration is updated
850
- const gateStatus = { hasGate: false };
1186
+ // Read gate status from porch YAML files
1187
+ const gateStatus = getGateStatusForProject(projectPath);
851
1188
  return { terminals, gateStatus };
852
1189
  }
853
1190
  // Resolve once at module load: both symlinked and real temp dir paths
@@ -870,64 +1207,46 @@ function isTempDirectory(projectPath) {
870
1207
  * Get all instances with their status
871
1208
  */
872
1209
  async function getInstances() {
873
- const allocations = loadPortAllocations();
1210
+ const knownPaths = getKnownProjectPaths();
874
1211
  const instances = [];
875
- for (const allocation of allocations) {
1212
+ for (const projectPath of knownPaths) {
876
1213
  // Skip builder worktrees - they're managed by their parent project
877
- if (allocation.project_path.includes('/.builders/')) {
1214
+ if (projectPath.includes('/.builders/')) {
878
1215
  continue;
879
1216
  }
880
1217
  // Skip projects in temp directories (e.g. test artifacts) or whose directories no longer exist
881
- if (!allocation.project_path.startsWith('remote:')) {
882
- if (!fs.existsSync(allocation.project_path)) {
1218
+ if (!projectPath.startsWith('remote:')) {
1219
+ if (!fs.existsSync(projectPath)) {
883
1220
  continue;
884
1221
  }
885
- if (isTempDirectory(allocation.project_path)) {
1222
+ if (isTempDirectory(projectPath)) {
886
1223
  continue;
887
1224
  }
888
1225
  }
889
- const basePort = allocation.base_port;
890
- const dashboardPort = basePort;
891
1226
  // Encode project path for proxy URL
892
- const encodedPath = Buffer.from(allocation.project_path).toString('base64url');
1227
+ const encodedPath = Buffer.from(projectPath).toString('base64url');
893
1228
  const proxyUrl = `/project/${encodedPath}/`;
894
1229
  // Get terminals and gate status from tower's registry
895
1230
  // Phase 4 (Spec 0090): Tower manages terminals directly - no separate dashboard server
896
- const { terminals, gateStatus } = await getTerminalsForProject(allocation.project_path, proxyUrl);
1231
+ const { terminals, gateStatus } = await getTerminalsForProject(projectPath, proxyUrl);
897
1232
  // Project is active if it has any terminals (Phase 4: no port check needed)
898
1233
  const isActive = terminals.length > 0;
899
- const ports = [
900
- {
901
- type: 'Dashboard',
902
- port: dashboardPort,
903
- url: proxyUrl, // Use tower proxy URL, not raw localhost
904
- active: isActive,
905
- },
906
- ];
907
1234
  instances.push({
908
- projectPath: allocation.project_path,
909
- projectName: getProjectName(allocation.project_path),
910
- basePort,
911
- dashboardPort,
912
- architectPort: basePort + 1, // Legacy field for backward compat
913
- registered: allocation.registered_at,
914
- lastUsed: allocation.last_used_at,
1235
+ projectPath,
1236
+ projectName: getProjectName(projectPath),
915
1237
  running: isActive,
916
- proxyUrl, // Tower proxy URL for dashboard
917
- architectUrl: `${proxyUrl}?tab=architect`, // Direct URL to architect terminal
918
- terminals, // All available terminals
919
- ports,
1238
+ proxyUrl,
1239
+ architectUrl: `${proxyUrl}?tab=architect`,
1240
+ terminals,
920
1241
  gateStatus,
921
1242
  });
922
1243
  }
923
- // Sort: running first, then by last used (most recent first)
1244
+ // Sort: running first, then by project name
924
1245
  instances.sort((a, b) => {
925
1246
  if (a.running !== b.running) {
926
1247
  return a.running ? -1 : 1;
927
1248
  }
928
- const aTime = a.lastUsed ? new Date(a.lastUsed).getTime() : 0;
929
- const bTime = b.lastUsed ? new Date(b.lastUsed).getTime() : 0;
930
- return bTime - aTime;
1249
+ return a.projectName.localeCompare(b.projectName);
931
1250
  });
932
1251
  return instances;
933
1252
  }
@@ -943,6 +1262,10 @@ async function getDirectorySuggestions(inputPath) {
943
1262
  if (inputPath.startsWith('~')) {
944
1263
  inputPath = inputPath.replace('~', homedir());
945
1264
  }
1265
+ // Relative paths are meaningless for the tower daemon — only absolute paths
1266
+ if (!path.isAbsolute(inputPath)) {
1267
+ return [];
1268
+ }
946
1269
  // Determine the directory to list and the prefix to filter by
947
1270
  let dirToList;
948
1271
  let prefix;
@@ -996,8 +1319,6 @@ async function getDirectorySuggestions(inputPath) {
996
1319
  * Auto-adopts non-codev directories and creates architect terminal
997
1320
  */
998
1321
  async function launchInstance(projectPath) {
999
- // Clean up stale port allocations before launching (handles machine restarts)
1000
- cleanupStaleEntries();
1001
1322
  // Validate path exists
1002
1323
  if (!fs.existsSync(projectPath)) {
1003
1324
  return { success: false, error: `Path does not exist: ${projectPath}` };
@@ -1028,38 +1349,8 @@ async function launchInstance(projectPath) {
1028
1349
  // Phase 4 (Spec 0090): Tower manages terminals directly
1029
1350
  // No dashboard-server spawning - tower handles everything
1030
1351
  try {
1031
- // Clear any stale state file
1032
- const stateFile = path.join(projectPath, '.agent-farm', 'state.json');
1033
- if (fs.existsSync(stateFile)) {
1034
- try {
1035
- fs.unlinkSync(stateFile);
1036
- }
1037
- catch {
1038
- // Ignore - file might not exist or be locked
1039
- }
1040
- }
1041
1352
  // Ensure project has port allocation
1042
1353
  const resolvedPath = fs.realpathSync(projectPath);
1043
- const db = getGlobalDb();
1044
- let allocation = db
1045
- .prepare('SELECT base_port FROM port_allocations WHERE project_path = ? OR project_path = ?')
1046
- .get(projectPath, resolvedPath);
1047
- if (!allocation) {
1048
- // Allocate a new port for this project
1049
- // Find the next available port block (starting at 4200, incrementing by 100)
1050
- const existingPorts = db
1051
- .prepare('SELECT base_port FROM port_allocations ORDER BY base_port')
1052
- .all();
1053
- let nextPort = 4200;
1054
- for (const { base_port } of existingPorts) {
1055
- if (base_port >= nextPort) {
1056
- nextPort = base_port + 100;
1057
- }
1058
- }
1059
- db.prepare("INSERT INTO port_allocations (project_path, project_name, base_port, created_at) VALUES (?, ?, ?, datetime('now'))").run(resolvedPath, path.basename(projectPath), nextPort);
1060
- allocation = { base_port: nextPort };
1061
- log('INFO', `Allocated port ${nextPort} for project: ${projectPath}`);
1062
- }
1063
1354
  // Initialize project terminal entry
1064
1355
  const entry = getProjectTerminalsEntry(resolvedPath);
1065
1356
  // Create architect terminal if not already present
@@ -1086,14 +1377,26 @@ async function launchInstance(projectPath) {
1086
1377
  let cmdArgs = cmdParts.slice(1);
1087
1378
  // Wrap in tmux for session persistence across Tower restarts
1088
1379
  const tmuxName = `architect-${path.basename(projectPath)}`;
1380
+ const sanitizedTmuxName = sanitizeTmuxSessionName(tmuxName);
1089
1381
  let activeTmuxSession = null;
1090
1382
  if (tmuxAvailable) {
1091
- const tmuxCreated = createTmuxSession(tmuxName, cmd, cmdArgs, projectPath, 200, 50);
1092
- if (tmuxCreated) {
1383
+ // Reuse existing tmux session if it's still alive (e.g., after
1384
+ // disconnect timeout killed the `tmux attach` process but the
1385
+ // architect process inside tmux kept running).
1386
+ if (tmuxSessionExists(sanitizedTmuxName)) {
1093
1387
  cmd = 'tmux';
1094
- cmdArgs = ['attach-session', '-t', tmuxName];
1095
- activeTmuxSession = tmuxName;
1096
- log('INFO', `Created tmux session "${tmuxName}" for architect`);
1388
+ cmdArgs = ['attach-session', '-t', sanitizedTmuxName];
1389
+ activeTmuxSession = sanitizedTmuxName;
1390
+ log('INFO', `Reconnecting to existing tmux session "${sanitizedTmuxName}" for architect`);
1391
+ }
1392
+ else {
1393
+ const createdName = createTmuxSession(tmuxName, cmd, cmdArgs, projectPath, 200, 50);
1394
+ if (createdName) {
1395
+ cmd = 'tmux';
1396
+ cmdArgs = ['attach-session', '-t', createdName];
1397
+ activeTmuxSession = createdName;
1398
+ log('INFO', `Created tmux session "${createdName}" for architect`);
1399
+ }
1097
1400
  }
1098
1401
  }
1099
1402
  const session = await manager.createSession({
@@ -1106,19 +1409,30 @@ async function launchInstance(projectPath) {
1106
1409
  entry.architect = session.id;
1107
1410
  // TICK-001: Save to SQLite for persistence (with tmux session name)
1108
1411
  saveTerminalSession(session.id, resolvedPath, 'architect', null, session.pid, activeTmuxSession);
1109
- // Auto-restart architect on exit (restored from pre-Phase 4 dashboard-server.ts)
1412
+ // Auto-restart architect on exit
1110
1413
  const ptySession = manager.getSession(session.id);
1111
1414
  if (ptySession) {
1112
1415
  const startedAt = Date.now();
1113
1416
  ptySession.on('exit', () => {
1114
- entry.architect = undefined;
1417
+ // Re-read entry from the Map — getTerminalsForProject() periodically
1418
+ // replaces the Map entry with a fresh object, so the `entry` captured
1419
+ // in the closure may be stale.
1420
+ const currentEntry = getProjectTerminalsEntry(resolvedPath);
1421
+ if (currentEntry.architect === session.id) {
1422
+ currentEntry.architect = undefined;
1423
+ }
1115
1424
  deleteTerminalSession(session.id);
1116
- // Kill stale tmux session so restart can create a fresh one
1117
- if (activeTmuxSession) {
1118
- try {
1119
- execSync(`tmux kill-session -t "${activeTmuxSession}" 2>/dev/null`, { stdio: 'ignore' });
1120
- }
1121
- catch { /* already gone */ }
1425
+ // Check if the tmux session's inner process is still alive.
1426
+ // The node-pty process is `tmux attach` — it exits on disconnect
1427
+ // timeout, but the tmux session (and the architect process inside
1428
+ // it) may still be running. Only kill tmux if the inner process
1429
+ // has also exited (e.g., user typed "exit" or process crashed).
1430
+ const tmuxAlive = activeTmuxSession && tmuxSessionExists(activeTmuxSession);
1431
+ if (activeTmuxSession && !tmuxAlive) {
1432
+ log('INFO', `Tmux session "${activeTmuxSession}" already gone for ${projectPath}`);
1433
+ }
1434
+ else if (tmuxAlive) {
1435
+ log('INFO', `Tmux session "${activeTmuxSession}" still alive for ${projectPath}, preserving for reconnect`);
1122
1436
  }
1123
1437
  // Only restart if the architect ran for at least 5s (prevents crash loops)
1124
1438
  const uptime = Date.now() - startedAt;
@@ -1126,6 +1440,12 @@ async function launchInstance(projectPath) {
1126
1440
  log('INFO', `Architect exited after ${uptime}ms for ${projectPath}, not restarting (too short)`);
1127
1441
  return;
1128
1442
  }
1443
+ // Kill the stale tmux session so launchInstance creates a fresh one
1444
+ // instead of reconnecting to the dead session.
1445
+ if (activeTmuxSession && tmuxSessionExists(activeTmuxSession)) {
1446
+ killTmuxSession(activeTmuxSession);
1447
+ log('INFO', `Killed stale tmux session "${activeTmuxSession}" before restart`);
1448
+ }
1129
1449
  log('INFO', `Architect exited for ${projectPath}, restarting in 2s...`);
1130
1450
  setTimeout(() => {
1131
1451
  launchInstance(projectPath).catch((err) => {
@@ -1234,7 +1554,6 @@ const templatePath = findTemplatePath();
1234
1554
  // WebSocket server for terminal connections (Phase 2 - Spec 0090)
1235
1555
  let terminalWss = null;
1236
1556
  // React dashboard dist path (for serving directly from tower)
1237
- // React dashboard dist path (for serving directly from tower)
1238
1557
  // Phase 4 (Spec 0090): Tower serves everything directly, no dashboard-server
1239
1558
  const reactDashboardPath = path.resolve(__dirname, '../../../dashboard/dist');
1240
1559
  const hasReactDashboard = fs.existsSync(reactDashboardPath);
@@ -1320,17 +1639,77 @@ const server = http.createServer(async (req, res) => {
1320
1639
  }));
1321
1640
  return;
1322
1641
  }
1642
+ // =========================================================================
1643
+ // Tunnel Management Endpoints (Spec 0097 Phase 4)
1644
+ // =========================================================================
1645
+ // POST /api/tunnel/connect — Connect or reconnect tunnel to codevos.ai
1646
+ if (req.method === 'POST' && url.pathname === '/api/tunnel/connect') {
1647
+ try {
1648
+ const config = readCloudConfig();
1649
+ if (!config) {
1650
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1651
+ res.end(JSON.stringify({ success: false, error: 'Not registered. Run \'af tower register\' first.' }));
1652
+ return;
1653
+ }
1654
+ // Reset circuit breaker if in auth_failed state
1655
+ if (tunnelClient)
1656
+ tunnelClient.resetCircuitBreaker();
1657
+ const client = await connectTunnel(config);
1658
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1659
+ res.end(JSON.stringify({ success: true, state: client.getState() }));
1660
+ }
1661
+ catch (err) {
1662
+ log('ERROR', `Tunnel connect failed: ${err.message}`);
1663
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1664
+ res.end(JSON.stringify({ success: false, error: err.message }));
1665
+ }
1666
+ return;
1667
+ }
1668
+ // POST /api/tunnel/disconnect — Disconnect tunnel from codevos.ai
1669
+ if (req.method === 'POST' && url.pathname === '/api/tunnel/disconnect') {
1670
+ if (tunnelClient) {
1671
+ tunnelClient.disconnect();
1672
+ tunnelClient = null;
1673
+ }
1674
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1675
+ res.end(JSON.stringify({ success: true }));
1676
+ return;
1677
+ }
1678
+ // GET /api/tunnel/status — Return tunnel connection status
1679
+ if (req.method === 'GET' && url.pathname === '/api/tunnel/status') {
1680
+ let config = null;
1681
+ try {
1682
+ config = readCloudConfig();
1683
+ }
1684
+ catch {
1685
+ // Config file may be corrupted — treat as unregistered
1686
+ }
1687
+ const state = tunnelClient?.getState() ?? 'disconnected';
1688
+ const uptime = tunnelClient?.getUptime() ?? null;
1689
+ const response = {
1690
+ registered: config !== null,
1691
+ state,
1692
+ uptime,
1693
+ };
1694
+ if (config) {
1695
+ response.towerId = config.tower_id;
1696
+ response.towerName = config.tower_name;
1697
+ response.serverUrl = config.server_url;
1698
+ response.accessUrl = `${config.server_url}/t/${config.tower_name}/`;
1699
+ }
1700
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1701
+ res.end(JSON.stringify(response));
1702
+ return;
1703
+ }
1323
1704
  // API: List all projects (Spec 0090 Phase 1)
1324
1705
  if (req.method === 'GET' && url.pathname === '/api/projects') {
1325
1706
  const instances = await getInstances();
1326
1707
  const projects = instances.map((i) => ({
1327
1708
  path: i.projectPath,
1328
1709
  name: i.projectName,
1329
- basePort: i.basePort,
1330
1710
  active: i.running,
1331
1711
  proxyUrl: i.proxyUrl,
1332
1712
  terminals: i.terminals.length,
1333
- lastUsed: i.lastUsed,
1334
1713
  }));
1335
1714
  res.writeHead(200, { 'Content-Type': 'application/json' });
1336
1715
  res.end(JSON.stringify({ projects }));
@@ -1369,7 +1748,6 @@ const server = http.createServer(async (req, res) => {
1369
1748
  path: instance.projectPath,
1370
1749
  name: instance.projectName,
1371
1750
  active: instance.running,
1372
- basePort: instance.basePort,
1373
1751
  terminals: instance.terminals,
1374
1752
  gateStatus: instance.gateStatus,
1375
1753
  }));
@@ -1397,11 +1775,11 @@ const server = http.createServer(async (req, res) => {
1397
1775
  }
1398
1776
  // POST /api/projects/:path/deactivate
1399
1777
  if (req.method === 'POST' && action === 'deactivate') {
1400
- // Check if project exists in port allocations
1401
- const allocations = loadPortAllocations();
1778
+ // Check if project is known (has terminals or sessions)
1779
+ const knownPaths = getKnownProjectPaths();
1402
1780
  const resolvedPath = fs.existsSync(projectPath) ? fs.realpathSync(projectPath) : projectPath;
1403
- const allocation = allocations.find((a) => a.project_path === projectPath || a.project_path === resolvedPath);
1404
- if (!allocation) {
1781
+ const isKnown = knownPaths.some((p) => p === projectPath || p === resolvedPath);
1782
+ if (!isKnown) {
1405
1783
  res.writeHead(404, { 'Content-Type': 'application/json' });
1406
1784
  res.end(JSON.stringify({ ok: false, error: 'Project not found' }));
1407
1785
  return;
@@ -1433,13 +1811,13 @@ const server = http.createServer(async (req, res) => {
1433
1811
  const tmuxSession = typeof body.tmuxSession === 'string' ? body.tmuxSession : null;
1434
1812
  let activeTmuxSession = null;
1435
1813
  if (tmuxSession && tmuxAvailable && command && cwd) {
1436
- const tmuxCreated = createTmuxSession(tmuxSession, command, args || [], cwd, cols || 200, rows || 50);
1437
- if (tmuxCreated) {
1438
- // Override: node-pty attaches to the tmux session
1814
+ const sanitizedName = createTmuxSession(tmuxSession, command, args || [], cwd, cols || 200, rows || 50);
1815
+ if (sanitizedName) {
1816
+ // Override: node-pty attaches to the tmux session (use sanitized name)
1439
1817
  command = 'tmux';
1440
- args = ['attach-session', '-t', tmuxSession];
1441
- activeTmuxSession = tmuxSession;
1442
- log('INFO', `Created tmux session "${tmuxSession}" for terminal`);
1818
+ args = ['attach-session', '-t', sanitizedName];
1819
+ activeTmuxSession = sanitizedName;
1820
+ log('INFO', `Created tmux session "${sanitizedName}" for terminal`);
1443
1821
  }
1444
1822
  // If tmux creation failed, fall through to bare node-pty
1445
1823
  }
@@ -1699,52 +2077,39 @@ const server = http.createServer(async (req, res) => {
1699
2077
  // API: Launch new instance
1700
2078
  if (req.method === 'POST' && url.pathname === '/api/launch') {
1701
2079
  const body = await parseJsonBody(req);
1702
- const projectPath = body.projectPath;
2080
+ let projectPath = body.projectPath;
1703
2081
  if (!projectPath) {
1704
2082
  res.writeHead(400, { 'Content-Type': 'application/json' });
1705
2083
  res.end(JSON.stringify({ success: false, error: 'Missing projectPath' }));
1706
2084
  return;
1707
2085
  }
2086
+ // Expand ~ to home directory
2087
+ if (projectPath.startsWith('~')) {
2088
+ projectPath = projectPath.replace('~', homedir());
2089
+ }
2090
+ // Reject relative paths — tower daemon CWD is unpredictable
2091
+ if (!path.isAbsolute(projectPath)) {
2092
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2093
+ res.end(JSON.stringify({
2094
+ success: false,
2095
+ error: `Relative paths are not supported. Use an absolute path (e.g., /Users/.../project or ~/Development/project).`,
2096
+ }));
2097
+ return;
2098
+ }
2099
+ // Normalize path (resolve .. segments, trailing slashes)
2100
+ projectPath = path.resolve(projectPath);
1708
2101
  const result = await launchInstance(projectPath);
1709
2102
  res.writeHead(result.success ? 200 : 400, { 'Content-Type': 'application/json' });
1710
2103
  res.end(JSON.stringify(result));
1711
2104
  return;
1712
2105
  }
1713
- // API: Get tunnel status (cloudflared availability and running tunnel)
1714
- if (req.method === 'GET' && url.pathname === '/api/tunnel/status') {
1715
- const status = getTunnelStatus();
1716
- res.writeHead(200, { 'Content-Type': 'application/json' });
1717
- res.end(JSON.stringify(status));
1718
- return;
1719
- }
1720
- // API: Start cloudflared tunnel
1721
- if (req.method === 'POST' && url.pathname === '/api/tunnel/start') {
1722
- const result = await startTunnel(port);
1723
- res.writeHead(200, { 'Content-Type': 'application/json' });
1724
- res.end(JSON.stringify(result));
1725
- return;
1726
- }
1727
- // API: Stop cloudflared tunnel
1728
- if (req.method === 'POST' && url.pathname === '/api/tunnel/stop') {
1729
- const result = stopTunnel();
1730
- res.writeHead(200, { 'Content-Type': 'application/json' });
1731
- res.end(JSON.stringify(result));
1732
- return;
1733
- }
1734
2106
  // API: Stop an instance
1735
- // Phase 4 (Spec 0090): Accept projectPath or basePort for backwards compat
1736
2107
  if (req.method === 'POST' && url.pathname === '/api/stop') {
1737
2108
  const body = await parseJsonBody(req);
1738
- let targetPath = body.projectPath;
1739
- // Backwards compat: if basePort provided, find the project path
1740
- if (!targetPath && body.basePort) {
1741
- const allocations = loadPortAllocations();
1742
- const allocation = allocations.find((a) => a.base_port === body.basePort);
1743
- targetPath = allocation?.project_path || '';
1744
- }
2109
+ const targetPath = body.projectPath;
1745
2110
  if (!targetPath) {
1746
2111
  res.writeHead(400, { 'Content-Type': 'application/json' });
1747
- res.end(JSON.stringify({ success: false, error: 'Missing projectPath or basePort' }));
2112
+ res.end(JSON.stringify({ success: false, error: 'Missing projectPath' }));
1748
2113
  return;
1749
2114
  }
1750
2115
  const result = await stopInstance(targetPath);
@@ -1779,8 +2144,8 @@ const server = http.createServer(async (req, res) => {
1779
2144
  const encodedPath = pathParts[2];
1780
2145
  const subPath = pathParts.slice(3).join('/');
1781
2146
  if (!encodedPath) {
1782
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1783
- res.end('Missing project path');
2147
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2148
+ res.end(JSON.stringify({ error: 'Missing project path' }));
1784
2149
  return;
1785
2150
  }
1786
2151
  // Decode Base64URL (RFC 4648)
@@ -1795,11 +2160,10 @@ const server = http.createServer(async (req, res) => {
1795
2160
  projectPath = normalizeProjectPath(projectPath);
1796
2161
  }
1797
2162
  catch {
1798
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1799
- res.end('Invalid project path encoding');
2163
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2164
+ res.end(JSON.stringify({ error: 'Invalid project path encoding' }));
1800
2165
  return;
1801
2166
  }
1802
- const basePort = await getBasePortForProject(projectPath);
1803
2167
  // Phase 4 (Spec 0090): Tower handles everything directly
1804
2168
  const isApiCall = subPath.startsWith('api/') || subPath === 'api';
1805
2169
  const isWsPath = subPath.startsWith('ws/') || subPath === 'ws';
@@ -1854,66 +2218,64 @@ const server = http.createServer(async (req, res) => {
1854
2218
  const apiPath = subPath.replace(/^api\/?/, '');
1855
2219
  // GET /api/state - Return project state (architect, builders, shells)
1856
2220
  if (req.method === 'GET' && (apiPath === 'state' || apiPath === '')) {
2221
+ // Refresh cache via getTerminalsForProject (handles SQLite sync,
2222
+ // tmux reconnection, and tmux discovery in one place)
2223
+ const encodedPath = Buffer.from(projectPath).toString('base64url');
2224
+ const proxyUrl = `/project/${encodedPath}/`;
2225
+ const { gateStatus } = await getTerminalsForProject(projectPath, proxyUrl);
2226
+ // Now read from the refreshed cache
1857
2227
  const entry = getProjectTerminalsEntry(projectPath);
1858
2228
  const manager = getTerminalManager();
1859
- // Build state response compatible with React dashboard
1860
2229
  const state = {
1861
2230
  architect: null,
1862
2231
  builders: [],
1863
2232
  utils: [],
1864
2233
  annotations: [],
1865
2234
  projectName: path.basename(projectPath),
2235
+ gateStatus,
1866
2236
  };
1867
2237
  // Add architect if exists
1868
2238
  if (entry.architect) {
1869
2239
  const session = manager.getSession(entry.architect);
1870
- state.architect = {
1871
- port: basePort || 0,
1872
- pid: session?.pid || 0,
1873
- terminalId: entry.architect,
1874
- };
2240
+ if (session) {
2241
+ state.architect = {
2242
+ port: 0,
2243
+ pid: session.pid || 0,
2244
+ terminalId: entry.architect,
2245
+ };
2246
+ }
1875
2247
  }
1876
- // Add shells (skip stale entries whose terminal session is gone or exited)
1877
- const staleShellIds = [];
2248
+ // Add shells from refreshed cache
1878
2249
  for (const [shellId, terminalId] of entry.shells) {
1879
2250
  const session = manager.getSession(terminalId);
1880
- if (!session || session.status === 'exited') {
1881
- staleShellIds.push(shellId);
1882
- continue;
2251
+ if (session) {
2252
+ state.utils.push({
2253
+ id: shellId,
2254
+ name: `Shell ${shellId.replace('shell-', '')}`,
2255
+ port: 0,
2256
+ pid: session.pid || 0,
2257
+ terminalId,
2258
+ });
1883
2259
  }
1884
- state.utils.push({
1885
- id: shellId,
1886
- name: `Shell ${shellId.replace('shell-', '')}`,
1887
- port: basePort || 0,
1888
- pid: session?.pid || 0,
1889
- terminalId,
1890
- });
1891
2260
  }
1892
- for (const id of staleShellIds)
1893
- entry.shells.delete(id);
1894
- // Add builders (skip stale entries whose terminal session is gone or exited)
1895
- const staleBuilderIds = [];
2261
+ // Add builders from refreshed cache
1896
2262
  for (const [builderId, terminalId] of entry.builders) {
1897
2263
  const session = manager.getSession(terminalId);
1898
- if (!session || session.status === 'exited') {
1899
- staleBuilderIds.push(builderId);
1900
- continue;
2264
+ if (session) {
2265
+ state.builders.push({
2266
+ id: builderId,
2267
+ name: `Builder ${builderId}`,
2268
+ port: 0,
2269
+ pid: session.pid || 0,
2270
+ status: 'running',
2271
+ phase: '',
2272
+ worktree: '',
2273
+ branch: '',
2274
+ type: 'spec',
2275
+ terminalId,
2276
+ });
1901
2277
  }
1902
- state.builders.push({
1903
- id: builderId,
1904
- name: `Builder ${builderId}`,
1905
- port: basePort || 0,
1906
- pid: session?.pid || 0,
1907
- status: 'running',
1908
- phase: '',
1909
- worktree: '',
1910
- branch: '',
1911
- type: 'spec',
1912
- terminalId,
1913
- });
1914
2278
  }
1915
- for (const id of staleBuilderIds)
1916
- entry.builders.delete(id);
1917
2279
  // Add file tabs (Spec 0092 - served through Tower, no separate ports)
1918
2280
  for (const [tabId, tab] of entry.fileTabs) {
1919
2281
  state.annotations.push({
@@ -1938,11 +2300,11 @@ const server = http.createServer(async (req, res) => {
1938
2300
  const tmuxName = `shell-${path.basename(projectPath)}-${shellId}`;
1939
2301
  let activeTmuxSession = null;
1940
2302
  if (tmuxAvailable) {
1941
- const tmuxCreated = createTmuxSession(tmuxName, shellCmd, shellArgs, projectPath, 200, 50);
1942
- if (tmuxCreated) {
2303
+ const sanitizedName = createTmuxSession(tmuxName, shellCmd, shellArgs, projectPath, 200, 50);
2304
+ if (sanitizedName) {
1943
2305
  shellCmd = 'tmux';
1944
- shellArgs = ['attach-session', '-t', tmuxName];
1945
- activeTmuxSession = tmuxName;
2306
+ shellArgs = ['attach-session', '-t', sanitizedName];
2307
+ activeTmuxSession = sanitizedName;
1946
2308
  }
1947
2309
  }
1948
2310
  // Create terminal session
@@ -1961,7 +2323,7 @@ const server = http.createServer(async (req, res) => {
1961
2323
  res.writeHead(200, { 'Content-Type': 'application/json' });
1962
2324
  res.end(JSON.stringify({
1963
2325
  id: shellId,
1964
- port: basePort || 0,
2326
+ port: 0,
1965
2327
  name: `Shell ${shellId.replace('shell-', '')}`,
1966
2328
  terminalId: session.id,
1967
2329
  }));
@@ -1981,45 +2343,79 @@ const server = http.createServer(async (req, res) => {
1981
2343
  req.on('data', (chunk) => data += chunk.toString());
1982
2344
  req.on('end', () => resolve(data));
1983
2345
  });
1984
- const { path: filePath, line } = JSON.parse(body || '{}');
2346
+ const { path: filePath, line, terminalId } = JSON.parse(body || '{}');
1985
2347
  if (!filePath || typeof filePath !== 'string') {
1986
2348
  res.writeHead(400, { 'Content-Type': 'application/json' });
1987
2349
  res.end(JSON.stringify({ error: 'Missing path parameter' }));
1988
2350
  return;
1989
2351
  }
1990
- // Resolve path relative to project
1991
- const fullPath = path.isAbsolute(filePath)
1992
- ? filePath
1993
- : path.join(projectPath, filePath);
1994
- // Security: ensure path is within project or is absolute path user provided
1995
- const normalizedFull = path.normalize(fullPath);
1996
- const normalizedProject = path.normalize(projectPath);
1997
- if (!normalizedFull.startsWith(normalizedProject) && !path.isAbsolute(filePath)) {
2352
+ // Resolve path: use terminal's cwd for relative paths when terminalId is provided
2353
+ let fullPath;
2354
+ if (path.isAbsolute(filePath)) {
2355
+ fullPath = filePath;
2356
+ }
2357
+ else if (terminalId) {
2358
+ const manager = getTerminalManager();
2359
+ const session = manager.getSession(terminalId);
2360
+ if (session) {
2361
+ fullPath = path.join(session.cwd, filePath);
2362
+ }
2363
+ else {
2364
+ log('WARN', `Terminal session ${terminalId} not found, falling back to project root`);
2365
+ fullPath = path.join(projectPath, filePath);
2366
+ }
2367
+ }
2368
+ else {
2369
+ fullPath = path.join(projectPath, filePath);
2370
+ }
2371
+ // Security: symlink-aware containment check
2372
+ // For non-existent files, resolve the parent directory to handle
2373
+ // intermediate symlinks (e.g., /tmp -> /private/tmp on macOS).
2374
+ let resolvedPath;
2375
+ try {
2376
+ resolvedPath = fs.realpathSync(fullPath);
2377
+ }
2378
+ catch {
2379
+ try {
2380
+ resolvedPath = path.join(fs.realpathSync(path.dirname(fullPath)), path.basename(fullPath));
2381
+ }
2382
+ catch {
2383
+ resolvedPath = path.resolve(fullPath);
2384
+ }
2385
+ }
2386
+ let normalizedProject;
2387
+ try {
2388
+ normalizedProject = fs.realpathSync(projectPath);
2389
+ }
2390
+ catch {
2391
+ normalizedProject = path.resolve(projectPath);
2392
+ }
2393
+ const isWithinProject = resolvedPath.startsWith(normalizedProject + path.sep)
2394
+ || resolvedPath === normalizedProject;
2395
+ if (!isWithinProject) {
1998
2396
  res.writeHead(403, { 'Content-Type': 'application/json' });
1999
2397
  res.end(JSON.stringify({ error: 'Path outside project' }));
2000
2398
  return;
2001
2399
  }
2002
- // Check file exists
2003
- if (!fs.existsSync(fullPath)) {
2004
- res.writeHead(404, { 'Content-Type': 'application/json' });
2005
- res.end(JSON.stringify({ error: 'File not found' }));
2006
- return;
2007
- }
2400
+ // Non-existent files still create a tab (spec 0101: file viewer shows "File not found")
2401
+ const fileExists = fs.existsSync(fullPath);
2008
2402
  const entry = getProjectTerminalsEntry(projectPath);
2009
2403
  // Check if already open
2010
2404
  for (const [id, tab] of entry.fileTabs) {
2011
2405
  if (tab.path === fullPath) {
2012
2406
  res.writeHead(200, { 'Content-Type': 'application/json' });
2013
- res.end(JSON.stringify({ id, existing: true, line }));
2407
+ res.end(JSON.stringify({ id, existing: true, line, notFound: !fileExists }));
2014
2408
  return;
2015
2409
  }
2016
2410
  }
2017
- // Create new file tab
2018
- const id = `file-${Date.now().toString(36)}`;
2019
- entry.fileTabs.set(id, { id, path: fullPath, createdAt: Date.now() });
2411
+ // Create new file tab (write-through: in-memory + SQLite)
2412
+ const id = `file-${crypto.randomUUID()}`;
2413
+ const createdAt = Date.now();
2414
+ entry.fileTabs.set(id, { id, path: fullPath, createdAt });
2415
+ saveFileTab(id, projectPath, fullPath, createdAt);
2020
2416
  log('INFO', `Created file tab: ${id} for ${path.basename(fullPath)}`);
2021
2417
  res.writeHead(200, { 'Content-Type': 'application/json' });
2022
- res.end(JSON.stringify({ id, existing: false, line }));
2418
+ res.end(JSON.stringify({ id, existing: false, line, notFound: !fileExists }));
2023
2419
  }
2024
2420
  catch (err) {
2025
2421
  log('ERROR', `Failed to create file tab: ${err.message}`);
@@ -2074,6 +2470,7 @@ const server = http.createServer(async (req, res) => {
2074
2470
  }
2075
2471
  }
2076
2472
  catch (err) {
2473
+ log('ERROR', `GET /api/file/:id failed: ${err.message}`);
2077
2474
  res.writeHead(500, { 'Content-Type': 'application/json' });
2078
2475
  res.end(JSON.stringify({ error: err.message }));
2079
2476
  }
@@ -2086,8 +2483,8 @@ const server = http.createServer(async (req, res) => {
2086
2483
  const entry = getProjectTerminalsEntry(projectPath);
2087
2484
  const tab = entry.fileTabs.get(tabId);
2088
2485
  if (!tab) {
2089
- res.writeHead(404, { 'Content-Type': 'text/plain' });
2090
- res.end('File tab not found');
2486
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2487
+ res.end(JSON.stringify({ error: 'File tab not found' }));
2091
2488
  return;
2092
2489
  }
2093
2490
  try {
@@ -2101,8 +2498,9 @@ const server = http.createServer(async (req, res) => {
2101
2498
  res.end(data);
2102
2499
  }
2103
2500
  catch (err) {
2104
- res.writeHead(500, { 'Content-Type': 'text/plain' });
2105
- res.end(err.message);
2501
+ log('ERROR', `GET /api/file/:id/raw failed: ${err.message}`);
2502
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2503
+ res.end(JSON.stringify({ error: err.message }));
2106
2504
  }
2107
2505
  return;
2108
2506
  }
@@ -2135,6 +2533,7 @@ const server = http.createServer(async (req, res) => {
2135
2533
  res.end(JSON.stringify({ success: true }));
2136
2534
  }
2137
2535
  catch (err) {
2536
+ log('ERROR', `POST /api/file/:id/save failed: ${err.message}`);
2138
2537
  res.writeHead(500, { 'Content-Type': 'application/json' });
2139
2538
  res.end(JSON.stringify({ error: err.message }));
2140
2539
  }
@@ -2146,10 +2545,11 @@ const server = http.createServer(async (req, res) => {
2146
2545
  const tabId = deleteMatch[1];
2147
2546
  const entry = getProjectTerminalsEntry(projectPath);
2148
2547
  const manager = getTerminalManager();
2149
- // Check if it's a file tab first (Spec 0092)
2548
+ // Check if it's a file tab first (Spec 0092, write-through: in-memory + SQLite)
2150
2549
  if (tabId.startsWith('file-')) {
2151
2550
  if (entry.fileTabs.has(tabId)) {
2152
2551
  entry.fileTabs.delete(tabId);
2552
+ deleteFileTab(tabId);
2153
2553
  log('INFO', `Deleted file tab: ${tabId}`);
2154
2554
  res.writeHead(204);
2155
2555
  res.end();
@@ -2289,7 +2689,8 @@ const server = http.createServer(async (req, res) => {
2289
2689
  res.end(JSON.stringify({ modified, staged, untracked }));
2290
2690
  }
2291
2691
  catch (err) {
2292
- // Not a git repo or git command failed
2692
+ // Not a git repo or git command failed — return graceful degradation with error field
2693
+ log('WARN', `GET /api/git/status failed: ${err.message}`);
2293
2694
  res.writeHead(200, { 'Content-Type': 'application/json' });
2294
2695
  res.end(JSON.stringify({ modified: [], staged: [], untracked: [], error: err.message }));
2295
2696
  }
@@ -2320,8 +2721,8 @@ const server = http.createServer(async (req, res) => {
2320
2721
  const entry = getProjectTerminalsEntry(projectPath);
2321
2722
  const tab = entry.fileTabs.get(tabId);
2322
2723
  if (!tab) {
2323
- res.writeHead(404, { 'Content-Type': 'text/plain' });
2324
- res.end('File tab not found');
2724
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2725
+ res.end(JSON.stringify({ error: 'File tab not found' }));
2325
2726
  return;
2326
2727
  }
2327
2728
  const filePath = tab.path;
@@ -2339,8 +2740,9 @@ const server = http.createServer(async (req, res) => {
2339
2740
  res.end(content);
2340
2741
  }
2341
2742
  catch (err) {
2342
- res.writeHead(500, { 'Content-Type': 'text/plain' });
2343
- res.end(err.message);
2743
+ log('ERROR', `GET /api/annotate/:id/file failed: ${err.message}`);
2744
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2745
+ res.end(JSON.stringify({ error: err.message }));
2344
2746
  }
2345
2747
  return;
2346
2748
  }
@@ -2364,8 +2766,9 @@ const server = http.createServer(async (req, res) => {
2364
2766
  res.end(JSON.stringify({ ok: true }));
2365
2767
  }
2366
2768
  catch (err) {
2367
- res.writeHead(500, { 'Content-Type': 'text/plain' });
2368
- res.end(err.message);
2769
+ log('ERROR', `POST /api/annotate/:id/save failed: ${err.message}`);
2770
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2771
+ res.end(JSON.stringify({ error: err.message }));
2369
2772
  }
2370
2773
  return;
2371
2774
  }
@@ -2377,8 +2780,9 @@ const server = http.createServer(async (req, res) => {
2377
2780
  res.end(JSON.stringify({ mtime: stat.mtimeMs }));
2378
2781
  }
2379
2782
  catch (err) {
2380
- res.writeHead(500, { 'Content-Type': 'text/plain' });
2381
- res.end(err.message);
2783
+ log('ERROR', `GET /api/annotate/:id/api/mtime failed: ${err.message}`);
2784
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2785
+ res.end(JSON.stringify({ error: err.message }));
2382
2786
  }
2383
2787
  return;
2384
2788
  }
@@ -2395,8 +2799,9 @@ const server = http.createServer(async (req, res) => {
2395
2799
  res.end(data);
2396
2800
  }
2397
2801
  catch (err) {
2398
- res.writeHead(500, { 'Content-Type': 'text/plain' });
2399
- res.end(err.message);
2802
+ log('ERROR', `GET /api/annotate/:id/${subRoute} failed: ${err.message}`);
2803
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2804
+ res.end(JSON.stringify({ error: err.message }));
2400
2805
  }
2401
2806
  return;
2402
2807
  }
@@ -2484,8 +2889,8 @@ const server = http.createServer(async (req, res) => {
2484
2889
  }
2485
2890
  catch (err) {
2486
2891
  log('ERROR', `Request error: ${err.message}`);
2487
- res.writeHead(500, { 'Content-Type': 'text/plain' });
2488
- res.end('Internal server error: ' + err.message);
2892
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2893
+ res.end(JSON.stringify({ error: err.message }));
2489
2894
  }
2490
2895
  });
2491
2896
  // SECURITY: Bind to localhost only to prevent network exposure
@@ -2496,6 +2901,25 @@ server.listen(port, '127.0.0.1', async () => {
2496
2901
  log('INFO', `tmux available: ${tmuxAvailable}${tmuxAvailable ? '' : ' (terminals will not persist across restarts)'}`);
2497
2902
  // TICK-001: Reconcile terminal sessions from previous run
2498
2903
  await reconcileTerminalSessions();
2904
+ // Spec 0100: Start background gate watcher for af send notifications
2905
+ startGateWatcher();
2906
+ log('INFO', 'Gate watcher started (10s poll interval)');
2907
+ // Spec 0097 Phase 4: Auto-connect tunnel if registered
2908
+ try {
2909
+ const config = readCloudConfig();
2910
+ if (config) {
2911
+ log('INFO', `Cloud config found, connecting tunnel (tower: ${config.tower_name}, key: ${maskApiKey(config.api_key)})`);
2912
+ await connectTunnel(config);
2913
+ }
2914
+ else {
2915
+ log('INFO', 'No cloud config found, operating in local-only mode');
2916
+ }
2917
+ }
2918
+ catch (err) {
2919
+ log('WARN', `Failed to read cloud config: ${err.message}. Operating in local-only mode.`);
2920
+ }
2921
+ // Start watching cloud-config.json for changes
2922
+ startConfigWatcher();
2499
2923
  });
2500
2924
  // Initialize terminal WebSocket server (Phase 2 - Spec 0090)
2501
2925
  terminalWss = new WebSocketServer({ noServer: true });