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

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 (150) hide show
  1. package/dashboard/dist/assets/index-C7FtNK6Y.css +32 -0
  2. package/dashboard/dist/assets/index-DZuzzh0T.js +131 -0
  3. package/dashboard/dist/assets/index-DZuzzh0T.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 +17 -31
  23. package/dist/agent-farm/commands/open.js.map +1 -1
  24. package/dist/agent-farm/commands/shell.d.ts.map +1 -1
  25. package/dist/agent-farm/commands/shell.js +27 -38
  26. package/dist/agent-farm/commands/shell.js.map +1 -1
  27. package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
  28. package/dist/agent-farm/commands/spawn.js +113 -90
  29. package/dist/agent-farm/commands/spawn.js.map +1 -1
  30. package/dist/agent-farm/commands/start.d.ts +7 -20
  31. package/dist/agent-farm/commands/start.d.ts.map +1 -1
  32. package/dist/agent-farm/commands/start.js +3 -242
  33. package/dist/agent-farm/commands/start.js.map +1 -1
  34. package/dist/agent-farm/commands/status.d.ts.map +1 -1
  35. package/dist/agent-farm/commands/status.js +22 -29
  36. package/dist/agent-farm/commands/status.js.map +1 -1
  37. package/dist/agent-farm/commands/stop.d.ts.map +1 -1
  38. package/dist/agent-farm/commands/stop.js +43 -172
  39. package/dist/agent-farm/commands/stop.js.map +1 -1
  40. package/dist/agent-farm/commands/tower-cloud.d.ts +47 -0
  41. package/dist/agent-farm/commands/tower-cloud.d.ts.map +1 -0
  42. package/dist/agent-farm/commands/tower-cloud.js +316 -0
  43. package/dist/agent-farm/commands/tower-cloud.js.map +1 -0
  44. package/dist/agent-farm/db/index.d.ts +6 -2
  45. package/dist/agent-farm/db/index.d.ts.map +1 -1
  46. package/dist/agent-farm/db/index.js +56 -31
  47. package/dist/agent-farm/db/index.js.map +1 -1
  48. package/dist/agent-farm/db/migrate.d.ts +0 -4
  49. package/dist/agent-farm/db/migrate.d.ts.map +1 -1
  50. package/dist/agent-farm/db/migrate.js +0 -46
  51. package/dist/agent-farm/db/migrate.js.map +1 -1
  52. package/dist/agent-farm/db/schema.d.ts +3 -3
  53. package/dist/agent-farm/db/schema.d.ts.map +1 -1
  54. package/dist/agent-farm/db/schema.js +3 -17
  55. package/dist/agent-farm/db/schema.js.map +1 -1
  56. package/dist/agent-farm/db/types.d.ts +0 -10
  57. package/dist/agent-farm/db/types.d.ts.map +1 -1
  58. package/dist/agent-farm/db/types.js +0 -8
  59. package/dist/agent-farm/db/types.js.map +1 -1
  60. package/dist/agent-farm/hq-connector.d.ts +1 -1
  61. package/dist/agent-farm/hq-connector.js +1 -1
  62. package/dist/agent-farm/lib/cloud-config.d.ts +46 -0
  63. package/dist/agent-farm/lib/cloud-config.d.ts.map +1 -0
  64. package/dist/agent-farm/lib/cloud-config.js +106 -0
  65. package/dist/agent-farm/lib/cloud-config.js.map +1 -0
  66. package/dist/agent-farm/lib/tower-client.d.ts +7 -5
  67. package/dist/agent-farm/lib/tower-client.d.ts.map +1 -1
  68. package/dist/agent-farm/lib/tower-client.js.map +1 -1
  69. package/dist/agent-farm/lib/tunnel-client.d.ts +117 -0
  70. package/dist/agent-farm/lib/tunnel-client.d.ts.map +1 -0
  71. package/dist/agent-farm/lib/tunnel-client.js +502 -0
  72. package/dist/agent-farm/lib/tunnel-client.js.map +1 -0
  73. package/dist/agent-farm/servers/tower-server.js +559 -341
  74. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  75. package/dist/agent-farm/state.d.ts +2 -2
  76. package/dist/agent-farm/state.d.ts.map +1 -1
  77. package/dist/agent-farm/state.js +6 -16
  78. package/dist/agent-farm/state.js.map +1 -1
  79. package/dist/agent-farm/types.d.ts +1 -18
  80. package/dist/agent-farm/types.d.ts.map +1 -1
  81. package/dist/agent-farm/utils/config.d.ts +0 -5
  82. package/dist/agent-farm/utils/config.d.ts.map +1 -1
  83. package/dist/agent-farm/utils/config.js +0 -31
  84. package/dist/agent-farm/utils/config.js.map +1 -1
  85. package/dist/agent-farm/utils/file-tabs.d.ts +27 -0
  86. package/dist/agent-farm/utils/file-tabs.d.ts.map +1 -0
  87. package/dist/agent-farm/utils/file-tabs.js +46 -0
  88. package/dist/agent-farm/utils/file-tabs.js.map +1 -0
  89. package/dist/agent-farm/utils/gate-status.d.ts +16 -0
  90. package/dist/agent-farm/utils/gate-status.d.ts.map +1 -0
  91. package/dist/agent-farm/utils/gate-status.js +79 -0
  92. package/dist/agent-farm/utils/gate-status.js.map +1 -0
  93. package/dist/agent-farm/utils/gate-watcher.d.ts +38 -0
  94. package/dist/agent-farm/utils/gate-watcher.d.ts.map +1 -0
  95. package/dist/agent-farm/utils/gate-watcher.js +122 -0
  96. package/dist/agent-farm/utils/gate-watcher.js.map +1 -0
  97. package/dist/agent-farm/utils/index.d.ts +0 -1
  98. package/dist/agent-farm/utils/index.d.ts.map +1 -1
  99. package/dist/agent-farm/utils/index.js +0 -1
  100. package/dist/agent-farm/utils/index.js.map +1 -1
  101. package/dist/agent-farm/utils/notifications.js +1 -1
  102. package/dist/agent-farm/utils/notifications.js.map +1 -1
  103. package/dist/agent-farm/utils/server-utils.d.ts +1 -1
  104. package/dist/agent-farm/utils/server-utils.js +1 -1
  105. package/dist/agent-farm/utils/session.d.ts +10 -0
  106. package/dist/agent-farm/utils/session.d.ts.map +1 -0
  107. package/dist/agent-farm/utils/session.js +12 -0
  108. package/dist/agent-farm/utils/session.js.map +1 -0
  109. package/dist/commands/adopt.js +1 -1
  110. package/dist/commands/adopt.js.map +1 -1
  111. package/dist/commands/consult/index.d.ts.map +1 -1
  112. package/dist/commands/consult/index.js +23 -14
  113. package/dist/commands/consult/index.js.map +1 -1
  114. package/dist/commands/init.js +1 -1
  115. package/dist/commands/init.js.map +1 -1
  116. package/dist/commands/porch/index.d.ts.map +1 -1
  117. package/dist/commands/porch/index.js +35 -12
  118. package/dist/commands/porch/index.js.map +1 -1
  119. package/dist/commands/porch/next.js +11 -3
  120. package/dist/commands/porch/next.js.map +1 -1
  121. package/dist/commands/porch/verdict.d.ts +8 -0
  122. package/dist/commands/porch/verdict.d.ts.map +1 -1
  123. package/dist/commands/porch/verdict.js +13 -0
  124. package/dist/commands/porch/verdict.js.map +1 -1
  125. package/dist/terminal/pty-session.d.ts +2 -0
  126. package/dist/terminal/pty-session.d.ts.map +1 -1
  127. package/dist/terminal/pty-session.js +4 -0
  128. package/dist/terminal/pty-session.js.map +1 -1
  129. package/package.json +1 -1
  130. package/skeleton/.claude/skills/af/SKILL.md +15 -0
  131. package/skeleton/protocols/spir/prompts/review.md +15 -16
  132. package/skeleton/protocols/spir/protocol.json +4 -0
  133. package/skeleton/protocols/spir/templates/review.md +81 -199
  134. package/skeleton/resources/commands/agent-farm.md +38 -2
  135. package/templates/tower.html +7 -150
  136. package/dashboard/dist/assets/index-CXloFYpB.css +0 -32
  137. package/dashboard/dist/assets/index-Ca2fjOJf.js +0 -131
  138. package/dashboard/dist/assets/index-Ca2fjOJf.js.map +0 -1
  139. package/dist/agent-farm/utils/orphan-handler.d.ts +0 -27
  140. package/dist/agent-farm/utils/orphan-handler.d.ts.map +0 -1
  141. package/dist/agent-farm/utils/orphan-handler.js +0 -149
  142. package/dist/agent-farm/utils/orphan-handler.js.map +0 -1
  143. package/dist/agent-farm/utils/port-registry.d.ts +0 -57
  144. package/dist/agent-farm/utils/port-registry.d.ts.map +0 -1
  145. package/dist/agent-farm/utils/port-registry.js +0 -166
  146. package/dist/agent-farm/utils/port-registry.js.map +0 -1
  147. package/dist/agent-farm/utils/terminal-ports.d.ts +0 -18
  148. package/dist/agent-farm/utils/terminal-ports.d.ts.map +0 -1
  149. package/dist/agent-farm/utils/terminal-ports.js +0 -35
  150. 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
  /**
@@ -254,11 +470,13 @@ function createTmuxSession(sessionName, command, args, cwd, cols, rows) {
254
470
  log('WARN', `tmux new-session exited with code ${result.status} for "${sessionName}"`);
255
471
  return null;
256
472
  }
257
- // Hide tmux status bar (dashboard has its own tabs), enable mouse, and
258
- // 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.
259
478
  spawnSync('tmux', ['set-option', '-t', sessionName, 'status', 'off'], { stdio: 'ignore' });
260
479
  spawnSync('tmux', ['set-option', '-t', sessionName, 'mouse', 'on'], { stdio: 'ignore' });
261
- spawnSync('tmux', ['set-option', '-t', sessionName, 'aggressive-resize', 'on'], { stdio: 'ignore' });
262
480
  return sessionName;
263
481
  }
264
482
  catch (err) {
@@ -381,20 +599,14 @@ function findSqliteRowForTmuxSession(tmuxName) {
381
599
  }
382
600
  /**
383
601
  * Find the full project path for a tmux session's project basename.
384
- * Checks active port allocations (which have full paths) for a matching basename.
602
+ * Checks known projects (terminal_sessions + in-memory cache) for a matching basename.
385
603
  * Returns null if no match found.
386
604
  */
