@openscout/scout 0.2.65 → 0.2.69
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/README.md +32 -3
- package/bin/openscout-runtime.mjs +11 -0
- package/dist/client/assets/addon-fit-DtSNdpy5.js +1 -0
- package/dist/client/assets/addon-webgl-YYNKPQUg.js +82 -0
- package/dist/client/assets/index-5-XOQrKH.js +186 -0
- package/dist/client/assets/index-BnA2HAbt.css +1 -0
- package/dist/client/assets/xterm-fBtFsYpq.js +34 -0
- package/dist/client/index.html +3 -3
- package/dist/main.mjs +18897 -7052
- package/dist/openscout-terminal-relay.mjs +678 -0
- package/dist/pair-supervisor.mjs +27100 -21822
- package/dist/runtime/base-daemon.mjs +103 -12
- package/dist/runtime/broker-daemon.mjs +8238 -1089
- package/dist/runtime/broker-process-manager.mjs +79 -10
- package/dist/runtime/mesh-discover.mjs +79 -10
- package/dist/scout-control-plane-web.mjs +19364 -1812
- package/dist/scout-web-server.mjs +19364 -1812
- package/package.json +16 -2
- package/dist/client/assets/addon-fit-DX4qG4td.js +0 -1
- package/dist/client/assets/addon-webgl-DCtw1yLn.js +0 -64
- package/dist/client/assets/index-BOdG2WWL.css +0 -1
- package/dist/client/assets/index-TVkH_WDG.js +0 -1
- package/dist/client/assets/index-mL1LA8IG.js +0 -159
- package/dist/client/assets/xterm-B-qIQCd3.js +0 -16
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
// packages/web/server/terminal-relay-node.ts
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { join as join2 } from "node:path";
|
|
6
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
7
|
+
|
|
8
|
+
// packages/web/server/terminal-relay-session.ts
|
|
9
|
+
import { createRequire } from "module";
|
|
10
|
+
import { execFileSync, execSync } from "child_process";
|
|
11
|
+
import { accessSync, constants, existsSync, mkdirSync, writeFileSync } from "fs";
|
|
12
|
+
import { delimiter as pathDelimiter, join, dirname as pathDirname, resolve as pathResolve } from "path";
|
|
13
|
+
var require2 = createRequire(import.meta.url);
|
|
14
|
+
var pty = require2("node-pty");
|
|
15
|
+
var DEFAULT_ORPHAN_TTL_MS = 30 * 60 * 1000;
|
|
16
|
+
var MAX_BUFFER_SIZE = 512 * 1024;
|
|
17
|
+
var sessions = new Map;
|
|
18
|
+
function generateId() {
|
|
19
|
+
return Math.random().toString(36).slice(2, 10);
|
|
20
|
+
}
|
|
21
|
+
function send(ws, data) {
|
|
22
|
+
if (ws.readyState === 1) {
|
|
23
|
+
ws.send(JSON.stringify(data));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function ptyFdClosed(err) {
|
|
27
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
28
|
+
return message.includes("EBADF") || message.toLowerCase().includes("bad file descriptor");
|
|
29
|
+
}
|
|
30
|
+
function markSessionPtyClosed(session, err, op) {
|
|
31
|
+
if (session.exited)
|
|
32
|
+
return;
|
|
33
|
+
session.exited = true;
|
|
34
|
+
console.warn(`[relay] Session ${session.id}: PTY ${op} failed after fd closed (${err instanceof Error ? err.message : String(err)})`);
|
|
35
|
+
scheduleReap(session, 1e4);
|
|
36
|
+
}
|
|
37
|
+
function sessionOwnsSocket(session, ws) {
|
|
38
|
+
return session.ws === ws;
|
|
39
|
+
}
|
|
40
|
+
function writeSession(session, data) {
|
|
41
|
+
if (session.exited)
|
|
42
|
+
return false;
|
|
43
|
+
try {
|
|
44
|
+
session.pty.write(data);
|
|
45
|
+
return true;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (ptyFdClosed(err)) {
|
|
48
|
+
markSessionPtyClosed(session, err, "write");
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function resizeSession(session, cols, rows) {
|
|
55
|
+
if (session.exited)
|
|
56
|
+
return false;
|
|
57
|
+
try {
|
|
58
|
+
session.pty.resize(cols, rows);
|
|
59
|
+
if (session.backend === "tmux" && session.tmuxSession) {
|
|
60
|
+
resizeTmuxWindow(session.tmuxSession, cols, rows);
|
|
61
|
+
}
|
|
62
|
+
session.cols = cols;
|
|
63
|
+
session.rows = rows;
|
|
64
|
+
return true;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (ptyFdClosed(err)) {
|
|
67
|
+
markSessionPtyClosed(session, err, "resize");
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function resolveCwd(raw) {
|
|
74
|
+
const home = process.env.HOME || "/tmp";
|
|
75
|
+
const expanded = (raw || home).replace(/^~/, home);
|
|
76
|
+
if (existsSync(expanded))
|
|
77
|
+
return expanded;
|
|
78
|
+
try {
|
|
79
|
+
mkdirSync(expanded, { recursive: true });
|
|
80
|
+
console.log(`[relay] Created missing cwd: ${expanded}`);
|
|
81
|
+
return expanded;
|
|
82
|
+
} catch {
|
|
83
|
+
console.warn(`[relay] Could not create cwd ${expanded}, falling back to ${home}`);
|
|
84
|
+
return home;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function findBin(name, envOverride) {
|
|
88
|
+
if (envOverride && process.env[envOverride])
|
|
89
|
+
return process.env[envOverride];
|
|
90
|
+
try {
|
|
91
|
+
return execSync(`which ${name}`, { encoding: "utf8" }).trim() || null;
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function expandHomePath(value) {
|
|
97
|
+
const home = process.env.HOME || "/tmp";
|
|
98
|
+
if (value === "~")
|
|
99
|
+
return home;
|
|
100
|
+
if (value.startsWith("~/"))
|
|
101
|
+
return join(home, value.slice(2));
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
function isExecutablePath(candidate) {
|
|
105
|
+
if (!candidate)
|
|
106
|
+
return false;
|
|
107
|
+
try {
|
|
108
|
+
accessSync(candidate, constants.X_OK);
|
|
109
|
+
return true;
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function findExecutableInDirectories(name, directories) {
|
|
115
|
+
const seen = new Set;
|
|
116
|
+
for (const directory of directories) {
|
|
117
|
+
if (!directory)
|
|
118
|
+
continue;
|
|
119
|
+
const normalizedDirectory = pathResolve(expandHomePath(directory));
|
|
120
|
+
if (seen.has(normalizedDirectory))
|
|
121
|
+
continue;
|
|
122
|
+
seen.add(normalizedDirectory);
|
|
123
|
+
const candidate = join(normalizedDirectory, name);
|
|
124
|
+
if (isExecutablePath(candidate))
|
|
125
|
+
return candidate;
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
function findExecutableOnPath(name) {
|
|
130
|
+
return findExecutableInDirectories(name, (process.env.PATH || "").split(pathDelimiter));
|
|
131
|
+
}
|
|
132
|
+
function findClaudeBin() {
|
|
133
|
+
for (const envKey of ["OPENSCOUT_CLAUDE_BIN", "SCOUT_CLAUDE_BIN", "CLAUDE_BIN"]) {
|
|
134
|
+
const explicit = process.env[envKey]?.trim();
|
|
135
|
+
if (!explicit)
|
|
136
|
+
continue;
|
|
137
|
+
const expanded = expandHomePath(explicit);
|
|
138
|
+
if (isExecutablePath(expanded))
|
|
139
|
+
return pathResolve(expanded);
|
|
140
|
+
const foundOnPath = findExecutableOnPath(explicit);
|
|
141
|
+
if (foundOnPath)
|
|
142
|
+
return foundOnPath;
|
|
143
|
+
}
|
|
144
|
+
const home = process.env.HOME || "/tmp";
|
|
145
|
+
return findExecutableInDirectories("claude", [
|
|
146
|
+
join(home, ".local", "bin"),
|
|
147
|
+
join(home, ".claude", "local"),
|
|
148
|
+
"/opt/homebrew/bin",
|
|
149
|
+
"/usr/local/bin",
|
|
150
|
+
join(home, ".bun", "bin")
|
|
151
|
+
]) ?? findExecutableOnPath("claude");
|
|
152
|
+
}
|
|
153
|
+
function findPiBin() {
|
|
154
|
+
return findBin("pi", "PI_BIN");
|
|
155
|
+
}
|
|
156
|
+
function findShellBin() {
|
|
157
|
+
const candidates = [
|
|
158
|
+
process.env.SHELL,
|
|
159
|
+
"/bin/zsh",
|
|
160
|
+
"/bin/bash",
|
|
161
|
+
"/bin/sh"
|
|
162
|
+
].filter((candidate) => Boolean(candidate));
|
|
163
|
+
for (const candidate of candidates) {
|
|
164
|
+
if (existsSync(candidate))
|
|
165
|
+
return candidate;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
function normalizePiProviderForCli(provider) {
|
|
170
|
+
if (!provider)
|
|
171
|
+
return;
|
|
172
|
+
if (provider === "copilot" || provider === "github")
|
|
173
|
+
return "github-copilot";
|
|
174
|
+
return provider;
|
|
175
|
+
}
|
|
176
|
+
function tmuxSessionExists(name) {
|
|
177
|
+
try {
|
|
178
|
+
execSync(`tmux has-session -t ${name} 2>/dev/null`, { encoding: "utf8" });
|
|
179
|
+
return true;
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function resizeTmuxWindow(name, cols, rows) {
|
|
185
|
+
try {
|
|
186
|
+
execFileSync("tmux", [
|
|
187
|
+
"resize-window",
|
|
188
|
+
"-t",
|
|
189
|
+
name,
|
|
190
|
+
"-x",
|
|
191
|
+
String(cols),
|
|
192
|
+
"-y",
|
|
193
|
+
String(rows)
|
|
194
|
+
], { stdio: "ignore" });
|
|
195
|
+
return true;
|
|
196
|
+
} catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function bootstrapFiles(cwd, files, sessionId) {
|
|
201
|
+
for (const [relPath, content] of Object.entries(files)) {
|
|
202
|
+
const absPath = join(cwd, relPath);
|
|
203
|
+
if (!existsSync(absPath)) {
|
|
204
|
+
try {
|
|
205
|
+
const dir = pathDirname(absPath);
|
|
206
|
+
if (!existsSync(dir))
|
|
207
|
+
mkdirSync(dir, { recursive: true });
|
|
208
|
+
writeFileSync(absPath, content, "utf-8");
|
|
209
|
+
console.log(`[relay] Session ${sessionId}: bootstrapped ${relPath}`);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
console.warn(`[relay] Session ${sessionId}: failed to bootstrap ${relPath}:`, err);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function spawnTmuxSession(tmuxName, cols, rows, cwd, claudeBin, claudeArgs, env) {
|
|
217
|
+
const exists = tmuxSessionExists(tmuxName);
|
|
218
|
+
if (!exists) {
|
|
219
|
+
const shellCmd = [claudeBin, ...claudeArgs].map((a) => a.includes(" ") ? `'${a}'` : a).join(" ");
|
|
220
|
+
execSync(`tmux new-session -d -s ${tmuxName} -x ${cols} -y ${rows} -c '${cwd}' '${shellCmd}'`, { env });
|
|
221
|
+
console.log(`[relay] Created tmux session: ${tmuxName}`);
|
|
222
|
+
} else {
|
|
223
|
+
resizeTmuxWindow(tmuxName, cols, rows);
|
|
224
|
+
console.log(`[relay] Attaching to existing tmux session: ${tmuxName}`);
|
|
225
|
+
}
|
|
226
|
+
return pty.spawn("tmux", ["attach", "-t", tmuxName], {
|
|
227
|
+
name: "xterm-256color",
|
|
228
|
+
cols,
|
|
229
|
+
rows,
|
|
230
|
+
cwd,
|
|
231
|
+
env
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
function createSession(ws, msg) {
|
|
235
|
+
const id = generateId();
|
|
236
|
+
const cols = Math.max(msg.cols || 80, 20);
|
|
237
|
+
const rows = Math.max(msg.rows || 24, 4);
|
|
238
|
+
const backend = msg.backend || "pty";
|
|
239
|
+
const tmuxName = msg.tmuxSession || `hudson-${id}`;
|
|
240
|
+
const agent = msg.agent || "claude";
|
|
241
|
+
let agentBin;
|
|
242
|
+
if (agent === "shell") {
|
|
243
|
+
agentBin = findShellBin();
|
|
244
|
+
if (!agentBin) {
|
|
245
|
+
const reason = "Shell not found. Set SHELL or install zsh/bash/sh.";
|
|
246
|
+
console.error(`[relay] Session ${id} failed: ${reason}`);
|
|
247
|
+
send(ws, { type: "session:error", error: reason });
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
} else if (agent === "pi") {
|
|
251
|
+
agentBin = findPiBin();
|
|
252
|
+
if (!agentBin) {
|
|
253
|
+
const reason = "pi CLI not found. Install it with: npm install -g @mariozechner/pi-coding-agent";
|
|
254
|
+
console.error(`[relay] Session ${id} failed: ${reason}`);
|
|
255
|
+
send(ws, { type: "session:error", error: reason });
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
agentBin = findClaudeBin();
|
|
260
|
+
if (!agentBin) {
|
|
261
|
+
const reason = "Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash";
|
|
262
|
+
console.error(`[relay] Session ${id} failed: ${reason}`);
|
|
263
|
+
send(ws, { type: "session:error", error: reason });
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (!existsSync(agentBin)) {
|
|
268
|
+
const reason = `${agent} binary not found at ${agentBin}`;
|
|
269
|
+
console.error(`[relay] Session ${id} failed: ${reason}`);
|
|
270
|
+
send(ws, { type: "session:error", error: reason });
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
if (backend === "tmux" && !findBin("tmux")) {
|
|
274
|
+
const reason = "tmux not found. Install it with: brew install tmux";
|
|
275
|
+
console.error(`[relay] Session ${id} failed: ${reason}`);
|
|
276
|
+
send(ws, { type: "session:error", error: reason });
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
const cwd = resolveCwd(msg.cwd);
|
|
280
|
+
if (msg.workspaceFiles) {
|
|
281
|
+
bootstrapFiles(cwd, msg.workspaceFiles, id);
|
|
282
|
+
}
|
|
283
|
+
let agentArgs;
|
|
284
|
+
if (agent === "shell") {
|
|
285
|
+
agentArgs = [];
|
|
286
|
+
} else if (agent === "pi") {
|
|
287
|
+
agentArgs = ["--verbose"];
|
|
288
|
+
const provider = normalizePiProviderForCli(msg.provider);
|
|
289
|
+
if (provider)
|
|
290
|
+
agentArgs.push("--provider", provider);
|
|
291
|
+
if (msg.model)
|
|
292
|
+
agentArgs.push("--model", msg.model);
|
|
293
|
+
if (msg.systemPrompt)
|
|
294
|
+
agentArgs.push("--system-prompt", msg.systemPrompt);
|
|
295
|
+
} else {
|
|
296
|
+
agentArgs = ["--verbose"];
|
|
297
|
+
if (msg.systemPrompt)
|
|
298
|
+
agentArgs.push("--system-prompt", msg.systemPrompt);
|
|
299
|
+
}
|
|
300
|
+
const env = { ...process.env, TERM: "xterm-256color", FORCE_COLOR: "1" };
|
|
301
|
+
delete env.CLAUDECODE;
|
|
302
|
+
let ptyProcess;
|
|
303
|
+
try {
|
|
304
|
+
if (backend === "tmux") {
|
|
305
|
+
console.log(`[relay] Session ${id}: tmux backend (session: ${tmuxName}) in ${cwd} [agent: ${agent}]`);
|
|
306
|
+
ptyProcess = spawnTmuxSession(tmuxName, cols, rows, cwd, agentBin, agentArgs, env);
|
|
307
|
+
} else {
|
|
308
|
+
console.log(`[relay] Session ${id}: pty backend, spawning ${agentBin} in ${cwd} [agent: ${agent}]`);
|
|
309
|
+
ptyProcess = pty.spawn(agentBin, agentArgs, {
|
|
310
|
+
name: "xterm-256color",
|
|
311
|
+
cols,
|
|
312
|
+
rows,
|
|
313
|
+
cwd,
|
|
314
|
+
env
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
} catch (err) {
|
|
318
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
319
|
+
console.error(`[relay] Session ${id}: failed to spawn PTY — ${message}`);
|
|
320
|
+
send(ws, { type: "session:error", error: `Failed to spawn terminal: ${message}` });
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
const orphanTTL = msg.orphanTTL && msg.orphanTTL > 0 ? msg.orphanTTL : DEFAULT_ORPHAN_TTL_MS;
|
|
324
|
+
const session = {
|
|
325
|
+
id,
|
|
326
|
+
pty: ptyProcess,
|
|
327
|
+
ws,
|
|
328
|
+
outputBuffer: "",
|
|
329
|
+
cols,
|
|
330
|
+
rows,
|
|
331
|
+
reapTimer: null,
|
|
332
|
+
orphanTTL,
|
|
333
|
+
backend,
|
|
334
|
+
...backend === "tmux" ? { tmuxSession: tmuxName } : {},
|
|
335
|
+
exited: false,
|
|
336
|
+
exitCode: null
|
|
337
|
+
};
|
|
338
|
+
const startTime = Date.now();
|
|
339
|
+
ptyProcess.onData((data) => {
|
|
340
|
+
session.outputBuffer += data;
|
|
341
|
+
if (session.outputBuffer.length > MAX_BUFFER_SIZE) {
|
|
342
|
+
session.outputBuffer = session.outputBuffer.slice(-MAX_BUFFER_SIZE);
|
|
343
|
+
}
|
|
344
|
+
if (session.ws && session.ws.readyState === 1) {
|
|
345
|
+
send(session.ws, { type: "terminal:data", data });
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
349
|
+
session.exited = true;
|
|
350
|
+
session.exitCode = exitCode;
|
|
351
|
+
const uptime = Date.now() - startTime;
|
|
352
|
+
const crashed = exitCode !== 0 && uptime < 5000;
|
|
353
|
+
let reason;
|
|
354
|
+
if (crashed) {
|
|
355
|
+
const clean = session.outputBuffer.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").trim();
|
|
356
|
+
const lines = clean.split(`
|
|
357
|
+
`).filter((l) => l.trim()).slice(-5);
|
|
358
|
+
reason = lines.join(`
|
|
359
|
+
`) || `Process exited with code ${exitCode}`;
|
|
360
|
+
console.error(`[relay] Session ${id} crashed after ${uptime}ms (code ${exitCode}): ${reason}`);
|
|
361
|
+
}
|
|
362
|
+
if (session.ws) {
|
|
363
|
+
send(session.ws, { type: "session:exit", exitCode, ...reason ? { reason } : {} });
|
|
364
|
+
}
|
|
365
|
+
scheduleReap(session, 1e4);
|
|
366
|
+
});
|
|
367
|
+
sessions.set(id, session);
|
|
368
|
+
console.log(`[relay] Session ${id} created (${cols}x${rows})`);
|
|
369
|
+
return session;
|
|
370
|
+
}
|
|
371
|
+
function attachSession(session, ws, cols, rows) {
|
|
372
|
+
if (session.reapTimer) {
|
|
373
|
+
clearTimeout(session.reapTimer);
|
|
374
|
+
session.reapTimer = null;
|
|
375
|
+
}
|
|
376
|
+
session.ws = ws;
|
|
377
|
+
if (cols && rows) {
|
|
378
|
+
const c = Math.max(cols, 20);
|
|
379
|
+
const r = Math.max(rows, 4);
|
|
380
|
+
if (c !== session.cols || r !== session.rows) {
|
|
381
|
+
resizeSession(session, c, r);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (session.exited) {
|
|
385
|
+
send(ws, { type: "session:exit", exitCode: session.exitCode });
|
|
386
|
+
} else if (session.outputBuffer.length > 0) {
|
|
387
|
+
send(ws, { type: "terminal:data", data: session.outputBuffer });
|
|
388
|
+
}
|
|
389
|
+
console.log(`[relay] Session ${session.id} reconnected`);
|
|
390
|
+
}
|
|
391
|
+
function detachSession(session) {
|
|
392
|
+
session.ws = null;
|
|
393
|
+
if (session.exited) {
|
|
394
|
+
scheduleReap(session, 5000);
|
|
395
|
+
} else {
|
|
396
|
+
scheduleReap(session, session.orphanTTL);
|
|
397
|
+
console.log(`[relay] Session ${session.id} detached (orphaned for ${session.orphanTTL / 1000}s)`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function scheduleReap(session, delay) {
|
|
401
|
+
if (session.reapTimer)
|
|
402
|
+
clearTimeout(session.reapTimer);
|
|
403
|
+
session.reapTimer = setTimeout(() => {
|
|
404
|
+
if (!session.ws) {
|
|
405
|
+
destroy(session.id);
|
|
406
|
+
}
|
|
407
|
+
}, delay);
|
|
408
|
+
}
|
|
409
|
+
function destroy(sessionId) {
|
|
410
|
+
const session = sessions.get(sessionId);
|
|
411
|
+
if (!session)
|
|
412
|
+
return;
|
|
413
|
+
if (session.reapTimer)
|
|
414
|
+
clearTimeout(session.reapTimer);
|
|
415
|
+
try {
|
|
416
|
+
session.pty.kill();
|
|
417
|
+
} catch {}
|
|
418
|
+
sessions.delete(sessionId);
|
|
419
|
+
if (session.backend === "tmux") {
|
|
420
|
+
console.log(`[relay] Session ${sessionId} bridge destroyed (tmux session '${session.tmuxSession}' still alive)`);
|
|
421
|
+
} else {
|
|
422
|
+
console.log(`[relay] Session ${sessionId} destroyed`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// packages/web/server/terminal-relay-node.ts
|
|
427
|
+
process.title = "scout-relay";
|
|
428
|
+
var require3 = createRequire2(import.meta.url);
|
|
429
|
+
var { WebSocketServer } = require3("ws");
|
|
430
|
+
var hostname = process.env.OPENSCOUT_WEB_TERMINAL_RELAY_HOST?.trim() || process.env.OPENSCOUT_WEB_HOST?.trim() || process.env.SCOUT_WEB_HOST?.trim() || "127.0.0.1";
|
|
431
|
+
var port = Number.parseInt(process.env.OPENSCOUT_WEB_TERMINAL_RELAY_PORT?.trim() || "3201", 10);
|
|
432
|
+
var UPLOAD_DIR = "/tmp/scout-uploads";
|
|
433
|
+
var pendingCommand = null;
|
|
434
|
+
function queueTerminalCommand(input) {
|
|
435
|
+
if (!input.cwd) {
|
|
436
|
+
for (const [, session] of sessions) {
|
|
437
|
+
if (writeSession(session, input.command + `
|
|
438
|
+
`)) {
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
pendingCommand = input;
|
|
444
|
+
}
|
|
445
|
+
function drainPendingCommand() {
|
|
446
|
+
const command = pendingCommand;
|
|
447
|
+
pendingCommand = null;
|
|
448
|
+
return command;
|
|
449
|
+
}
|
|
450
|
+
function parseMessage(raw) {
|
|
451
|
+
try {
|
|
452
|
+
const message = JSON.parse(raw);
|
|
453
|
+
if (typeof message.type === "string") {
|
|
454
|
+
return message;
|
|
455
|
+
}
|
|
456
|
+
} catch {}
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
function setCors(res) {
|
|
460
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
461
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
462
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
463
|
+
}
|
|
464
|
+
async function readJson(req) {
|
|
465
|
+
const chunks = [];
|
|
466
|
+
for await (const chunk of req) {
|
|
467
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
468
|
+
}
|
|
469
|
+
if (chunks.length === 0) {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
474
|
+
} catch {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
function writeJson(res, status, body) {
|
|
479
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
480
|
+
res.end(JSON.stringify(body));
|
|
481
|
+
}
|
|
482
|
+
function relayUploadPath(name) {
|
|
483
|
+
return join2(UPLOAD_DIR, `${randomUUID()}-${name}`);
|
|
484
|
+
}
|
|
485
|
+
var server = createServer(async (req, res) => {
|
|
486
|
+
setCors(res);
|
|
487
|
+
if (req.method === "OPTIONS") {
|
|
488
|
+
res.writeHead(204);
|
|
489
|
+
res.end();
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const url = new URL(req.url || "/", `http://${hostname}:${port}`);
|
|
493
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
494
|
+
let attachedSessions = 0;
|
|
495
|
+
for (const session of sessions.values()) {
|
|
496
|
+
if (session.ws) {
|
|
497
|
+
attachedSessions += 1;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
writeJson(res, 200, {
|
|
501
|
+
ok: true,
|
|
502
|
+
surface: "openscout-terminal-relay",
|
|
503
|
+
pid: process.pid,
|
|
504
|
+
sessions: sessions.size,
|
|
505
|
+
attachedSessions
|
|
506
|
+
});
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (req.method === "POST" && (url.pathname === "/api/upload" || url.pathname === "/api/relay/upload")) {
|
|
510
|
+
const body = await readJson(req);
|
|
511
|
+
if (!body?.name || !body.data) {
|
|
512
|
+
writeJson(res, 400, { error: "Missing name or data" });
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
await mkdir(UPLOAD_DIR, { recursive: true });
|
|
516
|
+
const filepath = relayUploadPath(body.name);
|
|
517
|
+
await writeFile(filepath, Buffer.from(body.data, "base64"));
|
|
518
|
+
writeJson(res, 200, { path: filepath });
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (req.method === "POST" && url.pathname === "/api/terminal/run") {
|
|
522
|
+
const body = await readJson(req);
|
|
523
|
+
const command = body?.command?.trim();
|
|
524
|
+
if (!command) {
|
|
525
|
+
writeJson(res, 400, { error: "missing command" });
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
queueTerminalCommand({
|
|
529
|
+
command,
|
|
530
|
+
cwd: body?.cwd?.trim() || null,
|
|
531
|
+
agentId: body?.agentId?.trim() || null
|
|
532
|
+
});
|
|
533
|
+
writeJson(res, 200, { ok: true });
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
writeJson(res, 404, { error: "Not found" });
|
|
537
|
+
});
|
|
538
|
+
var wss = new WebSocketServer({ server });
|
|
539
|
+
wss.on("connection", (ws) => {
|
|
540
|
+
let sessionId = null;
|
|
541
|
+
ws.on("message", (raw) => {
|
|
542
|
+
const msg = parseMessage(raw.toString());
|
|
543
|
+
if (!msg) {
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
switch (msg.type) {
|
|
547
|
+
case "session:init": {
|
|
548
|
+
const pending = drainPendingCommand();
|
|
549
|
+
if (sessionId) {
|
|
550
|
+
const previous = sessions.get(sessionId);
|
|
551
|
+
if (previous && sessionOwnsSocket(previous, ws)) {
|
|
552
|
+
detachSession(previous);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
const session = createSession(ws, pending?.cwd ? { ...msg, cwd: pending.cwd, agent: "shell" } : msg);
|
|
556
|
+
if (!session) {
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
sessionId = session.id;
|
|
560
|
+
send(ws, { type: "session:ready", sessionId: session.id });
|
|
561
|
+
if (pending) {
|
|
562
|
+
setTimeout(() => writeSession(session, pending.command + `
|
|
563
|
+
`), 400);
|
|
564
|
+
}
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
case "session:reconnect": {
|
|
568
|
+
const pending = drainPendingCommand();
|
|
569
|
+
if (pending?.cwd) {
|
|
570
|
+
if (sessionId) {
|
|
571
|
+
const previous = sessions.get(sessionId);
|
|
572
|
+
if (previous && sessionOwnsSocket(previous, ws)) {
|
|
573
|
+
detachSession(previous);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
const session = createSession(ws, {
|
|
577
|
+
type: "session:init",
|
|
578
|
+
cols: msg.cols ?? 80,
|
|
579
|
+
rows: msg.rows ?? 24,
|
|
580
|
+
cwd: pending.cwd,
|
|
581
|
+
agent: "shell"
|
|
582
|
+
});
|
|
583
|
+
if (!session) {
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
sessionId = session.id;
|
|
587
|
+
send(ws, { type: "session:ready", sessionId: session.id });
|
|
588
|
+
setTimeout(() => writeSession(session, pending.command + `
|
|
589
|
+
`), 400);
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
const existing = sessions.get(msg.sessionId);
|
|
593
|
+
if (existing && !existing.exited) {
|
|
594
|
+
if (sessionId && sessionId !== msg.sessionId) {
|
|
595
|
+
const previous = sessions.get(sessionId);
|
|
596
|
+
if (previous && sessionOwnsSocket(previous, ws)) {
|
|
597
|
+
detachSession(previous);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (existing.ws && existing.ws !== ws) {
|
|
601
|
+
send(existing.ws, { type: "session:detached" });
|
|
602
|
+
}
|
|
603
|
+
sessionId = existing.id;
|
|
604
|
+
attachSession(existing, ws, msg.cols, msg.rows);
|
|
605
|
+
send(ws, {
|
|
606
|
+
type: "session:ready",
|
|
607
|
+
sessionId: existing.id,
|
|
608
|
+
reconnected: true
|
|
609
|
+
});
|
|
610
|
+
if (pending) {
|
|
611
|
+
setTimeout(() => writeSession(existing, pending.command + `
|
|
612
|
+
`), 400);
|
|
613
|
+
}
|
|
614
|
+
} else {
|
|
615
|
+
send(ws, { type: "session:expired", sessionId: msg.sessionId });
|
|
616
|
+
}
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
case "terminal:input": {
|
|
620
|
+
if (!sessionId) {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const session = sessions.get(sessionId);
|
|
624
|
+
if (session && sessionOwnsSocket(session, ws)) {
|
|
625
|
+
writeSession(session, msg.data);
|
|
626
|
+
}
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
case "terminal:resize": {
|
|
630
|
+
if (!sessionId) {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const session = sessions.get(sessionId);
|
|
634
|
+
if (session && sessionOwnsSocket(session, ws)) {
|
|
635
|
+
const cols = Math.max(msg.cols || 80, 20);
|
|
636
|
+
const rows = Math.max(msg.rows || 24, 4);
|
|
637
|
+
resizeSession(session, cols, rows);
|
|
638
|
+
}
|
|
639
|
+
break;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
ws.on("close", () => {
|
|
644
|
+
if (!sessionId) {
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
const session = sessions.get(sessionId);
|
|
648
|
+
if (session && sessionOwnsSocket(session, ws)) {
|
|
649
|
+
detachSession(session);
|
|
650
|
+
}
|
|
651
|
+
sessionId = null;
|
|
652
|
+
});
|
|
653
|
+
ws.on("error", (err) => {
|
|
654
|
+
console.error("[relay] WebSocket error:", err.message);
|
|
655
|
+
if (!sessionId) {
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
const session = sessions.get(sessionId);
|
|
659
|
+
if (session && sessionOwnsSocket(session, ws)) {
|
|
660
|
+
detachSession(session);
|
|
661
|
+
}
|
|
662
|
+
sessionId = null;
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
server.listen(port, hostname, () => {
|
|
666
|
+
console.log(`[relay] Server listening on http://${hostname}:${port} (HTTP + WebSocket)`);
|
|
667
|
+
});
|
|
668
|
+
var shutdown = () => {
|
|
669
|
+
console.log(`
|
|
670
|
+
[relay] Shutting down...`);
|
|
671
|
+
for (const [id] of sessions) {
|
|
672
|
+
destroy(id);
|
|
673
|
+
}
|
|
674
|
+
wss.close();
|
|
675
|
+
server.close(() => process.exit(0));
|
|
676
|
+
};
|
|
677
|
+
process.on("SIGINT", shutdown);
|
|
678
|
+
process.on("SIGTERM", shutdown);
|