@cluesmith/codev 1.1.0
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/bin/af.js +8 -0
- package/bin/codev.js +4 -0
- package/bin/consult.js +7 -0
- package/dist/agent-farm/cli.d.ts +11 -0
- package/dist/agent-farm/cli.d.ts.map +1 -0
- package/dist/agent-farm/cli.js +359 -0
- package/dist/agent-farm/cli.js.map +1 -0
- package/dist/agent-farm/commands/cleanup.d.ts +12 -0
- package/dist/agent-farm/commands/cleanup.d.ts.map +1 -0
- package/dist/agent-farm/commands/cleanup.js +154 -0
- package/dist/agent-farm/commands/cleanup.js.map +1 -0
- package/dist/agent-farm/commands/db.d.ts +38 -0
- package/dist/agent-farm/commands/db.d.ts.map +1 -0
- package/dist/agent-farm/commands/db.js +133 -0
- package/dist/agent-farm/commands/db.js.map +1 -0
- package/dist/agent-farm/commands/index.d.ts +11 -0
- package/dist/agent-farm/commands/index.d.ts.map +1 -0
- package/dist/agent-farm/commands/index.js +11 -0
- package/dist/agent-farm/commands/index.js.map +1 -0
- package/dist/agent-farm/commands/open.d.ts +15 -0
- package/dist/agent-farm/commands/open.d.ts.map +1 -0
- package/dist/agent-farm/commands/open.js +118 -0
- package/dist/agent-farm/commands/open.js.map +1 -0
- package/dist/agent-farm/commands/rename.d.ts +13 -0
- package/dist/agent-farm/commands/rename.d.ts.map +1 -0
- package/dist/agent-farm/commands/rename.js +33 -0
- package/dist/agent-farm/commands/rename.js.map +1 -0
- package/dist/agent-farm/commands/send.d.ts +9 -0
- package/dist/agent-farm/commands/send.d.ts.map +1 -0
- package/dist/agent-farm/commands/send.js +282 -0
- package/dist/agent-farm/commands/send.js.map +1 -0
- package/dist/agent-farm/commands/spawn.d.ts +15 -0
- package/dist/agent-farm/commands/spawn.d.ts.map +1 -0
- package/dist/agent-farm/commands/spawn.js +575 -0
- package/dist/agent-farm/commands/spawn.js.map +1 -0
- package/dist/agent-farm/commands/start.d.ts +9 -0
- package/dist/agent-farm/commands/start.d.ts.map +1 -0
- package/dist/agent-farm/commands/start.js +175 -0
- package/dist/agent-farm/commands/start.js.map +1 -0
- package/dist/agent-farm/commands/status.d.ts +8 -0
- package/dist/agent-farm/commands/status.d.ts.map +1 -0
- package/dist/agent-farm/commands/status.js +123 -0
- package/dist/agent-farm/commands/status.js.map +1 -0
- package/dist/agent-farm/commands/stop.d.ts +8 -0
- package/dist/agent-farm/commands/stop.d.ts.map +1 -0
- package/dist/agent-farm/commands/stop.js +76 -0
- package/dist/agent-farm/commands/stop.js.map +1 -0
- package/dist/agent-farm/commands/tower.d.ts +19 -0
- package/dist/agent-farm/commands/tower.d.ts.map +1 -0
- package/dist/agent-farm/commands/tower.js +125 -0
- package/dist/agent-farm/commands/tower.js.map +1 -0
- package/dist/agent-farm/commands/tutorial.d.ts +10 -0
- package/dist/agent-farm/commands/tutorial.d.ts.map +1 -0
- package/dist/agent-farm/commands/tutorial.js +49 -0
- package/dist/agent-farm/commands/tutorial.js.map +1 -0
- package/dist/agent-farm/commands/util.d.ts +15 -0
- package/dist/agent-farm/commands/util.d.ts.map +1 -0
- package/dist/agent-farm/commands/util.js +108 -0
- package/dist/agent-farm/commands/util.js.map +1 -0
- package/dist/agent-farm/db/errors.d.ts +17 -0
- package/dist/agent-farm/db/errors.d.ts.map +1 -0
- package/dist/agent-farm/db/errors.js +46 -0
- package/dist/agent-farm/db/errors.js.map +1 -0
- package/dist/agent-farm/db/index.d.ts +41 -0
- package/dist/agent-farm/db/index.d.ts.map +1 -0
- package/dist/agent-farm/db/index.js +168 -0
- package/dist/agent-farm/db/index.js.map +1 -0
- package/dist/agent-farm/db/migrate.d.ts +15 -0
- package/dist/agent-farm/db/migrate.d.ts.map +1 -0
- package/dist/agent-farm/db/migrate.js +137 -0
- package/dist/agent-farm/db/migrate.js.map +1 -0
- package/dist/agent-farm/db/schema.d.ts +16 -0
- package/dist/agent-farm/db/schema.d.ts.map +1 -0
- package/dist/agent-farm/db/schema.js +103 -0
- package/dist/agent-farm/db/schema.js.map +1 -0
- package/dist/agent-farm/db/types.d.ts +87 -0
- package/dist/agent-farm/db/types.d.ts.map +1 -0
- package/dist/agent-farm/db/types.js +65 -0
- package/dist/agent-farm/db/types.js.map +1 -0
- package/dist/agent-farm/index.d.ts +7 -0
- package/dist/agent-farm/index.d.ts.map +1 -0
- package/dist/agent-farm/index.js +373 -0
- package/dist/agent-farm/index.js.map +1 -0
- package/dist/agent-farm/servers/annotate-server.d.ts +9 -0
- package/dist/agent-farm/servers/annotate-server.d.ts.map +1 -0
- package/dist/agent-farm/servers/annotate-server.js +136 -0
- package/dist/agent-farm/servers/annotate-server.js.map +1 -0
- package/dist/agent-farm/servers/dashboard-server.d.ts +9 -0
- package/dist/agent-farm/servers/dashboard-server.d.ts.map +1 -0
- package/dist/agent-farm/servers/dashboard-server.js +939 -0
- package/dist/agent-farm/servers/dashboard-server.js.map +1 -0
- package/dist/agent-farm/servers/tower-server.d.ts +9 -0
- package/dist/agent-farm/servers/tower-server.d.ts.map +1 -0
- package/dist/agent-farm/servers/tower-server.js +463 -0
- package/dist/agent-farm/servers/tower-server.js.map +1 -0
- package/dist/agent-farm/state.d.ts +93 -0
- package/dist/agent-farm/state.d.ts.map +1 -0
- package/dist/agent-farm/state.js +253 -0
- package/dist/agent-farm/state.js.map +1 -0
- package/dist/agent-farm/tutorial/index.d.ts +8 -0
- package/dist/agent-farm/tutorial/index.d.ts.map +1 -0
- package/dist/agent-farm/tutorial/index.js +8 -0
- package/dist/agent-farm/tutorial/index.js.map +1 -0
- package/dist/agent-farm/tutorial/prompts.d.ts +57 -0
- package/dist/agent-farm/tutorial/prompts.d.ts.map +1 -0
- package/dist/agent-farm/tutorial/prompts.js +147 -0
- package/dist/agent-farm/tutorial/prompts.js.map +1 -0
- package/dist/agent-farm/tutorial/runner.d.ts +52 -0
- package/dist/agent-farm/tutorial/runner.d.ts.map +1 -0
- package/dist/agent-farm/tutorial/runner.js +204 -0
- package/dist/agent-farm/tutorial/runner.js.map +1 -0
- package/dist/agent-farm/tutorial/state.d.ts +26 -0
- package/dist/agent-farm/tutorial/state.d.ts.map +1 -0
- package/dist/agent-farm/tutorial/state.js +89 -0
- package/dist/agent-farm/tutorial/state.js.map +1 -0
- package/dist/agent-farm/tutorial/steps/first-spec.d.ts +7 -0
- package/dist/agent-farm/tutorial/steps/first-spec.d.ts.map +1 -0
- package/dist/agent-farm/tutorial/steps/first-spec.js +136 -0
- package/dist/agent-farm/tutorial/steps/first-spec.js.map +1 -0
- package/dist/agent-farm/tutorial/steps/implementation.d.ts +7 -0
- package/dist/agent-farm/tutorial/steps/implementation.d.ts.map +1 -0
- package/dist/agent-farm/tutorial/steps/implementation.js +76 -0
- package/dist/agent-farm/tutorial/steps/implementation.js.map +1 -0
- package/dist/agent-farm/tutorial/steps/index.d.ts +10 -0
- package/dist/agent-farm/tutorial/steps/index.d.ts.map +1 -0
- package/dist/agent-farm/tutorial/steps/index.js +10 -0
- package/dist/agent-farm/tutorial/steps/index.js.map +1 -0
- package/dist/agent-farm/tutorial/steps/planning.d.ts +7 -0
- package/dist/agent-farm/tutorial/steps/planning.d.ts.map +1 -0
- package/dist/agent-farm/tutorial/steps/planning.js +143 -0
- package/dist/agent-farm/tutorial/steps/planning.js.map +1 -0
- package/dist/agent-farm/tutorial/steps/review.d.ts +7 -0
- package/dist/agent-farm/tutorial/steps/review.d.ts.map +1 -0
- package/dist/agent-farm/tutorial/steps/review.js +78 -0
- package/dist/agent-farm/tutorial/steps/review.js.map +1 -0
- package/dist/agent-farm/tutorial/steps/setup.d.ts +7 -0
- package/dist/agent-farm/tutorial/steps/setup.d.ts.map +1 -0
- package/dist/agent-farm/tutorial/steps/setup.js +126 -0
- package/dist/agent-farm/tutorial/steps/setup.js.map +1 -0
- package/dist/agent-farm/tutorial/steps/welcome.d.ts +7 -0
- package/dist/agent-farm/tutorial/steps/welcome.d.ts.map +1 -0
- package/dist/agent-farm/tutorial/steps/welcome.js +50 -0
- package/dist/agent-farm/tutorial/steps/welcome.js.map +1 -0
- package/dist/agent-farm/types.d.ts +131 -0
- package/dist/agent-farm/types.d.ts.map +1 -0
- package/dist/agent-farm/types.js +5 -0
- package/dist/agent-farm/types.js.map +1 -0
- package/dist/agent-farm/utils/config.d.ts +27 -0
- package/dist/agent-farm/utils/config.d.ts.map +1 -0
- package/dist/agent-farm/utils/config.js +242 -0
- package/dist/agent-farm/utils/config.js.map +1 -0
- package/dist/agent-farm/utils/deps.d.ts +51 -0
- package/dist/agent-farm/utils/deps.d.ts.map +1 -0
- package/dist/agent-farm/utils/deps.js +194 -0
- package/dist/agent-farm/utils/deps.js.map +1 -0
- package/dist/agent-farm/utils/index.d.ts +6 -0
- package/dist/agent-farm/utils/index.d.ts.map +1 -0
- package/dist/agent-farm/utils/index.js +6 -0
- package/dist/agent-farm/utils/index.js.map +1 -0
- package/dist/agent-farm/utils/logger.d.ts +31 -0
- package/dist/agent-farm/utils/logger.d.ts.map +1 -0
- package/dist/agent-farm/utils/logger.js +58 -0
- package/dist/agent-farm/utils/logger.js.map +1 -0
- package/dist/agent-farm/utils/orphan-handler.d.ts +27 -0
- package/dist/agent-farm/utils/orphan-handler.d.ts.map +1 -0
- package/dist/agent-farm/utils/orphan-handler.js +127 -0
- package/dist/agent-farm/utils/orphan-handler.js.map +1 -0
- package/dist/agent-farm/utils/port-registry.d.ts +58 -0
- package/dist/agent-farm/utils/port-registry.d.ts.map +1 -0
- package/dist/agent-farm/utils/port-registry.js +149 -0
- package/dist/agent-farm/utils/port-registry.js.map +1 -0
- package/dist/agent-farm/utils/shell.d.ts +45 -0
- package/dist/agent-farm/utils/shell.d.ts.map +1 -0
- package/dist/agent-farm/utils/shell.js +120 -0
- package/dist/agent-farm/utils/shell.js.map +1 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +160 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/adopt.d.ts +12 -0
- package/dist/commands/adopt.d.ts.map +1 -0
- package/dist/commands/adopt.js +178 -0
- package/dist/commands/adopt.js.map +1 -0
- package/dist/commands/consult/index.d.ts +17 -0
- package/dist/commands/consult/index.d.ts.map +1 -0
- package/dist/commands/consult/index.js +405 -0
- package/dist/commands/consult/index.js.map +1 -0
- package/dist/commands/doctor.d.ts +10 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +346 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/init.d.ts +12 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +167 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/tower.d.ts +16 -0
- package/dist/commands/tower.d.ts.map +1 -0
- package/dist/commands/tower.js +21 -0
- package/dist/commands/tower.js.map +1 -0
- package/dist/commands/update.d.ts +13 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +137 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/lib/templates.d.ts +57 -0
- package/dist/lib/templates.d.ts.map +1 -0
- package/dist/lib/templates.js +205 -0
- package/dist/lib/templates.js.map +1 -0
- package/package.json +55 -0
- package/templates/AGENTS.md +49 -0
- package/templates/CLAUDE.md +47 -0
- package/templates/DEPENDENCIES.md +344 -0
- package/templates/agents/architecture-documenter.md +189 -0
- package/templates/agents/codev-updater.md +276 -0
- package/templates/agents/spider-protocol-updater.md +118 -0
- package/templates/annotate.html +903 -0
- package/templates/bin/agent-farm +18 -0
- package/templates/bin/annotate-server.js +140 -0
- package/templates/bin/codev-doctor +335 -0
- package/templates/builders.md +30 -0
- package/templates/config.json +7 -0
- package/templates/dashboard-split.html +1679 -0
- package/templates/dashboard.html +149 -0
- package/templates/plans/.gitkeep +0 -0
- package/templates/protocols/experiment/protocol.md +229 -0
- package/templates/protocols/experiment/templates/notes.md +97 -0
- package/templates/protocols/maintain/protocol.md +235 -0
- package/templates/protocols/spider/protocol.md +639 -0
- package/templates/protocols/spider/templates/plan.md +169 -0
- package/templates/protocols/spider/templates/review.md +207 -0
- package/templates/protocols/spider/templates/spec.md +140 -0
- package/templates/protocols/spider-solo/protocol.md +619 -0
- package/templates/protocols/spider-solo/templates/plan.md +169 -0
- package/templates/protocols/spider-solo/templates/review.md +207 -0
- package/templates/protocols/spider-solo/templates/spec.md +140 -0
- package/templates/protocols/tick/protocol.md +250 -0
- package/templates/protocols/tick/templates/plan.md +67 -0
- package/templates/protocols/tick/templates/review.md +90 -0
- package/templates/protocols/tick/templates/spec.md +61 -0
- package/templates/reviews/.gitkeep +0 -0
- package/templates/roles/architect.md +230 -0
- package/templates/roles/builder.md +175 -0
- package/templates/roles/consultant.md +27 -0
- package/templates/specs/.gitkeep +0 -0
- package/templates/templates/projectlist.md +129 -0
- package/templates/tower.html +1032 -0
|
@@ -0,0 +1,939 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Dashboard server for Agent Farm.
|
|
4
|
+
* Serves the split-pane dashboard UI and provides state/tab management APIs.
|
|
5
|
+
*
|
|
6
|
+
* Usage: node dashboard-server.js <port>
|
|
7
|
+
*/
|
|
8
|
+
import http from 'node:http';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import net from 'node:net';
|
|
12
|
+
import { spawn, execSync } from 'node:child_process';
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { loadState, getAnnotations, addAnnotation, removeAnnotation, getUtils, addUtil, removeUtil, getBuilder, getBuilders, removeBuilder, upsertBuilder, clearState, } from '../state.js';
|
|
16
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
17
|
+
const __dirname = path.dirname(__filename);
|
|
18
|
+
// Default dashboard port
|
|
19
|
+
const DEFAULT_DASHBOARD_PORT = 4200;
|
|
20
|
+
// Parse arguments (override default port if provided)
|
|
21
|
+
const port = parseInt(process.argv[2] || String(DEFAULT_DASHBOARD_PORT), 10);
|
|
22
|
+
// Configuration - ports are relative to the dashboard port
|
|
23
|
+
// This ensures multi-project support (e.g., dashboard on 4300 uses 4350 for annotations)
|
|
24
|
+
const CONFIG = {
|
|
25
|
+
dashboardPort: port,
|
|
26
|
+
architectPort: port + 1,
|
|
27
|
+
builderPortStart: port + 10,
|
|
28
|
+
utilPortStart: port + 30,
|
|
29
|
+
annotatePortStart: port + 50,
|
|
30
|
+
maxTabs: 20, // DoS protection: max concurrent tabs
|
|
31
|
+
};
|
|
32
|
+
// Find project root by looking for .agent-farm directory
|
|
33
|
+
function findProjectRoot() {
|
|
34
|
+
let dir = process.cwd();
|
|
35
|
+
while (dir !== '/') {
|
|
36
|
+
if (fs.existsSync(path.join(dir, '.agent-farm'))) {
|
|
37
|
+
return dir;
|
|
38
|
+
}
|
|
39
|
+
if (fs.existsSync(path.join(dir, 'codev'))) {
|
|
40
|
+
return dir;
|
|
41
|
+
}
|
|
42
|
+
dir = path.dirname(dir);
|
|
43
|
+
}
|
|
44
|
+
return process.cwd();
|
|
45
|
+
}
|
|
46
|
+
// Get project name from root path, with truncation for long names
|
|
47
|
+
function getProjectName(projectRoot) {
|
|
48
|
+
const baseName = path.basename(projectRoot);
|
|
49
|
+
const maxLength = 30;
|
|
50
|
+
if (baseName.length <= maxLength) {
|
|
51
|
+
return baseName;
|
|
52
|
+
}
|
|
53
|
+
// Truncate with ellipsis for very long names
|
|
54
|
+
return '...' + baseName.slice(-(maxLength - 3));
|
|
55
|
+
}
|
|
56
|
+
// HTML-escape a string to prevent XSS
|
|
57
|
+
function escapeHtml(str) {
|
|
58
|
+
return str
|
|
59
|
+
.replace(/&/g, '&')
|
|
60
|
+
.replace(/</g, '<')
|
|
61
|
+
.replace(/>/g, '>')
|
|
62
|
+
.replace(/"/g, '"')
|
|
63
|
+
.replace(/'/g, ''');
|
|
64
|
+
}
|
|
65
|
+
function findTemplatePath(filename, required = false) {
|
|
66
|
+
// 1. Try relative to compiled output (dist/servers/ -> templates/)
|
|
67
|
+
const pkgPath = path.resolve(__dirname, '../templates/', filename);
|
|
68
|
+
if (fs.existsSync(pkgPath))
|
|
69
|
+
return pkgPath;
|
|
70
|
+
// 2. Try relative to source (src/servers/ -> templates/)
|
|
71
|
+
const devPath = path.resolve(__dirname, '../../templates/', filename);
|
|
72
|
+
if (fs.existsSync(devPath))
|
|
73
|
+
return devPath;
|
|
74
|
+
if (required) {
|
|
75
|
+
throw new Error(`Template not found: ${filename}`);
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const projectRoot = findProjectRoot();
|
|
80
|
+
const templatePath = findTemplatePath('dashboard-split.html', true);
|
|
81
|
+
const legacyTemplatePath = findTemplatePath('dashboard.html', true);
|
|
82
|
+
// Clean up dead processes from state (called on state load)
|
|
83
|
+
function cleanupDeadProcesses() {
|
|
84
|
+
// Clean up dead shell processes
|
|
85
|
+
for (const util of getUtils()) {
|
|
86
|
+
if (!isProcessRunning(util.pid)) {
|
|
87
|
+
console.log(`Auto-closing shell tab ${util.name} (process ${util.pid} exited)`);
|
|
88
|
+
if (util.tmuxSession) {
|
|
89
|
+
killTmuxSession(util.tmuxSession);
|
|
90
|
+
}
|
|
91
|
+
removeUtil(util.id);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Clean up dead annotation processes
|
|
95
|
+
for (const annotation of getAnnotations()) {
|
|
96
|
+
if (!isProcessRunning(annotation.pid)) {
|
|
97
|
+
console.log(`Auto-closing file tab ${annotation.file} (process ${annotation.pid} exited)`);
|
|
98
|
+
removeAnnotation(annotation.id);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Load state with cleanup
|
|
103
|
+
function loadStateWithCleanup() {
|
|
104
|
+
cleanupDeadProcesses();
|
|
105
|
+
return loadState();
|
|
106
|
+
}
|
|
107
|
+
// Generate unique ID using crypto for collision resistance
|
|
108
|
+
function generateId(prefix) {
|
|
109
|
+
const uuid = randomUUID().replace(/-/g, '').substring(0, 8).toUpperCase();
|
|
110
|
+
return `${prefix}${uuid}`;
|
|
111
|
+
}
|
|
112
|
+
// Get all ports currently used in state
|
|
113
|
+
function getUsedPorts(state) {
|
|
114
|
+
const ports = new Set();
|
|
115
|
+
if (state.architect?.port)
|
|
116
|
+
ports.add(state.architect.port);
|
|
117
|
+
for (const builder of state.builders || []) {
|
|
118
|
+
if (builder.port)
|
|
119
|
+
ports.add(builder.port);
|
|
120
|
+
}
|
|
121
|
+
for (const util of state.utils || []) {
|
|
122
|
+
if (util.port)
|
|
123
|
+
ports.add(util.port);
|
|
124
|
+
}
|
|
125
|
+
for (const annotation of state.annotations || []) {
|
|
126
|
+
if (annotation.port)
|
|
127
|
+
ports.add(annotation.port);
|
|
128
|
+
}
|
|
129
|
+
return ports;
|
|
130
|
+
}
|
|
131
|
+
// Find available port in range (checks both state and actual availability)
|
|
132
|
+
async function findAvailablePort(startPort, state) {
|
|
133
|
+
// Get ports already allocated in state
|
|
134
|
+
const usedPorts = state ? getUsedPorts(state) : new Set();
|
|
135
|
+
// Skip ports already in state
|
|
136
|
+
let port = startPort;
|
|
137
|
+
while (usedPorts.has(port)) {
|
|
138
|
+
port++;
|
|
139
|
+
}
|
|
140
|
+
// Then verify the port is actually available for binding
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
const server = net.createServer();
|
|
143
|
+
server.listen(port, () => {
|
|
144
|
+
const { port: boundPort } = server.address();
|
|
145
|
+
server.close(() => resolve(boundPort));
|
|
146
|
+
});
|
|
147
|
+
server.on('error', () => {
|
|
148
|
+
resolve(findAvailablePort(port + 1, state));
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
// Wait for a port to be accepting connections (server ready)
|
|
153
|
+
async function waitForPortReady(port, timeoutMs = 5000) {
|
|
154
|
+
const startTime = Date.now();
|
|
155
|
+
const pollInterval = 100; // Check every 100ms
|
|
156
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
157
|
+
const isReady = await new Promise((resolve) => {
|
|
158
|
+
const socket = new net.Socket();
|
|
159
|
+
socket.setTimeout(pollInterval);
|
|
160
|
+
socket.on('connect', () => {
|
|
161
|
+
socket.destroy();
|
|
162
|
+
resolve(true);
|
|
163
|
+
});
|
|
164
|
+
socket.on('error', () => {
|
|
165
|
+
socket.destroy();
|
|
166
|
+
resolve(false);
|
|
167
|
+
});
|
|
168
|
+
socket.on('timeout', () => {
|
|
169
|
+
socket.destroy();
|
|
170
|
+
resolve(false);
|
|
171
|
+
});
|
|
172
|
+
socket.connect(port, '127.0.0.1');
|
|
173
|
+
});
|
|
174
|
+
if (isReady) {
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
// Wait before next poll
|
|
178
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
179
|
+
}
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
// Kill tmux session
|
|
183
|
+
function killTmuxSession(sessionName) {
|
|
184
|
+
try {
|
|
185
|
+
execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { stdio: 'ignore' });
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// Session may not exist
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Check if a process is running
|
|
192
|
+
function isProcessRunning(pid) {
|
|
193
|
+
try {
|
|
194
|
+
// Signal 0 doesn't kill, just checks if process exists
|
|
195
|
+
process.kill(pid, 0);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Graceful process termination with two-phase shutdown
|
|
203
|
+
async function killProcessGracefully(pid, tmuxSession) {
|
|
204
|
+
// First kill tmux session if provided
|
|
205
|
+
if (tmuxSession) {
|
|
206
|
+
killTmuxSession(tmuxSession);
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
// First try SIGTERM
|
|
210
|
+
process.kill(pid, 'SIGTERM');
|
|
211
|
+
// Wait up to 500ms for process to exit
|
|
212
|
+
await new Promise((resolve) => {
|
|
213
|
+
let attempts = 0;
|
|
214
|
+
const checkInterval = setInterval(() => {
|
|
215
|
+
attempts++;
|
|
216
|
+
try {
|
|
217
|
+
// Signal 0 checks if process exists
|
|
218
|
+
process.kill(pid, 0);
|
|
219
|
+
if (attempts >= 5) {
|
|
220
|
+
// Process still alive after 500ms, use SIGKILL
|
|
221
|
+
clearInterval(checkInterval);
|
|
222
|
+
try {
|
|
223
|
+
process.kill(pid, 'SIGKILL');
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// Already dead
|
|
227
|
+
}
|
|
228
|
+
resolve();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// Process is dead
|
|
233
|
+
clearInterval(checkInterval);
|
|
234
|
+
resolve();
|
|
235
|
+
}
|
|
236
|
+
}, 100);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// Process may already be dead
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Spawn detached process with error handling
|
|
244
|
+
function spawnDetached(command, args, cwd) {
|
|
245
|
+
try {
|
|
246
|
+
const child = spawn(command, args, {
|
|
247
|
+
cwd,
|
|
248
|
+
detached: true,
|
|
249
|
+
stdio: 'ignore',
|
|
250
|
+
});
|
|
251
|
+
child.on('error', (err) => {
|
|
252
|
+
console.error(`Failed to spawn ${command}:`, err.message);
|
|
253
|
+
});
|
|
254
|
+
child.unref();
|
|
255
|
+
return child.pid || null;
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
console.error(`Failed to spawn ${command}:`, err.message);
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// Check if tmux session exists
|
|
263
|
+
function tmuxSessionExists(sessionName) {
|
|
264
|
+
try {
|
|
265
|
+
execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { stdio: 'ignore' });
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// Create a persistent tmux session and attach ttyd to it
|
|
273
|
+
// Idempotent: if session exists, just spawn ttyd to attach to it
|
|
274
|
+
function spawnTmuxWithTtyd(sessionName, shellCommand, ttydPort, cwd) {
|
|
275
|
+
try {
|
|
276
|
+
// Only create session if it doesn't exist (idempotent)
|
|
277
|
+
if (!tmuxSessionExists(sessionName)) {
|
|
278
|
+
// Create tmux session with the shell command
|
|
279
|
+
execSync(`tmux new-session -d -s "${sessionName}" -x 200 -y 50 "${shellCommand}"`, { cwd, stdio: 'ignore' });
|
|
280
|
+
// Enable mouse support in the session
|
|
281
|
+
execSync(`tmux set-option -t "${sessionName}" -g mouse on`, { stdio: 'ignore' });
|
|
282
|
+
// Enable OSC 52 clipboard (allows copy to browser clipboard via ttyd)
|
|
283
|
+
execSync(`tmux set-option -t "${sessionName}" -g set-clipboard on`, { stdio: 'ignore' });
|
|
284
|
+
// Enable passthrough for hyperlinks and clipboard
|
|
285
|
+
execSync(`tmux set-option -t "${sessionName}" -g allow-passthrough on`, { stdio: 'ignore' });
|
|
286
|
+
// Copy selection to clipboard when mouse is released
|
|
287
|
+
// Use copy-pipe-and-cancel with pbcopy to directly copy to system clipboard
|
|
288
|
+
// (OSC 52 via set-clipboard doesn't work reliably through ttyd/xterm.js)
|
|
289
|
+
execSync(`tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"`, { stdio: 'ignore' });
|
|
290
|
+
execSync(`tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"`, { stdio: 'ignore' });
|
|
291
|
+
}
|
|
292
|
+
// Start ttyd to attach to the tmux session
|
|
293
|
+
// Using simple theme arg to avoid shell escaping issues
|
|
294
|
+
// Use custom index.html for file path click-to-open functionality (optional)
|
|
295
|
+
const customIndexPath = findTemplatePath('ttyd-index.html');
|
|
296
|
+
const ttydArgs = [
|
|
297
|
+
'-W',
|
|
298
|
+
'-p', String(ttydPort),
|
|
299
|
+
'-t', 'theme={"background":"#000000"}',
|
|
300
|
+
'-t', 'fontSize=14',
|
|
301
|
+
'-t', 'rightClickSelectsWord=true', // Enable word selection on right-click for better UX
|
|
302
|
+
];
|
|
303
|
+
// Add custom index if it exists
|
|
304
|
+
if (customIndexPath) {
|
|
305
|
+
ttydArgs.push('-I', customIndexPath);
|
|
306
|
+
}
|
|
307
|
+
ttydArgs.push('tmux', 'attach-session', '-t', sessionName);
|
|
308
|
+
const pid = spawnDetached('ttyd', ttydArgs, cwd);
|
|
309
|
+
return pid;
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
console.error(`Failed to create tmux session ${sessionName}:`, err.message);
|
|
313
|
+
// Cleanup any partial session
|
|
314
|
+
killTmuxSession(sessionName);
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Generate a short 4-character base64-encoded ID for worktree names
|
|
320
|
+
*/
|
|
321
|
+
function generateShortId() {
|
|
322
|
+
const num = Math.floor(Math.random() * 0xFFFFFF);
|
|
323
|
+
const bytes = new Uint8Array([num >> 16, (num >> 8) & 0xFF, num & 0xFF]);
|
|
324
|
+
return btoa(String.fromCharCode(...bytes))
|
|
325
|
+
.replace(/\+/g, '-')
|
|
326
|
+
.replace(/\//g, '_')
|
|
327
|
+
.replace(/=/g, '')
|
|
328
|
+
.substring(0, 4);
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Spawn a worktree builder - creates git worktree and starts builder CLI
|
|
332
|
+
* Similar to shell spawning but with git worktree isolation
|
|
333
|
+
*/
|
|
334
|
+
function spawnWorktreeBuilder(builderPort, state) {
|
|
335
|
+
const shortId = generateShortId();
|
|
336
|
+
const builderId = `worktree-${shortId}`;
|
|
337
|
+
const branchName = `builder/worktree-${shortId}`;
|
|
338
|
+
const worktreePath = path.resolve(projectRoot, '.builders', builderId);
|
|
339
|
+
const sessionName = `builder-${builderId}`;
|
|
340
|
+
try {
|
|
341
|
+
// Ensure .builders directory exists
|
|
342
|
+
const buildersDir = path.resolve(projectRoot, '.builders');
|
|
343
|
+
if (!fs.existsSync(buildersDir)) {
|
|
344
|
+
fs.mkdirSync(buildersDir, { recursive: true });
|
|
345
|
+
}
|
|
346
|
+
// Create git branch and worktree
|
|
347
|
+
execSync(`git branch "${branchName}" HEAD`, { cwd: projectRoot, stdio: 'ignore' });
|
|
348
|
+
execSync(`git worktree add "${worktreePath}" "${branchName}"`, { cwd: projectRoot, stdio: 'ignore' });
|
|
349
|
+
// Get builder command from config or use default
|
|
350
|
+
const configPath = path.resolve(projectRoot, 'codev', 'config.json');
|
|
351
|
+
let builderCommand = 'claude';
|
|
352
|
+
if (fs.existsSync(configPath)) {
|
|
353
|
+
try {
|
|
354
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
355
|
+
builderCommand = config?.shell?.builder || 'claude';
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
// Use default
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// Create tmux session with builder command
|
|
362
|
+
execSync(`tmux new-session -d -s "${sessionName}" -x 200 -y 50 -c "${worktreePath}" "${builderCommand}"`, { cwd: worktreePath, stdio: 'ignore' });
|
|
363
|
+
// Enable mouse support
|
|
364
|
+
execSync(`tmux set-option -t "${sessionName}" -g mouse on`, { stdio: 'ignore' });
|
|
365
|
+
execSync(`tmux set-option -t "${sessionName}" -g set-clipboard on`, { stdio: 'ignore' });
|
|
366
|
+
execSync(`tmux set-option -t "${sessionName}" -g allow-passthrough on`, { stdio: 'ignore' });
|
|
367
|
+
// Copy selection to clipboard when mouse is released (pbcopy for macOS)
|
|
368
|
+
execSync(`tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"`, { stdio: 'ignore' });
|
|
369
|
+
execSync(`tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"`, { stdio: 'ignore' });
|
|
370
|
+
// Start ttyd
|
|
371
|
+
const customIndexPath = findTemplatePath('ttyd-index.html');
|
|
372
|
+
const ttydArgs = [
|
|
373
|
+
'-W',
|
|
374
|
+
'-p', String(builderPort),
|
|
375
|
+
'-t', 'theme={"background":"#000000"}',
|
|
376
|
+
'-t', 'fontSize=14',
|
|
377
|
+
'-t', 'rightClickSelectsWord=true',
|
|
378
|
+
];
|
|
379
|
+
if (customIndexPath) {
|
|
380
|
+
ttydArgs.push('-I', customIndexPath);
|
|
381
|
+
}
|
|
382
|
+
ttydArgs.push('tmux', 'attach-session', '-t', sessionName);
|
|
383
|
+
const pid = spawnDetached('ttyd', ttydArgs, worktreePath);
|
|
384
|
+
if (!pid) {
|
|
385
|
+
// Cleanup on failure
|
|
386
|
+
killTmuxSession(sessionName);
|
|
387
|
+
try {
|
|
388
|
+
execSync(`git worktree remove "${worktreePath}" --force`, { cwd: projectRoot, stdio: 'ignore' });
|
|
389
|
+
execSync(`git branch -D "${branchName}"`, { cwd: projectRoot, stdio: 'ignore' });
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
// Best effort cleanup
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
const builder = {
|
|
397
|
+
id: builderId,
|
|
398
|
+
name: `Worktree ${shortId}`,
|
|
399
|
+
port: builderPort,
|
|
400
|
+
pid,
|
|
401
|
+
status: 'implementing',
|
|
402
|
+
phase: 'interactive',
|
|
403
|
+
worktree: worktreePath,
|
|
404
|
+
branch: branchName,
|
|
405
|
+
tmuxSession: sessionName,
|
|
406
|
+
type: 'worktree',
|
|
407
|
+
};
|
|
408
|
+
return { builder, pid };
|
|
409
|
+
}
|
|
410
|
+
catch (err) {
|
|
411
|
+
console.error(`Failed to spawn worktree builder:`, err.message);
|
|
412
|
+
// Cleanup any partial state
|
|
413
|
+
killTmuxSession(sessionName);
|
|
414
|
+
try {
|
|
415
|
+
execSync(`git worktree remove "${worktreePath}" --force`, { cwd: projectRoot, stdio: 'ignore' });
|
|
416
|
+
execSync(`git branch -D "${branchName}"`, { cwd: projectRoot, stdio: 'ignore' });
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
// Best effort cleanup
|
|
420
|
+
}
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
// Parse JSON body from request with size limit
|
|
425
|
+
function parseJsonBody(req, maxSize = 1024 * 1024) {
|
|
426
|
+
return new Promise((resolve, reject) => {
|
|
427
|
+
let body = '';
|
|
428
|
+
let size = 0;
|
|
429
|
+
req.on('data', (chunk) => {
|
|
430
|
+
size += chunk.length;
|
|
431
|
+
if (size > maxSize) {
|
|
432
|
+
reject(new Error('Request body too large'));
|
|
433
|
+
req.destroy();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
body += chunk.toString();
|
|
437
|
+
});
|
|
438
|
+
req.on('end', () => {
|
|
439
|
+
try {
|
|
440
|
+
resolve(body ? JSON.parse(body) : {});
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
reject(new Error('Invalid JSON'));
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
req.on('error', reject);
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
// Validate path is within project root (prevent path traversal)
|
|
450
|
+
// Handles URL-encoded dots (%2e), symlinks, and other encodings
|
|
451
|
+
function validatePathWithinProject(filePath) {
|
|
452
|
+
// First decode any URL encoding to catch %2e%2e (encoded ..)
|
|
453
|
+
let decodedPath;
|
|
454
|
+
try {
|
|
455
|
+
decodedPath = decodeURIComponent(filePath);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
// Invalid encoding
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
// Resolve to absolute path
|
|
462
|
+
const resolvedPath = decodedPath.startsWith('/')
|
|
463
|
+
? path.resolve(decodedPath)
|
|
464
|
+
: path.resolve(projectRoot, decodedPath);
|
|
465
|
+
// Normalize to remove any .. or . segments
|
|
466
|
+
const normalizedPath = path.normalize(resolvedPath);
|
|
467
|
+
// First check normalized path (for paths that don't exist yet)
|
|
468
|
+
if (!normalizedPath.startsWith(projectRoot + path.sep) && normalizedPath !== projectRoot) {
|
|
469
|
+
return null; // Path escapes project root
|
|
470
|
+
}
|
|
471
|
+
// If file exists, resolve symlinks to prevent symlink-based path traversal
|
|
472
|
+
// An attacker could create a symlink within the repo pointing outside
|
|
473
|
+
if (fs.existsSync(normalizedPath)) {
|
|
474
|
+
try {
|
|
475
|
+
const realPath = fs.realpathSync(normalizedPath);
|
|
476
|
+
if (!realPath.startsWith(projectRoot + path.sep) && realPath !== projectRoot) {
|
|
477
|
+
return null; // Symlink target escapes project root
|
|
478
|
+
}
|
|
479
|
+
return realPath;
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
// realpathSync failed (broken symlink, permissions, etc.)
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return normalizedPath;
|
|
487
|
+
}
|
|
488
|
+
// Count total tabs for DoS protection
|
|
489
|
+
function countTotalTabs(state) {
|
|
490
|
+
return state.builders.length + state.utils.length + state.annotations.length;
|
|
491
|
+
}
|
|
492
|
+
// Find annotation server script (prefer .ts for dev, .js for compiled)
|
|
493
|
+
function getAnnotateServerPath() {
|
|
494
|
+
const tsPath = path.join(__dirname, 'annotate-server.ts');
|
|
495
|
+
const jsPath = path.join(__dirname, 'annotate-server.js');
|
|
496
|
+
if (fs.existsSync(tsPath)) {
|
|
497
|
+
return { script: tsPath, useTsx: true };
|
|
498
|
+
}
|
|
499
|
+
return { script: jsPath, useTsx: false };
|
|
500
|
+
}
|
|
501
|
+
// Use split template as main, legacy is already loaded via findTemplatePath
|
|
502
|
+
const finalTemplatePath = templatePath;
|
|
503
|
+
// Security: Validate request origin
|
|
504
|
+
function isRequestAllowed(req) {
|
|
505
|
+
const host = req.headers.host;
|
|
506
|
+
const origin = req.headers.origin;
|
|
507
|
+
// Host check (prevent DNS rebinding attacks)
|
|
508
|
+
if (host && !host.startsWith('localhost') && !host.startsWith('127.0.0.1')) {
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
// Origin check (prevent CSRF from external sites)
|
|
512
|
+
// Note: CLI tools/curl might not send Origin, so we only block if Origin is present and invalid
|
|
513
|
+
if (origin && !origin.startsWith('http://localhost') && !origin.startsWith('http://127.0.0.1')) {
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
// Create server
|
|
519
|
+
const server = http.createServer(async (req, res) => {
|
|
520
|
+
// Security: Validate Host and Origin headers
|
|
521
|
+
if (!isRequestAllowed(req)) {
|
|
522
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
523
|
+
res.end('Forbidden');
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
// CORS headers - restrict to localhost only for security
|
|
527
|
+
const origin = req.headers.origin;
|
|
528
|
+
if (origin && (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:'))) {
|
|
529
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
530
|
+
}
|
|
531
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
532
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
533
|
+
// Prevent caching of API responses
|
|
534
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
535
|
+
if (req.method === 'OPTIONS') {
|
|
536
|
+
res.writeHead(200);
|
|
537
|
+
res.end();
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const url = new URL(req.url || '/', `http://localhost:${port}`);
|
|
541
|
+
try {
|
|
542
|
+
// API: Get state
|
|
543
|
+
if (req.method === 'GET' && url.pathname === '/api/state') {
|
|
544
|
+
const state = loadStateWithCleanup();
|
|
545
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
546
|
+
res.end(JSON.stringify(state));
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
// API: Create file tab (annotation)
|
|
550
|
+
if (req.method === 'POST' && url.pathname === '/api/tabs/file') {
|
|
551
|
+
const body = await parseJsonBody(req);
|
|
552
|
+
const filePath = body.path;
|
|
553
|
+
if (!filePath) {
|
|
554
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
555
|
+
res.end('Missing path');
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
// Validate path is within project root (prevent path traversal)
|
|
559
|
+
const fullPath = validatePathWithinProject(filePath);
|
|
560
|
+
if (!fullPath) {
|
|
561
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
562
|
+
res.end('Path must be within project directory');
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
// Check file exists
|
|
566
|
+
if (!fs.existsSync(fullPath)) {
|
|
567
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
568
|
+
res.end(`File not found: ${filePath}`);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
// Check if already open
|
|
572
|
+
const annotations = getAnnotations();
|
|
573
|
+
const existing = annotations.find((a) => a.file === fullPath);
|
|
574
|
+
if (existing) {
|
|
575
|
+
// Verify the process is still running
|
|
576
|
+
if (isProcessRunning(existing.pid)) {
|
|
577
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
578
|
+
res.end(JSON.stringify({ id: existing.id, port: existing.port, existing: true }));
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
// Process is dead - clean up stale entry and spawn new one
|
|
582
|
+
console.log(`Cleaning up stale annotation for ${fullPath} (pid ${existing.pid} dead)`);
|
|
583
|
+
removeAnnotation(existing.id);
|
|
584
|
+
}
|
|
585
|
+
// DoS protection: check tab limit
|
|
586
|
+
const state = loadState();
|
|
587
|
+
if (countTotalTabs(state) >= CONFIG.maxTabs) {
|
|
588
|
+
res.writeHead(429, { 'Content-Type': 'text/plain' });
|
|
589
|
+
res.end(`Tab limit reached (max ${CONFIG.maxTabs}). Close some tabs first.`);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
// Find available port (pass state to avoid already-allocated ports)
|
|
593
|
+
const annotatePort = await findAvailablePort(CONFIG.annotatePortStart, state);
|
|
594
|
+
// Start annotation server
|
|
595
|
+
const { script: serverScript, useTsx } = getAnnotateServerPath();
|
|
596
|
+
if (!fs.existsSync(serverScript)) {
|
|
597
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
598
|
+
res.end('Annotation server not found');
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
// Use tsx for TypeScript files, node for compiled JavaScript
|
|
602
|
+
const cmd = useTsx ? 'npx' : 'node';
|
|
603
|
+
const args = useTsx
|
|
604
|
+
? ['tsx', serverScript, String(annotatePort), fullPath]
|
|
605
|
+
: [serverScript, String(annotatePort), fullPath];
|
|
606
|
+
const pid = spawnDetached(cmd, args, projectRoot);
|
|
607
|
+
if (!pid) {
|
|
608
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
609
|
+
res.end('Failed to start annotation server');
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
// Wait for annotation server to be ready (accepting connections)
|
|
613
|
+
const serverReady = await waitForPortReady(annotatePort, 5000);
|
|
614
|
+
if (!serverReady) {
|
|
615
|
+
// Server didn't start in time - kill it and report error
|
|
616
|
+
try {
|
|
617
|
+
process.kill(pid);
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
// Process may have already died
|
|
621
|
+
}
|
|
622
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
623
|
+
res.end('Annotation server failed to start (timeout)');
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
// Create annotation record
|
|
627
|
+
const annotation = {
|
|
628
|
+
id: generateId('A'),
|
|
629
|
+
file: fullPath,
|
|
630
|
+
port: annotatePort,
|
|
631
|
+
pid,
|
|
632
|
+
parent: { type: 'architect' },
|
|
633
|
+
};
|
|
634
|
+
addAnnotation(annotation);
|
|
635
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
636
|
+
res.end(JSON.stringify({ id: annotation.id, port: annotatePort }));
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
// API: Create builder tab (spawns worktree builder with random ID)
|
|
640
|
+
if (req.method === 'POST' && url.pathname === '/api/tabs/builder') {
|
|
641
|
+
const builderState = loadState();
|
|
642
|
+
// DoS protection: check tab limit
|
|
643
|
+
if (countTotalTabs(builderState) >= CONFIG.maxTabs) {
|
|
644
|
+
res.writeHead(429, { 'Content-Type': 'text/plain' });
|
|
645
|
+
res.end(`Tab limit reached (max ${CONFIG.maxTabs}). Close some tabs first.`);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
// Find available port for builder
|
|
649
|
+
const builderPort = await findAvailablePort(CONFIG.builderPortStart, builderState);
|
|
650
|
+
// Spawn worktree builder
|
|
651
|
+
const result = spawnWorktreeBuilder(builderPort, builderState);
|
|
652
|
+
if (!result) {
|
|
653
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
654
|
+
res.end('Failed to spawn worktree builder');
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
// Wait for ttyd to be ready
|
|
658
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
659
|
+
// Save builder to state
|
|
660
|
+
upsertBuilder(result.builder);
|
|
661
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
662
|
+
res.end(JSON.stringify({ id: result.builder.id, port: result.builder.port, name: result.builder.name }));
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
// API: Create shell tab
|
|
666
|
+
if (req.method === 'POST' && url.pathname === '/api/tabs/shell') {
|
|
667
|
+
const body = await parseJsonBody(req);
|
|
668
|
+
const name = body.name || undefined;
|
|
669
|
+
// Validate name if provided (prevent command injection)
|
|
670
|
+
if (name && !/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
671
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
672
|
+
res.end('Invalid name format');
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const shellState = loadState();
|
|
676
|
+
// DoS protection: check tab limit
|
|
677
|
+
if (countTotalTabs(shellState) >= CONFIG.maxTabs) {
|
|
678
|
+
res.writeHead(429, { 'Content-Type': 'text/plain' });
|
|
679
|
+
res.end(`Tab limit reached (max ${CONFIG.maxTabs}). Close some tabs first.`);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
// Generate ID and name
|
|
683
|
+
const id = generateId('U');
|
|
684
|
+
const utilName = name || `shell-${shellState.utils.length + 1}`;
|
|
685
|
+
const sessionName = `af-shell-${id}`;
|
|
686
|
+
// Find available port (pass state to avoid already-allocated ports)
|
|
687
|
+
const utilPort = await findAvailablePort(CONFIG.utilPortStart, shellState);
|
|
688
|
+
// Get shell command
|
|
689
|
+
const shell = process.env.SHELL || '/bin/bash';
|
|
690
|
+
// Start tmux session with ttyd attached
|
|
691
|
+
const pid = spawnTmuxWithTtyd(sessionName, shell, utilPort, projectRoot);
|
|
692
|
+
if (!pid) {
|
|
693
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
694
|
+
res.end('Failed to start shell');
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
// Wait for ttyd to be ready
|
|
698
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
699
|
+
// Create util record
|
|
700
|
+
const util = {
|
|
701
|
+
id,
|
|
702
|
+
name: utilName,
|
|
703
|
+
port: utilPort,
|
|
704
|
+
pid,
|
|
705
|
+
tmuxSession: sessionName,
|
|
706
|
+
};
|
|
707
|
+
addUtil(util);
|
|
708
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
709
|
+
res.end(JSON.stringify({ id, port: utilPort, name: utilName }));
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
// API: Close tab
|
|
713
|
+
if (req.method === 'DELETE' && url.pathname.startsWith('/api/tabs/')) {
|
|
714
|
+
const tabId = decodeURIComponent(url.pathname.replace('/api/tabs/', ''));
|
|
715
|
+
let found = false;
|
|
716
|
+
// Check if it's a file tab
|
|
717
|
+
if (tabId.startsWith('file-')) {
|
|
718
|
+
const annotationId = tabId.replace('file-', '');
|
|
719
|
+
const tabAnnotations = getAnnotations();
|
|
720
|
+
const annotation = tabAnnotations.find((a) => a.id === annotationId);
|
|
721
|
+
if (annotation) {
|
|
722
|
+
await killProcessGracefully(annotation.pid);
|
|
723
|
+
removeAnnotation(annotationId);
|
|
724
|
+
found = true;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
// Check if it's a builder tab
|
|
728
|
+
if (tabId.startsWith('builder-')) {
|
|
729
|
+
const builderId = tabId.replace('builder-', '');
|
|
730
|
+
const builder = getBuilder(builderId);
|
|
731
|
+
if (builder) {
|
|
732
|
+
await killProcessGracefully(builder.pid);
|
|
733
|
+
removeBuilder(builderId);
|
|
734
|
+
found = true;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
// Check if it's a shell tab
|
|
738
|
+
if (tabId.startsWith('shell-')) {
|
|
739
|
+
const utilId = tabId.replace('shell-', '');
|
|
740
|
+
const tabUtils = getUtils();
|
|
741
|
+
const util = tabUtils.find((u) => u.id === utilId);
|
|
742
|
+
if (util) {
|
|
743
|
+
await killProcessGracefully(util.pid, util.tmuxSession);
|
|
744
|
+
removeUtil(utilId);
|
|
745
|
+
found = true;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
if (found) {
|
|
749
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
750
|
+
res.end(JSON.stringify({ success: true }));
|
|
751
|
+
}
|
|
752
|
+
else {
|
|
753
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
754
|
+
res.end('Tab not found');
|
|
755
|
+
}
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
// API: Stop all
|
|
759
|
+
if (req.method === 'POST' && url.pathname === '/api/stop') {
|
|
760
|
+
const stopState = loadState();
|
|
761
|
+
// Kill all tmux sessions first
|
|
762
|
+
for (const util of stopState.utils) {
|
|
763
|
+
if (util.tmuxSession) {
|
|
764
|
+
killTmuxSession(util.tmuxSession);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if (stopState.architect?.tmuxSession) {
|
|
768
|
+
killTmuxSession(stopState.architect.tmuxSession);
|
|
769
|
+
}
|
|
770
|
+
// Kill all processes gracefully
|
|
771
|
+
const pids = [];
|
|
772
|
+
if (stopState.architect) {
|
|
773
|
+
pids.push(stopState.architect.pid);
|
|
774
|
+
}
|
|
775
|
+
for (const builder of stopState.builders) {
|
|
776
|
+
pids.push(builder.pid);
|
|
777
|
+
}
|
|
778
|
+
for (const util of stopState.utils) {
|
|
779
|
+
pids.push(util.pid);
|
|
780
|
+
}
|
|
781
|
+
for (const annotation of stopState.annotations) {
|
|
782
|
+
pids.push(annotation.pid);
|
|
783
|
+
}
|
|
784
|
+
// Kill all processes in parallel
|
|
785
|
+
await Promise.all(pids.map((pid) => killProcessGracefully(pid)));
|
|
786
|
+
// Clear state
|
|
787
|
+
clearState();
|
|
788
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
789
|
+
res.end(JSON.stringify({ success: true, killed: pids.length }));
|
|
790
|
+
// Exit after a short delay
|
|
791
|
+
setTimeout(() => process.exit(0), 500);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
// Open file route - handles file clicks from terminal
|
|
795
|
+
// Returns a small HTML page that messages the dashboard via BroadcastChannel
|
|
796
|
+
if (req.method === 'GET' && url.pathname === '/open-file') {
|
|
797
|
+
const filePath = url.searchParams.get('path');
|
|
798
|
+
const line = url.searchParams.get('line');
|
|
799
|
+
const sourcePort = url.searchParams.get('sourcePort');
|
|
800
|
+
if (!filePath) {
|
|
801
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
802
|
+
res.end('Missing path parameter');
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
// Determine base path for relative path resolution
|
|
806
|
+
// If sourcePort is provided, look up the builder/util to get its worktree
|
|
807
|
+
let basePath = projectRoot;
|
|
808
|
+
if (sourcePort) {
|
|
809
|
+
const portNum = parseInt(sourcePort, 10);
|
|
810
|
+
const builders = getBuilders();
|
|
811
|
+
// Check if it's a builder terminal
|
|
812
|
+
const builder = builders.find((b) => b.port === portNum);
|
|
813
|
+
if (builder && builder.worktree) {
|
|
814
|
+
basePath = builder.worktree;
|
|
815
|
+
}
|
|
816
|
+
// Check if it's a utility terminal (they run in project root, so no change needed)
|
|
817
|
+
// Architect terminal also runs in project root
|
|
818
|
+
}
|
|
819
|
+
// Validate path is within project (or builder worktree)
|
|
820
|
+
// For relative paths, resolve against the determined base path
|
|
821
|
+
let fullPath;
|
|
822
|
+
if (filePath.startsWith('/')) {
|
|
823
|
+
// Absolute path - validate against project root
|
|
824
|
+
fullPath = validatePathWithinProject(filePath);
|
|
825
|
+
}
|
|
826
|
+
else {
|
|
827
|
+
// Relative path - resolve against base path, then validate
|
|
828
|
+
const resolvedPath = path.resolve(basePath, filePath);
|
|
829
|
+
// For builder worktrees, the path is within project root (worktrees are under .builders/)
|
|
830
|
+
fullPath = validatePathWithinProject(resolvedPath);
|
|
831
|
+
}
|
|
832
|
+
if (!fullPath) {
|
|
833
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
834
|
+
res.end('Path must be within project directory');
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
// Check file exists
|
|
838
|
+
if (!fs.existsSync(fullPath)) {
|
|
839
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
840
|
+
res.end(`File not found: ${filePath}`);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
// HTML-escape the file path for safe display
|
|
844
|
+
const escapeHtml = (str) => str
|
|
845
|
+
.replace(/&/g, '&')
|
|
846
|
+
.replace(/</g, '<')
|
|
847
|
+
.replace(/>/g, '>')
|
|
848
|
+
.replace(/"/g, '"');
|
|
849
|
+
const safeFilePath = escapeHtml(filePath);
|
|
850
|
+
const safeLineDisplay = line ? ':' + escapeHtml(line) : '';
|
|
851
|
+
// Serve a small HTML page that communicates back to dashboard
|
|
852
|
+
// Note: We only use BroadcastChannel, not API call (dashboard handles tab creation)
|
|
853
|
+
const html = `<!DOCTYPE html>
|
|
854
|
+
<html>
|
|
855
|
+
<head>
|
|
856
|
+
<title>Opening file...</title>
|
|
857
|
+
<style>
|
|
858
|
+
body {
|
|
859
|
+
font-family: system-ui;
|
|
860
|
+
background: #1a1a1a;
|
|
861
|
+
color: #ccc;
|
|
862
|
+
display: flex;
|
|
863
|
+
align-items: center;
|
|
864
|
+
justify-content: center;
|
|
865
|
+
height: 100vh;
|
|
866
|
+
margin: 0;
|
|
867
|
+
}
|
|
868
|
+
.message { text-align: center; }
|
|
869
|
+
.path { color: #3b82f6; font-family: monospace; margin: 8px 0; }
|
|
870
|
+
</style>
|
|
871
|
+
</head>
|
|
872
|
+
<body>
|
|
873
|
+
<div class="message">
|
|
874
|
+
<p>Opening file...</p>
|
|
875
|
+
<p class="path">${safeFilePath}${safeLineDisplay}</p>
|
|
876
|
+
</div>
|
|
877
|
+
<script>
|
|
878
|
+
(async function() {
|
|
879
|
+
const path = ${JSON.stringify(fullPath)};
|
|
880
|
+
const line = ${line ? parseInt(line, 10) : 'null'};
|
|
881
|
+
|
|
882
|
+
// Use BroadcastChannel to message the dashboard
|
|
883
|
+
// Dashboard will handle opening the file tab
|
|
884
|
+
const channel = new BroadcastChannel('agent-farm');
|
|
885
|
+
channel.postMessage({
|
|
886
|
+
type: 'openFile',
|
|
887
|
+
path: path,
|
|
888
|
+
line: line
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
// Close this window/tab after a short delay
|
|
892
|
+
setTimeout(() => {
|
|
893
|
+
window.close();
|
|
894
|
+
// If window.close() doesn't work (wasn't opened by script),
|
|
895
|
+
// show success message
|
|
896
|
+
document.body.innerHTML = '<div class="message"><p>File opened in dashboard</p><p class="path">You can close this tab</p></div>';
|
|
897
|
+
}, 500);
|
|
898
|
+
})();
|
|
899
|
+
</script>
|
|
900
|
+
</body>
|
|
901
|
+
</html>`;
|
|
902
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
903
|
+
res.end(html);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
// Serve dashboard
|
|
907
|
+
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
|
|
908
|
+
try {
|
|
909
|
+
let template = fs.readFileSync(finalTemplatePath, 'utf-8');
|
|
910
|
+
const state = loadStateWithCleanup();
|
|
911
|
+
// Inject project name into template (HTML-escaped for security)
|
|
912
|
+
const projectName = escapeHtml(getProjectName(projectRoot));
|
|
913
|
+
template = template.replace(/\{\{PROJECT_NAME\}\}/g, projectName);
|
|
914
|
+
// Inject state into template
|
|
915
|
+
const stateJson = JSON.stringify(state);
|
|
916
|
+
template = template.replace('// STATE_INJECTION_POINT', `window.INITIAL_STATE = ${stateJson};`);
|
|
917
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
918
|
+
res.end(template);
|
|
919
|
+
}
|
|
920
|
+
catch (err) {
|
|
921
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
922
|
+
res.end('Error loading dashboard: ' + err.message);
|
|
923
|
+
}
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
// 404 for everything else
|
|
927
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
928
|
+
res.end('Not found');
|
|
929
|
+
}
|
|
930
|
+
catch (err) {
|
|
931
|
+
console.error('Request error:', err);
|
|
932
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
933
|
+
res.end('Internal server error: ' + err.message);
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
server.listen(port, () => {
|
|
937
|
+
console.log(`Dashboard: http://localhost:${port}`);
|
|
938
|
+
});
|
|
939
|
+
//# sourceMappingURL=dashboard-server.js.map
|