387
605
  function resolveProjectPathFromBasename(projectBasename) {
388
- const allocations = loadPortAllocations();
389
- for (const alloc of allocations) {
390
- if (path.basename(alloc.project_path) === projectBasename) {
391
- return normalizeProjectPath(alloc.project_path);
392
- }
393
- }
394
- // Also check projectTerminals cache (may have entries not yet in allocations)
395
- for (const [projectPath] of projectTerminals) {
606
+ const knownPaths = getKnownProjectPaths();
607
+ for (const projectPath of knownPaths) {
396
608
  if (path.basename(projectPath) === projectBasename) {
397
- return projectPath;
609
+ return normalizeProjectPath(projectPath);
398
610
  }
399
611
  }
400
612
  return null;
@@ -402,7 +614,20 @@ function resolveProjectPathFromBasename(projectBasename) {
402
614
  /**
403
615
  * Reconcile terminal sessions on startup.
404
616
  *
405
- * STRATEGY: tmux is the source of truth for existence.
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).
406
631
  *
407
632
  * Phase 1 — tmux-first discovery:
408
633
  * List all codev tmux sessions. For each, look up SQLite for metadata.
@@ -437,6 +662,23 @@ async function reconcileTerminalSessions() {
437
662
  log('WARN', `Cannot resolve project path for tmux session "${tmuxName}" (basename: ${parsed.projectBasename}) — skipping`);
438
663
  continue;
439
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
+ }
440
682
  try {
441
683
  const label = type === 'architect' ? 'Architect' : `${type} ${roleId || 'unknown'}`;
442
684
  const newSession = await manager.createSession({
@@ -668,8 +910,19 @@ async function gracefulShutdown(signal) {
668
910
  log('INFO', 'Shutting down terminal manager...');
669
911
  terminalManager.shutdown();
670
912
  }
671
- // 4. Stop cloudflared tunnel if running
672
- 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
+ }
673
926
  log('INFO', 'Graceful shutdown complete');
674
927
  process.exit(0);
675
928
  }
@@ -682,38 +935,26 @@ if (isNaN(port) || port < 1 || port > 65535) {
682
935
  }
683
936
  log('INFO', `Tower server starting on port ${port}`);
684
937
  /**
685
- * Load port allocations from SQLite database
938
+ * Get all known project paths from terminal_sessions and in-memory cache
686
939
  */
687
- function loadPortAllocations() {
940
+ function getKnownProjectPaths() {
941
+ const projectPaths = new Set();
942
+ // From terminal_sessions table (persists across Tower restarts)
688
943
  try {
689
944
  const db = getGlobalDb();
690
- 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
+ }
691
949
  }
692
- catch (err) {
693
- log('ERROR', `Error loading port allocations: ${err.message}`);
694
- return [];
950
+ catch {
951
+ // Table may not exist yet
695
952
  }
696
- }
697
- /**
698
- * Check if a port is listening
699
- */
700
- async function isPortListening(port) {
701
- return new Promise((resolve) => {
702
- const socket = new net.Socket();
703
- socket.setTimeout(1000);
704
- socket.on('connect', () => {
705
- socket.destroy();
706
- resolve(true);
707
- });
708
- socket.on('timeout', () => {
709
- socket.destroy();
710
- resolve(false);
711
- });
712
- socket.on('error', () => {
713
- resolve(false);
714
- });
715
- socket.connect(port, '127.0.0.1');
716
- });
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);
717
958
  }
718
959
  /**
719
960
  * Get project name from path
@@ -721,88 +962,22 @@ async function isPortListening(port) {
721
962
  function getProjectName(projectPath) {
722
963
  return path.basename(projectPath);
723
964
  }
724
- /**
725
- * Get the base port for a project from global.db
726
- * Returns null if project not found or not running
727
- */
728
- async function getBasePortForProject(projectPath) {
729
- try {
730
- const db = getGlobalDb();
731
- const row = db.prepare('SELECT base_port FROM port_allocations WHERE project_path = ?').get(projectPath);
732
- if (!row)
733
- return null;
734
- // Check if actually running
735
- const isRunning = await isPortListening(row.base_port);
736
- return isRunning ? row.base_port : null;
737
- }
738
- catch {
739
- return null;
740
- }
741
- }
742
- // Cloudflared tunnel management
743
- let tunnelProcess = null;
744
- let tunnelUrl = null;
745
- function isCloudflaredInstalled() {
746
- try {
747
- execSync('which cloudflared', { stdio: 'ignore' });
748
- return true;
749
- }
750
- catch {
751
- return false;
752
- }
753
- }
754
- function getTunnelStatus() {
755
- return {
756
- available: isCloudflaredInstalled(),
757
- running: tunnelProcess !== null && tunnelUrl !== null,
758
- url: tunnelUrl,
759
- };
760
- }
761
- async function startTunnel(port) {
762
- if (!isCloudflaredInstalled()) {
763
- return { success: false, error: 'cloudflared not installed. Install with: brew install cloudflared' };
764
- }
765
- if (tunnelProcess) {
766
- return { success: true, url: tunnelUrl || undefined };
767
- }
768
- return new Promise((resolve) => {
769
- tunnelProcess = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
770
- stdio: ['ignore', 'pipe', 'pipe'],
771
- });
772
- const handleOutput = (data) => {
773
- const text = data.toString();
774
- const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
775
- if (match && !tunnelUrl) {
776
- tunnelUrl = match[0];
777
- log('INFO', `Cloudflared tunnel started: ${tunnelUrl}`);
778
- resolve({ success: true, url: tunnelUrl });
779
- }
780
- };
781
- tunnelProcess.stdout?.on('data', handleOutput);
782
- tunnelProcess.stderr?.on('data', handleOutput);
783
- tunnelProcess.on('close', (code) => {
784
- log('INFO', `Cloudflared tunnel closed with code ${code}`);
785
- tunnelProcess = null;
786
- tunnelUrl = null;
787
- });
788
- // Timeout after 30 seconds
789
- setTimeout(() => {
790
- if (!tunnelUrl) {
791
- tunnelProcess?.kill();
792
- tunnelProcess = null;
793
- resolve({ success: false, error: 'Tunnel startup timed out' });
794
- }
795
- }, 30000);
796
- });
797
- }
798
- function stopTunnel() {
799
- if (tunnelProcess) {
800
- tunnelProcess.kill();
801
- tunnelProcess = null;
802
- tunnelUrl = null;
803
- log('INFO', 'Cloudflared tunnel stopped');
804
- }
805
- 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);
806
981
  }
