@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.
- package/dashboard/dist/assets/index-C7FtNK6Y.css +32 -0
- package/dashboard/dist/assets/index-D6VqWAaI.js +131 -0
- package/dashboard/dist/assets/index-D6VqWAaI.js.map +1 -0
- package/dashboard/dist/index.html +2 -2
- package/dist/agent-farm/cli.d.ts.map +1 -1
- package/dist/agent-farm/cli.js +75 -50
- package/dist/agent-farm/cli.js.map +1 -1
- package/dist/agent-farm/commands/architect.d.ts.map +1 -1
- package/dist/agent-farm/commands/architect.js +38 -48
- package/dist/agent-farm/commands/architect.js.map +1 -1
- package/dist/agent-farm/commands/attach.d.ts.map +1 -1
- package/dist/agent-farm/commands/attach.js +14 -35
- package/dist/agent-farm/commands/attach.js.map +1 -1
- package/dist/agent-farm/commands/cleanup.d.ts.map +1 -1
- package/dist/agent-farm/commands/cleanup.js +17 -18
- package/dist/agent-farm/commands/cleanup.js.map +1 -1
- package/dist/agent-farm/commands/consult.d.ts +3 -4
- package/dist/agent-farm/commands/consult.d.ts.map +1 -1
- package/dist/agent-farm/commands/consult.js +27 -37
- package/dist/agent-farm/commands/consult.js.map +1 -1
- package/dist/agent-farm/commands/open.d.ts.map +1 -1
- package/dist/agent-farm/commands/open.js +19 -36
- package/dist/agent-farm/commands/open.js.map +1 -1
- package/dist/agent-farm/commands/shell.d.ts +3 -3
- package/dist/agent-farm/commands/shell.d.ts.map +1 -1
- package/dist/agent-farm/commands/shell.js +33 -78
- package/dist/agent-farm/commands/shell.js.map +1 -1
- package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
- package/dist/agent-farm/commands/spawn.js +113 -90
- package/dist/agent-farm/commands/spawn.js.map +1 -1
- package/dist/agent-farm/commands/start.d.ts +7 -20
- package/dist/agent-farm/commands/start.d.ts.map +1 -1
- package/dist/agent-farm/commands/start.js +7 -243
- package/dist/agent-farm/commands/start.js.map +1 -1
- package/dist/agent-farm/commands/status.d.ts.map +1 -1
- package/dist/agent-farm/commands/status.js +22 -29
- package/dist/agent-farm/commands/status.js.map +1 -1
- package/dist/agent-farm/commands/stop.d.ts.map +1 -1
- package/dist/agent-farm/commands/stop.js +43 -172
- package/dist/agent-farm/commands/stop.js.map +1 -1
- package/dist/agent-farm/commands/tower-cloud.d.ts +47 -0
- package/dist/agent-farm/commands/tower-cloud.d.ts.map +1 -0
- package/dist/agent-farm/commands/tower-cloud.js +316 -0
- package/dist/agent-farm/commands/tower-cloud.js.map +1 -0
- package/dist/agent-farm/db/index.d.ts +6 -2
- package/dist/agent-farm/db/index.d.ts.map +1 -1
- package/dist/agent-farm/db/index.js +56 -31
- package/dist/agent-farm/db/index.js.map +1 -1
- package/dist/agent-farm/db/migrate.d.ts +0 -4
- package/dist/agent-farm/db/migrate.d.ts.map +1 -1
- package/dist/agent-farm/db/migrate.js +0 -46
- package/dist/agent-farm/db/migrate.js.map +1 -1
- package/dist/agent-farm/db/schema.d.ts +3 -3
- package/dist/agent-farm/db/schema.d.ts.map +1 -1
- package/dist/agent-farm/db/schema.js +3 -17
- package/dist/agent-farm/db/schema.js.map +1 -1
- package/dist/agent-farm/db/types.d.ts +0 -10
- package/dist/agent-farm/db/types.d.ts.map +1 -1
- package/dist/agent-farm/db/types.js +0 -8
- package/dist/agent-farm/db/types.js.map +1 -1
- package/dist/agent-farm/hq-connector.d.ts +1 -1
- package/dist/agent-farm/hq-connector.js +1 -1
- package/dist/agent-farm/lib/cloud-config.d.ts +46 -0
- package/dist/agent-farm/lib/cloud-config.d.ts.map +1 -0
- package/dist/agent-farm/lib/cloud-config.js +106 -0
- package/dist/agent-farm/lib/cloud-config.js.map +1 -0
- package/dist/agent-farm/lib/tower-client.d.ts +7 -5
- package/dist/agent-farm/lib/tower-client.d.ts.map +1 -1
- package/dist/agent-farm/lib/tower-client.js.map +1 -1
- package/dist/agent-farm/lib/tunnel-client.d.ts +117 -0
- package/dist/agent-farm/lib/tunnel-client.d.ts.map +1 -0
- package/dist/agent-farm/lib/tunnel-client.js +502 -0
- package/dist/agent-farm/lib/tunnel-client.js.map +1 -0
- package/dist/agent-farm/servers/tower-server.js +865 -441
- package/dist/agent-farm/servers/tower-server.js.map +1 -1
- package/dist/agent-farm/state.d.ts +2 -2
- package/dist/agent-farm/state.d.ts.map +1 -1
- package/dist/agent-farm/state.js +6 -16
- package/dist/agent-farm/state.js.map +1 -1
- package/dist/agent-farm/types.d.ts +1 -18
- package/dist/agent-farm/types.d.ts.map +1 -1
- package/dist/agent-farm/utils/config.d.ts +0 -5
- package/dist/agent-farm/utils/config.d.ts.map +1 -1
- package/dist/agent-farm/utils/config.js +0 -31
- package/dist/agent-farm/utils/config.js.map +1 -1
- package/dist/agent-farm/utils/file-tabs.d.ts +27 -0
- package/dist/agent-farm/utils/file-tabs.d.ts.map +1 -0
- package/dist/agent-farm/utils/file-tabs.js +46 -0
- package/dist/agent-farm/utils/file-tabs.js.map +1 -0
- package/dist/agent-farm/utils/gate-status.d.ts +16 -0
- package/dist/agent-farm/utils/gate-status.d.ts.map +1 -0
- package/dist/agent-farm/utils/gate-status.js +79 -0
- package/dist/agent-farm/utils/gate-status.js.map +1 -0
- package/dist/agent-farm/utils/gate-watcher.d.ts +38 -0
- package/dist/agent-farm/utils/gate-watcher.d.ts.map +1 -0
- package/dist/agent-farm/utils/gate-watcher.js +122 -0
- package/dist/agent-farm/utils/gate-watcher.js.map +1 -0
- package/dist/agent-farm/utils/index.d.ts +0 -1
- package/dist/agent-farm/utils/index.d.ts.map +1 -1
- package/dist/agent-farm/utils/index.js +0 -1
- package/dist/agent-farm/utils/index.js.map +1 -1
- package/dist/agent-farm/utils/notifications.js +1 -1
- package/dist/agent-farm/utils/notifications.js.map +1 -1
- package/dist/agent-farm/utils/server-utils.d.ts +1 -1
- package/dist/agent-farm/utils/server-utils.js +1 -1
- package/dist/agent-farm/utils/session.d.ts +10 -0
- package/dist/agent-farm/utils/session.d.ts.map +1 -0
- package/dist/agent-farm/utils/session.js +12 -0
- package/dist/agent-farm/utils/session.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/adopt.js +1 -1
- package/dist/commands/adopt.js.map +1 -1
- package/dist/commands/consult/index.d.ts +1 -0
- package/dist/commands/consult/index.d.ts.map +1 -1
- package/dist/commands/consult/index.js +42 -15
- package/dist/commands/consult/index.js.map +1 -1
- package/dist/commands/init.js +1 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/porch/index.d.ts.map +1 -1
- package/dist/commands/porch/index.js +35 -12
- package/dist/commands/porch/index.js.map +1 -1
- package/dist/commands/porch/next.js +15 -5
- package/dist/commands/porch/next.js.map +1 -1
- package/dist/commands/porch/verdict.d.ts +8 -0
- package/dist/commands/porch/verdict.d.ts.map +1 -1
- package/dist/commands/porch/verdict.js +13 -0
- package/dist/commands/porch/verdict.js.map +1 -1
- package/dist/terminal/pty-session.d.ts +2 -0
- package/dist/terminal/pty-session.d.ts.map +1 -1
- package/dist/terminal/pty-session.js +4 -0
- package/dist/terminal/pty-session.js.map +1 -1
- package/package.json +1 -1
- package/skeleton/.claude/skills/af/SKILL.md +15 -0
- package/skeleton/consult-types/impl-review.md +9 -0
- package/skeleton/protocols/spir/prompts/review.md +15 -16
- package/skeleton/protocols/spir/protocol.json +4 -0
- package/skeleton/protocols/spir/templates/review.md +81 -199
- package/skeleton/resources/commands/agent-farm.md +38 -2
- package/templates/tower.html +7 -150
- package/dashboard/dist/assets/index-CXloFYpB.css +0 -32
- package/dashboard/dist/assets/index-Ca2fjOJf.js +0 -131
- package/dashboard/dist/assets/index-Ca2fjOJf.js.map +0 -1
- package/dist/agent-farm/utils/orphan-handler.d.ts +0 -27
- package/dist/agent-farm/utils/orphan-handler.d.ts.map +0 -1
- package/dist/agent-farm/utils/orphan-handler.js +0 -149
- package/dist/agent-farm/utils/orphan-handler.js.map +0 -1
- package/dist/agent-farm/utils/port-registry.d.ts +0 -57
- package/dist/agent-farm/utils/port-registry.d.ts.map +0 -1
- package/dist/agent-farm/utils/port-registry.js +0 -166
- package/dist/agent-farm/utils/port-registry.js.map +0 -1
- package/dist/agent-farm/utils/terminal-ports.d.ts +0 -18
- package/dist/agent-farm/utils/terminal-ports.d.ts.map +0 -1
- package/dist/agent-farm/utils/terminal-ports.js +0 -35
- 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 {
|
|
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:
|
|
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
|
|
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
|
|
471
|
+
return null;
|
|
244
472
|
}
|
|
245
|
-
// Hide tmux status bar (dashboard has its own tabs)
|
|
246
|
-
//
|
|
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
|
-
|
|
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
|
|
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 "${
|
|
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
|
-
*
|
|
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
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
|
307
|
-
|
|
308
|
-
|
|
581
|
+
catch {
|
|
582
|
+
_tmuxListCache = [];
|
|
583
|
+
_tmuxListCacheTime = now;
|
|
584
|
+
return [];
|
|
309
585
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
351
|
-
log('
|
|
352
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
530
|
-
|
|
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
|
-
*
|
|
938
|
+
* Get all known project paths from terminal_sessions and in-memory cache
|
|
544
939
|
*/
|
|
545
|
-
function
|
|
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
|
-
|
|
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
|
|
551
|
-
|
|
552
|
-
return [];
|
|
950
|
+
catch {
|
|
951
|
+
// Table may not exist yet
|
|
553
952
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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',
|
|
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,
|
|
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 "${
|
|
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
|
-
//
|
|
849
|
-
|
|
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
|
|
1210
|
+
const knownPaths = getKnownProjectPaths();
|
|
874
1211
|
const instances = [];
|
|
875
|
-
for (const
|
|
1212
|
+
for (const projectPath of knownPaths) {
|
|
876
1213
|
// Skip builder worktrees - they're managed by their parent project
|
|
877
|
-
if (
|
|
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 (!
|
|
882
|
-
if (!fs.existsSync(
|
|
1218
|
+
if (!projectPath.startsWith('remote:')) {
|
|
1219
|
+
if (!fs.existsSync(projectPath)) {
|
|
883
1220
|
continue;
|
|
884
1221
|
}
|
|
885
|
-
if (isTempDirectory(
|
|
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(
|
|
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(
|
|
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
|
|
909
|
-
projectName: getProjectName(
|
|
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,
|
|
917
|
-
architectUrl: `${proxyUrl}?tab=architect`,
|
|
918
|
-
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
|
|
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
|
-
|
|
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
|
-
|
|
1092
|
-
|
|
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',
|
|
1095
|
-
activeTmuxSession =
|
|
1096
|
-
log('INFO', `
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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
|
|
1401
|
-
const
|
|
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
|
|
1404
|
-
if (!
|
|
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
|
|
1437
|
-
if (
|
|
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',
|
|
1441
|
-
activeTmuxSession =
|
|
1442
|
-
log('INFO', `Created tmux session "${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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': '
|
|
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': '
|
|
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
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
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
|
|
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 (
|
|
1881
|
-
|
|
1882
|
-
|
|
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
|
-
|
|
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 (
|
|
1899
|
-
|
|
1900
|
-
|
|
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
|
|
1942
|
-
if (
|
|
2303
|
+
const sanitizedName = createTmuxSession(tmuxName, shellCmd, shellArgs, projectPath, 200, 50);
|
|
2304
|
+
if (sanitizedName) {
|
|
1943
2305
|
shellCmd = 'tmux';
|
|
1944
|
-
shellArgs = ['attach-session', '-t',
|
|
1945
|
-
activeTmuxSession =
|
|
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:
|
|
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
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
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
|
-
//
|
|
2003
|
-
|
|
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-${
|
|
2019
|
-
|
|
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': '
|
|
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
|
-
|
|
2105
|
-
res.
|
|
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': '
|
|
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
|
-
|
|
2343
|
-
res.
|
|
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
|
-
|
|
2368
|
-
res.
|
|
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
|
-
|
|
2381
|
-
res.
|
|
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
|
-
|
|
2399
|
-
res.
|
|
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': '
|
|
2488
|
-
res.end(
|
|
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 });
|