807
982
  const sseClients = [];
808
983
  let notificationIdCounter = 0;
@@ -822,37 +997,6 @@ function broadcastNotification(notification) {
822
997
  }
823
998
  }
824
999
  }
825
- /**
826
- * Get gate status for a project by querying its dashboard API.
827
- * Uses timeout to prevent hung projects from stalling tower status.
828
- */
829
- async function getGateStatusForProject(basePort) {
830
- const controller = new AbortController();
831
- const timeout = setTimeout(() => controller.abort(), 2000); // 2-second timeout
832
- try {
833
- const response = await fetch(`http://localhost:${basePort}/api/status`, {
834
- signal: controller.signal,
835
- });
836
- clearTimeout(timeout);
837
- if (!response.ok)
838
- return { hasGate: false };
839
- const projectStatus = await response.json();
840
- // Check if any builder has a pending gate
841
- const builderWithGate = projectStatus.builders?.find((b) => b.gateStatus?.waiting || b.status === 'gate-pending');
842
- if (builderWithGate) {
843
- return {
844
- hasGate: true,
845
- gateName: builderWithGate.gateStatus?.gateName || builderWithGate.currentGate,
846
- builderId: builderWithGate.id,
847
- timestamp: builderWithGate.gateStatus?.timestamp || Date.now(),
848
- };
849
- }
850
- }
851
- catch {
852
- // Project dashboard not responding or timeout
853
- }
854
- return { hasGate: false };
855
- }
856
1000
  /**
857
1001
  * Get terminal list for a project from tower's registry.
858
1002
  * Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server fetch.
@@ -870,11 +1014,15 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
870
1014
  // Previous approach cleared the cache then rebuilt, which lost terminals
871
1015
  // if their SQLite rows were deleted by external interference (e.g., tests).
872
1016
  const freshEntry = { builders: new Map(), shells: new Map(), fileTabs: new Map() };
873
- // Preserve file tabs from existing entry (not stored in SQLite)
1017
+ // Load file tabs from SQLite (persisted across restarts)
874
1018
  const existingEntry = projectTerminals.get(normalizedPath);
875
- if (existingEntry) {
1019
+ if (existingEntry && existingEntry.fileTabs.size > 0) {
1020
+ // Use in-memory state if already populated (avoids redundant DB reads)
876
1021
  freshEntry.fileTabs = existingEntry.fileTabs;
877
1022
  }
1023
+ else {
1024
+ freshEntry.fileTabs = loadFileTabsForProject(projectPath);
1025
+ }
878
1026
  for (const dbSession of dbSessions) {
879
1027
  // Verify session still exists in TerminalManager (runtime state)
880
1028
  let session = manager.getSession(dbSession.id);
@@ -944,7 +1092,7 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
944
1092
  if (existingEntry) {
945
1093
  if (existingEntry.architect && !freshEntry.architect) {
946
1094
  const session = manager.getSession(existingEntry.architect);
947
- if (session) {
1095
+ if (session && session.status === 'running') {
948
1096
  freshEntry.architect = existingEntry.architect;
949
1097
  terminals.push({
950
1098
  type: 'architect',
@@ -958,7 +1106,7 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
958
1106
  for (const [builderId, terminalId] of existingEntry.builders) {
959
1107
  if (!freshEntry.builders.has(builderId)) {
960
1108
  const session = manager.getSession(terminalId);
961
- if (session) {
1109
+ if (session && session.status === 'running') {
962
1110
  freshEntry.builders.set(builderId, terminalId);
963
1111
  terminals.push({
964
1112
  type: 'builder',
@@ -973,7 +1121,7 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
973
1121
  for (const [shellId, terminalId] of existingEntry.shells) {
974
1122
  if (!freshEntry.shells.has(shellId)) {
975
1123
  const session = manager.getSession(terminalId);
976
- if (session) {
1124
+ if (session && session.status === 'running') {
977
1125
  freshEntry.shells.set(shellId, terminalId);
978
1126
  terminals.push({
979
1127
  type: 'shell',
@@ -1002,9 +1150,14 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
1002
1150
  (parsed.type === 'shell' && parsed.roleId && freshEntry.shells.has(parsed.roleId));
1003
1151
  if (alreadyRegistered)
1004
1152
  continue;
1005
- // Orphaned tmux session — reconnect it
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;
1006
1159
  try {
1007
- const label = parsed.type === 'architect' ? 'Architect' : `${parsed.type} ${parsed.roleId || 'unknown'}`;
1160
+ const label = `${parsed.type} ${parsed.roleId || 'unknown'}`;
1008
1161
  const newSession = await manager.createSession({
1009
1162
  command: 'tmux',
1010
1163
  args: ['attach-session', '-t', tmuxName],
@@ -1012,11 +1165,7 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
1012
1165
  label,
1013
1166
  });
1014
1167
  const roleId = parsed.roleId;
1015
- if (parsed.type === 'architect') {
1016
- freshEntry.architect = newSession.id;
1017
- terminals.push({ type: 'architect', id: 'architect', label: 'Architect', url: `${proxyUrl}?tab=architect`, active: true });
1018
- }
1019
- else if (parsed.type === 'builder' && roleId) {
1168
+ if (parsed.type === 'builder' && roleId) {
1020
1169
  freshEntry.builders.set(roleId, newSession.id);
1021
1170
  terminals.push({ type: 'builder', id: roleId, label: `Builder ${roleId}`, url: `${proxyUrl}?tab=builder-${roleId}`, active: true });
1022
1171
  }
@@ -1034,9 +1183,8 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
1034
1183
  }
1035
1184
  // Atomically replace the cache entry
1036
1185
  projectTerminals.set(normalizedPath, freshEntry);
1037
- // Gate status - builders don't have gate tracking yet in tower
1038
- // TODO: Add gate status tracking when porch integration is updated
1039
- const gateStatus = { hasGate: false };
1186
+ // Read gate status from porch YAML files
1187
+ const gateStatus = getGateStatusForProject(projectPath);
1040
1188
  return { terminals, gateStatus };
1041
1189
  }
1042
1190
  // Resolve once at module load: both symlinked and real temp dir paths
@@ -1059,64 +1207,46 @@ function isTempDirectory(projectPath) {
1059
1207
  * Get all instances with their status
1060
1208
  */
1061
1209
  async function getInstances() {
1062
- const allocations = loadPortAllocations();
1210
+ const knownPaths = getKnownProjectPaths();
1063
1211
  const instances = [];
1064
- for (const allocation of allocations) {
1212
+ for (const projectPath of knownPaths) {
1065
1213
  // Skip builder worktrees - they're managed by their parent project
1066
- if (allocation.project_path.includes('/.builders/')) {
1214
+ if (projectPath.includes('/.builders/')) {
1067
1215
  continue;
1068
1216
  }
1069
1217
  // Skip projects in temp directories (e.g. test artifacts) or whose directories no longer exist
1070
- if (!allocation.project_path.startsWith('remote:')) {
1071
- if (!fs.existsSync(allocation.project_path)) {
1218
+ if (!projectPath.startsWith('remote:')) {
1219
+ if (!fs.existsSync(projectPath)) {
1072
1220
  continue;
1073
1221
  }
1074
- if (isTempDirectory(allocation.project_path)) {
1222
+ if (isTempDirectory(projectPath)) {
1075
1223
  continue;
1076
1224
  }
1077
1225
  }
1078
- const basePort = allocation.base_port;
1079
- const dashboardPort = basePort;
1080
1226
  // Encode project path for proxy URL
1081
- const encodedPath = Buffer.from(allocation.project_path).toString('base64url');
1227
+ const encodedPath = Buffer.from(projectPath).toString('base64url');
1082
1228
  const proxyUrl = `/project/${encodedPath}/`;
1083
1229
  // Get terminals and gate status from tower's registry
1084
1230
  // Phase 4 (Spec 0090): Tower manages terminals directly - no separate dashboard server
1085
- const { terminals, gateStatus } = await getTerminalsForProject(allocation.project_path, proxyUrl);
1231
+ const { terminals, gateStatus } = await getTerminalsForProject(projectPath, proxyUrl);
1086
1232
  // Project is active if it has any terminals (Phase 4: no port check needed)
1087
1233
  const isActive = terminals.length > 0;
1088
- const ports = [
1089
- {
1090
- type: 'Dashboard',
1091
- port: dashboardPort,
1092
- url: proxyUrl, // Use tower proxy URL, not raw localhost
1093
- active: isActive,
1094
- },
1095
- ];
1096
1234
  instances.push({
1097
- projectPath: allocation.project_path,
1098
- projectName: getProjectName(allocation.project_path),
1099
- basePort,
1100
- dashboardPort,
1101
- architectPort: basePort + 1, // Legacy field for backward compat
1102
- registered: allocation.registered_at,
1103
- lastUsed: allocation.last_used_at,
1235
+ projectPath,
1236
+ projectName: getProjectName(projectPath),
1104
1237
  running: isActive,
1105
- proxyUrl, // Tower proxy URL for dashboard
1106
- architectUrl: `${proxyUrl}?tab=architect`, // Direct URL to architect terminal
1107
- terminals, // All available terminals
1108
- ports,
1238
+ proxyUrl,
1239
+ architectUrl: `${proxyUrl}?tab=architect`,
1240
+ terminals,
1109
1241
  gateStatus,
1110
1242
  });
1111
1243
  }
1112
- // Sort: running first, then by last used (most recent first)
1244
+ // Sort: running first, then by project name
1113
1245
  instances.sort((a, b) => {
1114
1246
  if (a.running !== b.running) {
1115
1247
  return a.running ? -1 : 1;
1116
1248
  }
1117
- const aTime = a.lastUsed ? new Date(a.lastUsed).getTime() : 0;
1118
- const bTime = b.lastUsed ? new Date(b.lastUsed).getTime() : 0;
1119
- return bTime - aTime;
1249
+ return a.projectName.localeCompare(b.projectName);
1120
1250
  });
1121
1251
  return instances;
1122
1252
  }
@@ -1189,8 +1319,6 @@ async function getDirectorySuggestions(inputPath) {
1189
1319
  * Auto-adopts non-codev directories and creates architect terminal
1190
1320
  */
1191
1321
  async function launchInstance(projectPath) {
1192
- // Clean up stale port allocations before launching (handles machine restarts)
1193
- cleanupStaleEntries();
1194
1322
  // Validate path exists
1195
1323
  if (!fs.existsSync(projectPath)) {
1196
1324
  return { success: false, error: `Path does not exist: ${projectPath}` };
@@ -1221,38 +1349,8 @@ async function launchInstance(projectPath) {
1221
1349
  // Phase 4 (Spec 0090): Tower manages terminals directly
1222
1350
  // No dashboard-server spawning - tower handles everything
1223
1351
  try {
1224
- // Clear any stale state file
1225
- const stateFile = path.join(projectPath, '.agent-farm', 'state.json');
1226
- if (fs.existsSync(stateFile)) {
1227
- try {
1228
- fs.unlinkSync(stateFile);
1229
- }
1230
- catch {
1231
- // Ignore - file might not exist or be locked
1232
- }
1233
- }
1234
1352
  // Ensure project has port allocation
1235
1353
  const resolvedPath = fs.realpathSync(projectPath);
1236
- const db = getGlobalDb();
1237
- let allocation = db
1238
- .prepare('SELECT base_port FROM port_allocations WHERE project_path = ? OR project_path = ?')
1239
- .get(projectPath, resolvedPath);
1240
- if (!allocation) {
1241
- // Allocate a new port for this project
1242
- // Find the next available port block (starting at 4200, incrementing by 100)
1243
- const existingPorts = db
1244
- .prepare('SELECT base_port FROM port_allocations ORDER BY base_port')
1245
- .all();
1246
- let nextPort = 4200;
1247
- for (const { base_port } of existingPorts) {
1248
- if (base_port >= nextPort) {
1249
- nextPort = base_port + 100;
1250
- }
1251
- }
1252
- db.prepare("INSERT INTO port_allocations (project_path, project_name, base_port, created_at) VALUES (?, ?, ?, datetime('now'))").run(resolvedPath, path.basename(projectPath), nextPort);
1253
- allocation = { base_port: nextPort };
1254
- log('INFO', `Allocated port ${nextPort} for project: ${projectPath}`);
1255
- }
1256
1354
  // Initialize project terminal entry
1257
1355
  const entry = getProjectTerminalsEntry(resolvedPath);
1258
1356
  // Create architect terminal if not already present
@@ -1279,14 +1377,26 @@ async function launchInstance(projectPath) {
1279
1377
  let cmdArgs = cmdParts.slice(1);
1280
1378
  // Wrap in tmux for session persistence across Tower restarts
1281
1379
  const tmuxName = `architect-${path.basename(projectPath)}`;
1380
+ const sanitizedTmuxName = sanitizeTmuxSessionName(tmuxName);
1282
1381
  let activeTmuxSession = null;
1283
1382
  if (tmuxAvailable) {
1284
- const sanitizedName = createTmuxSession(tmuxName, cmd, cmdArgs, projectPath, 200, 50);
1285
- if (sanitizedName) {
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)) {
1286
1387
  cmd = 'tmux';
1287
- cmdArgs = ['attach-session', '-t', sanitizedName];
1288
- activeTmuxSession = sanitizedName;
1289
- log('INFO', `Created tmux session "${sanitizedName}" 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
+ }
1290
1400
  }
1291
1401
  }
1292
1402
  const session = await manager.createSession({
@@ -1299,19 +1409,30 @@ async function launchInstance(projectPath) {
1299
1409
  entry.architect = session.id;
1300
1410
  // TICK-001: Save to SQLite for persistence (with tmux session name)
1301
1411
  saveTerminalSession(session.id, resolvedPath, 'architect', null, session.pid, activeTmuxSession);
1302
- // Auto-restart architect on exit (restored from pre-Phase 4 dashboard-server.ts)
1412
+ // Auto-restart architect on exit
1303
1413
  const ptySession = manager.getSession(session.id);
1304
1414
  if (ptySession) {
1305
1415
  const startedAt = Date.now();
1306
1416
  ptySession.on('exit', () => {
1307
- 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
+ }
1308
1424
  deleteTerminalSession(session.id);
1309
- // Kill stale tmux session so restart can create a fresh one
1310
- if (activeTmuxSession) {
1311
- try {
1312
- execSync(`tmux kill-session -t "${activeTmuxSession}" 2>/dev/null`, { stdio: 'ignore' });
1313
- }
1314
- 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`);
1315
1436
  }
1316
1437
  // Only restart if the architect ran for at least 5s (prevents crash loops)
1317
1438
  const uptime = Date.now() - startedAt;
@@ -1319,6 +1440,12 @@ async function launchInstance(projectPath) {
1319
1440
  log('INFO', `Architect exited after ${uptime}ms for ${projectPath}, not restarting (too short)`);
1320
1441
  return;
1321
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
+ }
1322
1449
  log('INFO', `Architect exited for ${projectPath}, restarting in 2s...`);
1323
1450
  setTimeout(() => {
1324
1451
  launchInstance(projectPath).catch((err) => {
@@ -1427,7 +1554,6 @@ const templatePath = findTemplatePath();
1427
1554
  // WebSocket server for terminal connections (Phase 2 - Spec 0090)
1428
1555
  let terminalWss = null;
1429
1556
  // React dashboard dist path (for serving directly from tower)
1430
- // React dashboard dist path (for serving directly from tower)
1431
1557
  // Phase 4 (Spec 0090): Tower serves everything directly, no dashboard-server
1432
1558
  const reactDashboardPath = path.resolve(__dirname, '../../../dashboard/dist');
1433
1559
  const hasReactDashboard = fs.existsSync(reactDashboardPath);
@@ -1513,17 +1639,77 @@ const server = http.createServer(async (req, res) => {
1513
1639
  }));
1514
1640
  return;
1515
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
+ }
1516
1704
  // API: List all projects (Spec 0090 Phase 1)
1517
1705
  if (req.method === 'GET' && url.pathname === '/api/projects') {
1518
1706
  const instances = await getInstances();
1519
1707
  const projects = instances.map((i) => ({
1520
1708
  path: i.projectPath,
1521
1709
  name: i.projectName,
1522
- basePort: i.basePort,
1523
1710
  active: i.running,
1524
1711
  proxyUrl: i.proxyUrl,
1525
1712
  terminals: i.terminals.length,
1526
- lastUsed: i.lastUsed,
1527
1713
  }));
1528
1714
  res.writeHead(200, { 'Content-Type': 'application/json' });
1529
1715
  res.end(JSON.stringify({ projects }));
@@ -1562,7 +1748,6 @@ const server = http.createServer(async (req, res) => {
1562
1748
  path: instance.projectPath,
1563
1749
  name: instance.projectName,
1564
1750
  active: instance.running,
1565
- basePort: instance.basePort,
1566
1751
  terminals: instance.terminals,
1567
1752
  gateStatus: instance.gateStatus,
1568
1753
  }));
@@ -1590,11 +1775,11 @@ const server = http.createServer(async (req, res) => {
1590
1775
  }
1591
1776
  // POST /api/projects/:path/deactivate
1592
1777
  if (req.method === 'POST' && action === 'deactivate') {
1593
- // Check if project exists in port allocations
1594
- const allocations = loadPortAllocations();
1778
+ // Check if project is known (has terminals or sessions)
1779
+ const knownPaths = getKnownProjectPaths();
1595
1780
  const resolvedPath = fs.existsSync(projectPath) ? fs.realpathSync(projectPath) : projectPath;
1596
- const allocation = allocations.find((a) => a.project_path === projectPath || a.project_path === resolvedPath);
1597
- if (!allocation) {
1781
+ const isKnown = knownPaths.some((p) => p === projectPath || p === resolvedPath);
1782
+ if (!isKnown) {
1598
1783
  res.writeHead(404, { 'Content-Type': 'application/json' });
1599
1784
  res.end(JSON.stringify({ ok: false, error: 'Project not found' }));
1600
1785
  return;
@@ -1918,41 +2103,13 @@ const server = http.createServer(async (req, res) => {
1918
2103
  res.end(JSON.stringify(result));
1919
2104
  return;
1920
2105
  }
1921
- // API: Get tunnel status (cloudflared availability and running tunnel)
1922
- if (req.method === 'GET' && url.pathname === '/api/tunnel/status') {
1923
- const status = getTunnelStatus();
1924
- res.writeHead(200, { 'Content-Type': 'application/json' });
1925
- res.end(JSON.stringify(status));
1926
- return;
1927
- }
1928
- // API: Start cloudflared tunnel
1929
- if (req.method === 'POST' && url.pathname === '/api/tunnel/start') {
1930
- const result = await startTunnel(port);
1931
- res.writeHead(200, { 'Content-Type': 'application/json' });
1932
- res.end(JSON.stringify(result));
1933
- return;
1934
- }
1935
- // API: Stop cloudflared tunnel
1936
- if (req.method === 'POST' && url.pathname === '/api/tunnel/stop') {
1937
- const result = stopTunnel();
1938
- res.writeHead(200, { 'Content-Type': 'application/json' });
1939
- res.end(JSON.stringify(result));
1940
- return;
1941
- }
1942
2106
  // API: Stop an instance
1943
- // Phase 4 (Spec 0090): Accept projectPath or basePort for backwards compat
1944
2107
  if (req.method === 'POST' && url.pathname === '/api/stop') {
1945
2108
  const body = await parseJsonBody(req);
1946
- let targetPath = body.projectPath;
1947
- // Backwards compat: if basePort provided, find the project path
1948
- if (!targetPath && body.basePort) {
1949
- const allocations = loadPortAllocations();
1950
- const allocation = allocations.find((a) => a.base_port === body.basePort);
1951
- targetPath = allocation?.project_path || '';
1952
- }
2109
+ const targetPath = body.projectPath;
1953
2110
  if (!targetPath) {
1954
2111
  res.writeHead(400, { 'Content-Type': 'application/json' });
1955
- res.end(JSON.stringify({ success: false, error: 'Missing projectPath or basePort' }));
2112
+ res.end(JSON.stringify({ success: false, error: 'Missing projectPath' }));
1956
2113
  return;
1957
2114
  }
1958
2115
  const result = await stopInstance(targetPath);
@@ -1987,8 +2144,8 @@ const server = http.createServer(async (req, res) => {
1987
2144
  const encodedPath = pathParts[2];
1988
2145
  const subPath = pathParts.slice(3).join('/');
1989
2146
  if (!encodedPath) {
1990
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1991
- res.end('Missing project path');
2147
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2148
+ res.end(JSON.stringify({ error: 'Missing project path' }));
1992
2149
  return;
1993
2150
  }
1994
2151
  // Decode Base64URL (RFC 4648)
@@ -2003,11 +2160,10 @@ const server = http.createServer(async (req, res) => {
2003
2160
  projectPath = normalizeProjectPath(projectPath);
2004
2161
  }
2005
2162
  catch {
2006
- res.writeHead(400, { 'Content-Type': 'text/plain' });
2007
- res.end('Invalid project path encoding');
2163
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2164
+ res.end(JSON.stringify({ error: 'Invalid project path encoding' }));
2008
2165
  return;
2009
2166
  }
2010
- const basePort = await getBasePortForProject(projectPath);
2011
2167
  // Phase 4 (Spec 0090): Tower handles everything directly
2012
2168
  const isApiCall = subPath.startsWith('api/') || subPath === 'api';
2013
2169
  const isWsPath = subPath.startsWith('ws/') || subPath === 'ws';
@@ -2066,24 +2222,24 @@ const server = http.createServer(async (req, res) => {
2066
2222
  // tmux reconnection, and tmux discovery in one place)
2067
2223
  const encodedPath = Buffer.from(projectPath).toString('base64url');
2068
2224
  const proxyUrl = `/project/${encodedPath}/`;
2069
- await getTerminalsForProject(projectPath, proxyUrl);
2225
+ const { gateStatus } = await getTerminalsForProject(projectPath, proxyUrl);
2070
2226
  // Now read from the refreshed cache
2071
2227
  const entry = getProjectTerminalsEntry(projectPath);
2072
2228
  const manager = getTerminalManager();
2073
- // Build state response compatible with React dashboard
2074
2229
  const state = {
2075
2230
  architect: null,
2076
2231
  builders: [],
2077
2232
  utils: [],
2078
2233
  annotations: [],
2079
2234
  projectName: path.basename(projectPath),
2235
+ gateStatus,
2080
2236
  };
2081
2237
  // Add architect if exists
2082
2238
  if (entry.architect) {
2083
2239
  const session = manager.getSession(entry.architect);
2084
2240
  if (session) {
2085
2241
  state.architect = {
2086
- port: basePort || 0,
2242
+ port: 0,
2087
2243
  pid: session.pid || 0,
2088
2244
  terminalId: entry.architect,
2089
2245
  };
@@ -2096,7 +2252,7 @@ const server = http.createServer(async (req, res) => {
2096
2252
  state.utils.push({
2097
2253
  id: shellId,
2098
2254
  name: `Shell ${shellId.replace('shell-', '')}`,
2099
- port: basePort || 0,
2255
+ port: 0,
2100
2256
  pid: session.pid || 0,
2101
2257
  terminalId,
2102
2258
  });
@@ -2109,7 +2265,7 @@ const server = http.createServer(async (req, res) => {
2109
2265
  state.builders.push({
2110
2266
  id: builderId,
2111
2267
  name: `Builder ${builderId}`,
2112
- port: basePort || 0,
2268
+ port: 0,
2113
2269
  pid: session.pid || 0,
2114
2270
  status: 'running',
2115
2271
  phase: '',
@@ -2167,7 +2323,7 @@ const server = http.createServer(async (req, res) => {
2167
2323
  res.writeHead(200, { 'Content-Type': 'application/json' });
2168
2324
  res.end(JSON.stringify({
2169
2325
  id: shellId,
2170
- port: basePort || 0,
2326
+ port: 0,
2171
2327
  name: `Shell ${shellId.replace('shell-', '')}`,
2172
2328
  terminalId: session.id,
2173
2329
  }));
@@ -2187,45 +2343,79 @@ const server = http.createServer(async (req, res) => {
2187
2343
  req.on('data', (chunk) => data += chunk.toString());
2188
2344
  req.on('end', () => resolve(data));
2189
2345
  });
2190
- const { path: filePath, line } = JSON.parse(body || '{}');
2346
+ const { path: filePath, line, terminalId } = JSON.parse(body || '{}');
2191
2347
  if (!filePath || typeof filePath !== 'string') {
2192
2348
  res.writeHead(400, { 'Content-Type': 'application/json' });
2193
2349
  res.end(JSON.stringify({ error: 'Missing path parameter' }));
2194
2350
  return;
2195
2351
  }
2196
- // Resolve path relative to project
2197
- const fullPath = path.isAbsolute(filePath)
2198
- ? filePath
2199
- : path.join(projectPath, filePath);
2200
- // Security: ensure path is within project or is absolute path user provided
2201
- const normalizedFull = path.normalize(fullPath);
2202
- const normalizedProject = path.normalize(projectPath);
2203
- 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) {
2204
2396
  res.writeHead(403, { 'Content-Type': 'application/json' });
2205
2397
  res.end(JSON.stringify({ error: 'Path outside project' }));
2206
2398
  return;
2207
2399
  }
2208
- // Check file exists
2209
- if (!fs.existsSync(fullPath)) {
2210
- res.writeHead(404, { 'Content-Type': 'application/json' });
2211
- res.end(JSON.stringify({ error: 'File not found' }));
2212
- return;
2213
- }
2400
+ // Non-existent files still create a tab (spec 0101: file viewer shows "File not found")
2401
+ const fileExists = fs.existsSync(fullPath);
2214
2402
  const entry = getProjectTerminalsEntry(projectPath);
2215
2403
  // Check if already open
2216
2404
  for (const [id, tab] of entry.fileTabs) {
2217
2405
  if (tab.path === fullPath) {
2218
2406
  res.writeHead(200, { 'Content-Type': 'application/json' });
2219
- res.end(JSON.stringify({ id, existing: true, line }));
2407
+ res.end(JSON.stringify({ id, existing: true, line, notFound: !fileExists }));
2220
2408
  return;
2221
2409
  }
2222
2410
  }
2223
- // Create new file tab
2224
- const id = `file-${Date.now().toString(36)}`;
2225
- 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);
2226
2416
  log('INFO', `Created file tab: ${id} for ${path.basename(fullPath)}`);
2227
2417
  res.writeHead(200, { 'Content-Type': 'application/json' });
2228
- res.end(JSON.stringify({ id, existing: false, line }));
2418
+ res.end(JSON.stringify({ id, existing: false, line, notFound: !fileExists }));
2229
2419
  }
2230
2420
  catch (err) {
2231
2421
  log('ERROR', `Failed to create file tab: ${err.message}`);
@@ -2280,6 +2470,7 @@ const server = http.createServer(async (req, res) => {
2280
2470
  }
2281
2471
  }
2282
2472
  catch (err) {
2473
+ log('ERROR', `GET /api/file/:id failed: ${err.message}`);
2283
2474
  res.writeHead(500, { 'Content-Type': 'application/json' });
2284
2475
  res.end(JSON.stringify({ error: err.message }));
2285
2476
  }
@@ -2292,8 +2483,8 @@ const server = http.createServer(async (req, res) => {
2292
2483
  const entry = getProjectTerminalsEntry(projectPath);
2293
2484
  const tab = entry.fileTabs.get(tabId);
2294
2485
  if (!tab) {
2295
- res.writeHead(404, { 'Content-Type': 'text/plain' });
2296
- res.end('File tab not found');
2486
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2487
+ res.end(JSON.stringify({ error: 'File tab not found' }));
2297
2488
  return;
2298
2489
  }
2299
2490
  try {
@@ -2307,8 +2498,9 @@ const server = http.createServer(async (req, res) => {
2307
2498
  res.end(data);
2308
2499
  }
2309
2500
  catch (err) {
2310
- res.writeHead(500, { 'Content-Type': 'text/plain' });
2311
- 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 }));
2312
2504
  }
2313
2505
  return;
2314
2506
  }
@@ -2341,6 +2533,7 @@ const server = http.createServer(async (req, res) => {
2341
2533
  res.end(JSON.stringify({ success: true }));
2342
2534
  }
2343
2535
  catch (err) {
2536
+ log('ERROR', `POST /api/file/:id/save failed: ${err.message}`);
2344
2537
  res.writeHead(500, { 'Content-Type': 'application/json' });
2345
2538
  res.end(JSON.stringify({ error: err.message }));
2346
2539
  }
@@ -2352,10 +2545,11 @@ const server = http.createServer(async (req, res) => {
2352
2545
  const tabId = deleteMatch[1];
2353
2546
  const entry = getProjectTerminalsEntry(projectPath);
2354
2547
  const manager = getTerminalManager();
2355
- // 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)
2356
2549
  if (tabId.startsWith('file-')) {
2357
2550
  if (entry.fileTabs.has(tabId)) {
2358
2551
  entry.fileTabs.delete(tabId);
2552
+ deleteFileTab(tabId);
2359
2553
  log('INFO', `Deleted file tab: ${tabId}`);
2360
2554
  res.writeHead(204);
2361
2555
  res.end();
@@ -2495,7 +2689,8 @@ const server = http.createServer(async (req, res) => {
2495
2689
  res.end(JSON.stringify({ modified, staged, untracked }));
2496
2690
  }
2497
2691
  catch (err) {
2498
- // 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}`);
2499
2694
  res.writeHead(200, { 'Content-Type': 'application/json' });
2500
2695
  res.end(JSON.stringify({ modified: [], staged: [], untracked: [], error: err.message }));
2501
2696
  }
@@ -2526,8 +2721,8 @@ const server = http.createServer(async (req, res) => {
2526
2721
  const entry = getProjectTerminalsEntry(projectPath);
2527
2722
  const tab = entry.fileTabs.get(tabId);
2528
2723
  if (!tab) {
2529
- res.writeHead(404, { 'Content-Type': 'text/plain' });
2530
- res.end('File tab not found');
2724
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2725
+ res.end(JSON.stringify({ error: 'File tab not found' }));
2531
2726
  return;
2532
2727
  }
2533
2728
  const filePath = tab.path;
@@ -2545,8 +2740,9 @@ const server = http.createServer(async (req, res) => {
2545
2740
  res.end(content);
2546
2741
  }
2547
2742
  catch (err) {
2548
- res.writeHead(500, { 'Content-Type': 'text/plain' });
2549
- 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 }));
2550
2746
  }
2551
2747
  return;
2552
2748
  }
@@ -2570,8 +2766,9 @@ const server = http.createServer(async (req, res) => {
2570
2766
  res.end(JSON.stringify({ ok: true }));
2571
2767
  }
2572
2768
  catch (err) {
2573
- res.writeHead(500, { 'Content-Type': 'text/plain' });
2574
- 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 }));
2575
2772
  }
2576
2773
  return;
2577
2774
  }
@@ -2583,8 +2780,9 @@ const server = http.createServer(async (req, res) => {
2583
2780
  res.end(JSON.stringify({ mtime: stat.mtimeMs }));
2584
2781
  }
2585
2782
  catch (err) {
2586
- res.writeHead(500, { 'Content-Type': 'text/plain' });
2587
- 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 }));
2588
2786
  }
2589
2787
  return;
2590
2788
  }
@@ -2601,8 +2799,9 @@ const server = http.createServer(async (req, res) => {
2601
2799
  res.end(data);
2602
2800
  }
2603
2801
  catch (err) {
2604
- res.writeHead(500, { 'Content-Type': 'text/plain' });
2605
- 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 }));
2606
2805
  }
2607
2806
  return;
2608
2807
  }
@@ -2690,8 +2889,8 @@ const server = http.createServer(async (req, res) => {
2690
2889
  }
2691
2890
  catch (err) {
2692
2891
  log('ERROR', `Request error: ${err.message}`);
2693
- res.writeHead(500, { 'Content-Type': 'text/plain' });
2694
- res.end('Internal server error: ' + err.message);
2892
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2893
+ res.end(JSON.stringify({ error: err.message }));
2695
2894
  }
2696
2895
  });
2697
2896
  // SECURITY: Bind to localhost only to prevent network exposure
@@ -2702,6 +2901,25 @@ server.listen(port, '127.0.0.1', async () => {
2702
2901
  log('INFO', `tmux available: ${tmuxAvailable}${tmuxAvailable ? '' : ' (terminals will not persist across restarts)'}`);
2703
2902
  // TICK-001: Reconcile terminal sessions from previous run
2704
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();
2705
2923
  });
2706
2924
  // Initialize terminal WebSocket server (Phase 2 - Spec 0090)
2707
2925
  terminalWss = new WebSocketServer({ noServer: true });