@company-os/terminal-server 1.0.0 → 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/README.md +33 -47
- package/dist/__tests__/auth.test.d.ts +2 -0
- package/dist/__tests__/auth.test.d.ts.map +1 -0
- package/dist/__tests__/auth.test.js +61 -0
- package/dist/__tests__/auth.test.js.map +1 -0
- package/dist/__tests__/spawn-config.test.d.ts +2 -0
- package/dist/__tests__/spawn-config.test.d.ts.map +1 -0
- package/dist/__tests__/spawn-config.test.js +79 -0
- package/dist/__tests__/spawn-config.test.js.map +1 -0
- package/dist/__tests__/validation.test.d.ts +2 -0
- package/dist/__tests__/validation.test.d.ts.map +1 -0
- package/dist/__tests__/validation.test.js +102 -0
- package/dist/__tests__/validation.test.js.map +1 -0
- package/dist/auth.d.ts +8 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +21 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +77 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +426 -904
- package/dist/index.js.map +1 -0
- package/dist/spawn-config.d.ts +16 -0
- package/dist/spawn-config.d.ts.map +1 -0
- package/dist/spawn-config.js +14 -0
- package/dist/spawn-config.js.map +1 -0
- package/dist/transcript-sync.d.ts +24 -0
- package/dist/transcript-sync.d.ts.map +1 -0
- package/dist/transcript-sync.js +176 -0
- package/dist/transcript-sync.js.map +1 -0
- package/dist/types.d.ts +38 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +16 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +135 -0
- package/dist/validation.js.map +1 -0
- package/package.json +16 -16
package/dist/index.js
CHANGED
|
@@ -1,935 +1,457 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
};
|
|
21
|
-
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
22
|
-
// If the importer is in node compatibility mode or this is not an ESM
|
|
23
|
-
// file that has been converted to a CommonJS file using a Babel-
|
|
24
|
-
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
25
|
-
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
26
|
-
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
27
|
-
mod
|
|
28
|
-
));
|
|
29
|
-
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
30
|
-
|
|
31
|
-
// src/index.ts
|
|
32
|
-
var index_exports = {};
|
|
33
|
-
__export(index_exports, {
|
|
34
|
-
DEFAULT_PORT: () => DEFAULT_PORT,
|
|
35
|
-
SERVER_CONFIG: () => SERVER_CONFIG
|
|
36
|
-
});
|
|
37
|
-
module.exports = __toCommonJS(index_exports);
|
|
38
|
-
var import_ws = require("ws");
|
|
39
|
-
var http = __toESM(require("http"));
|
|
40
|
-
var pty2 = __toESM(require("node-pty"));
|
|
41
|
-
|
|
42
|
-
// src/config.ts
|
|
43
|
-
var os = __toESM(require("os"));
|
|
44
|
-
var SERVER_CONFIG = {
|
|
45
|
-
/** Default WebSocket server port */
|
|
46
|
-
defaultPort: parseInt(process.env.TERMINAL_SERVER_PORT || "3002", 10),
|
|
47
|
-
/** Allowed origins for WebSocket connections */
|
|
48
|
-
allowedOrigins: (process.env.ALLOWED_ORIGINS || "http://localhost:3000,http://localhost:3002,https://app.company-os.ai").split(",")
|
|
49
|
-
};
|
|
50
|
-
var PTY_CONFIG = {
|
|
51
|
-
/** Terminal type */
|
|
52
|
-
termName: "xterm-256color",
|
|
53
|
-
/** Default columns */
|
|
54
|
-
cols: 80,
|
|
55
|
-
/** Default rows */
|
|
56
|
-
rows: 24
|
|
57
|
-
};
|
|
58
|
-
function getDefaultShell() {
|
|
59
|
-
if (os.platform() === "win32") {
|
|
60
|
-
return process.env.COMSPEC || "cmd.exe";
|
|
61
|
-
}
|
|
62
|
-
return process.env.SHELL || "/bin/zsh";
|
|
63
|
-
}
|
|
64
|
-
function getWorkingDirectory() {
|
|
65
|
-
return process.env.COMPANYOS_WORKSPACE_ROOT || process.cwd().replace(/\/packages\/terminal-server$/, "") || os.homedir();
|
|
66
|
-
}
|
|
67
|
-
function generateSessionId() {
|
|
68
|
-
return `session-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// src/validation.ts
|
|
72
|
-
var VALIDATION_LIMITS = {
|
|
73
|
-
/** Maximum input size in bytes (64KB) */
|
|
74
|
-
maxInputLength: 65536,
|
|
75
|
-
/** Maximum terminal columns */
|
|
76
|
-
maxCols: 500,
|
|
77
|
-
/** Maximum terminal rows */
|
|
78
|
-
maxRows: 200,
|
|
79
|
-
/** Minimum terminal columns */
|
|
80
|
-
minCols: 1,
|
|
81
|
-
/** Minimum terminal rows */
|
|
82
|
-
minRows: 1
|
|
83
|
-
};
|
|
84
|
-
function validateClientMessage(raw) {
|
|
85
|
-
let parsed;
|
|
86
|
-
try {
|
|
87
|
-
parsed = JSON.parse(raw.toString());
|
|
88
|
-
} catch {
|
|
89
|
-
return { valid: false, error: "Invalid JSON" };
|
|
90
|
-
}
|
|
91
|
-
if (typeof parsed !== "object" || parsed === null) {
|
|
92
|
-
return { valid: false, error: "Message must be an object" };
|
|
93
|
-
}
|
|
94
|
-
const msg = parsed;
|
|
95
|
-
if (typeof msg.type !== "string") {
|
|
96
|
-
return { valid: false, error: "Missing or invalid message type" };
|
|
97
|
-
}
|
|
98
|
-
switch (msg.type) {
|
|
99
|
-
case "input":
|
|
100
|
-
return validateInputMessage(msg);
|
|
101
|
-
case "resize":
|
|
102
|
-
return validateResizeMessage(msg);
|
|
103
|
-
case "ping":
|
|
104
|
-
return { valid: true, message: { type: "ping" } };
|
|
105
|
-
default:
|
|
106
|
-
return { valid: false, error: `Unknown message type: ${msg.type}` };
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
function validateInputMessage(msg) {
|
|
110
|
-
if (typeof msg.data !== "string") {
|
|
111
|
-
return { valid: false, error: "Input data must be a string" };
|
|
112
|
-
}
|
|
113
|
-
if (msg.data.length > VALIDATION_LIMITS.maxInputLength) {
|
|
114
|
-
return {
|
|
115
|
-
valid: false,
|
|
116
|
-
error: `Input exceeds maximum length of ${VALIDATION_LIMITS.maxInputLength} bytes`
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
return { valid: true, message: { type: "input", data: msg.data } };
|
|
2
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
3
|
+
import * as http from 'http';
|
|
4
|
+
import * as pty from 'node-pty';
|
|
5
|
+
import * as fsp from 'fs/promises';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import * as os from 'os';
|
|
8
|
+
import { execFileSync } from 'child_process';
|
|
9
|
+
import { SERVER_CONFIG, PTY_CONFIG, generateSessionId, detectClis } from './config.js';
|
|
10
|
+
import { validateClientMessage } from './validation.js';
|
|
11
|
+
import { getAuthConfig, verifyToken, extractTokenFromRequest } from './auth.js';
|
|
12
|
+
import { buildSpawnConfig } from './spawn-config.js';
|
|
13
|
+
import { startTranscriptSync } from './transcript-sync.js';
|
|
14
|
+
const sessions = new Map();
|
|
15
|
+
const authConfig = getAuthConfig();
|
|
16
|
+
function sendMessage(ws, message) {
|
|
17
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
18
|
+
ws.send(JSON.stringify(message));
|
|
19
|
+
}
|
|
120
20
|
}
|
|
121
|
-
function
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
21
|
+
function resetIdleTimer(sessionId) {
|
|
22
|
+
const session = sessions.get(sessionId);
|
|
23
|
+
if (!session)
|
|
24
|
+
return;
|
|
25
|
+
if (session.idleTimer)
|
|
26
|
+
clearTimeout(session.idleTimer);
|
|
27
|
+
if (SERVER_CONFIG.idleTimeoutMs <= 0)
|
|
28
|
+
return;
|
|
29
|
+
session.idleTimer = setTimeout(() => {
|
|
30
|
+
console.log(`Session idle timeout: ${sessionId}`);
|
|
31
|
+
sendMessage(session.ws, { type: 'exit', exitCode: -1 });
|
|
32
|
+
session.pty.kill();
|
|
33
|
+
session.ws.close(1000, 'Idle timeout');
|
|
34
|
+
sessions.delete(sessionId);
|
|
35
|
+
}, SERVER_CONFIG.idleTimeoutMs);
|
|
135
36
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
<html lang="en">
|
|
141
|
-
<head>
|
|
142
|
-
<meta charset="UTF-8" />
|
|
143
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
144
|
-
<title>CompanyOS Terminal</title>
|
|
145
|
-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css" />
|
|
146
|
-
<style>
|
|
147
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
148
|
-
html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
|
149
|
-
body { display: flex; flex-direction: column; }
|
|
150
|
-
#header {
|
|
151
|
-
height: 56px;
|
|
152
|
-
display: flex;
|
|
153
|
-
align-items: center;
|
|
154
|
-
justify-content: space-between;
|
|
155
|
-
padding: 0 16px;
|
|
156
|
-
background: #1e1e1e;
|
|
157
|
-
border-bottom: 1px solid #333;
|
|
158
|
-
flex-shrink: 0;
|
|
159
|
-
position: sticky;
|
|
160
|
-
top: 0;
|
|
161
|
-
z-index: 10;
|
|
162
|
-
}
|
|
163
|
-
#header-left {
|
|
164
|
-
display: flex;
|
|
165
|
-
align-items: center;
|
|
166
|
-
gap: 8px;
|
|
167
|
-
overflow: hidden;
|
|
37
|
+
function clearIdleTimer(session) {
|
|
38
|
+
if (session.idleTimer) {
|
|
39
|
+
clearTimeout(session.idleTimer);
|
|
40
|
+
session.idleTimer = null;
|
|
168
41
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
border-radius: 4px;
|
|
189
|
-
cursor: pointer;
|
|
190
|
-
font-size: 13px;
|
|
191
|
-
display: flex;
|
|
192
|
-
align-items: center;
|
|
193
|
-
gap: 4px;
|
|
194
|
-
}
|
|
195
|
-
#copy-btn:hover { background: #333; }
|
|
196
|
-
#terminal-container {
|
|
197
|
-
flex: 1;
|
|
198
|
-
overflow: hidden;
|
|
199
|
-
}
|
|
200
|
-
#terminal-container .xterm { height: 100%; width: 100%; }
|
|
201
|
-
#terminal-container .xterm-viewport { overflow-y: auto !important; }
|
|
202
|
-
#toast {
|
|
203
|
-
position: fixed;
|
|
204
|
-
top: 16px;
|
|
205
|
-
left: 50%;
|
|
206
|
-
transform: translateX(-50%) translateY(-80px);
|
|
207
|
-
background: #4caf50;
|
|
208
|
-
color: #fff;
|
|
209
|
-
padding: 8px 24px;
|
|
210
|
-
border-radius: 6px;
|
|
211
|
-
font-size: 14px;
|
|
212
|
-
font-weight: 500;
|
|
213
|
-
opacity: 0;
|
|
214
|
-
transition: transform 0.3s ease, opacity 0.3s ease;
|
|
215
|
-
z-index: 100;
|
|
216
|
-
pointer-events: none;
|
|
42
|
+
}
|
|
43
|
+
const RESUME_CAPABLE_COMMANDS = new Set(['claude', 'codex']);
|
|
44
|
+
/** Snapshot all .jsonl session files under ~/.claude/projects/ */
|
|
45
|
+
async function snapshotClaudeSessions() {
|
|
46
|
+
const ids = new Set();
|
|
47
|
+
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
|
|
48
|
+
try {
|
|
49
|
+
const projectDirs = await fsp.readdir(claudeDir);
|
|
50
|
+
for (const dir of projectDirs) {
|
|
51
|
+
const dirPath = path.join(claudeDir, dir);
|
|
52
|
+
try {
|
|
53
|
+
const files = await fsp.readdir(dirPath);
|
|
54
|
+
for (const f of files) {
|
|
55
|
+
if (f.endsWith('.jsonl'))
|
|
56
|
+
ids.add(f.replace('.jsonl', ''));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch { /* skip unreadable dirs */ }
|
|
60
|
+
}
|
|
217
61
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
62
|
+
catch { /* directory doesn't exist */ }
|
|
63
|
+
return ids;
|
|
64
|
+
}
|
|
65
|
+
/** After spawning a CLI, detect its session ID by diffing the sessions directory */
|
|
66
|
+
async function detectCliSessionId(beforeSnapshot) {
|
|
67
|
+
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
|
|
68
|
+
for (let attempt = 0; attempt < 12; attempt++) {
|
|
69
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
70
|
+
try {
|
|
71
|
+
const projectDirs = await fsp.readdir(claudeDir);
|
|
72
|
+
for (const dir of projectDirs) {
|
|
73
|
+
const dirPath = path.join(claudeDir, dir);
|
|
74
|
+
try {
|
|
75
|
+
const files = await fsp.readdir(dirPath);
|
|
76
|
+
for (const f of files) {
|
|
77
|
+
if (f.endsWith('.jsonl')) {
|
|
78
|
+
const id = f.replace('.jsonl', '');
|
|
79
|
+
if (!beforeSnapshot.has(id))
|
|
80
|
+
return id;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch { /* skip */ }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch { /* retry */ }
|
|
221
88
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
</div>
|
|
238
|
-
<div id="terminal-container"></div>
|
|
239
|
-
<div id="toast">Copied to clipboard</div>
|
|
240
|
-
|
|
241
|
-
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
|
242
|
-
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
|
243
|
-
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
|
244
|
-
<script>
|
|
245
|
-
(function() {
|
|
246
|
-
// --- Theme definitions (mirrored from terminalConfig.ts) ---
|
|
247
|
-
var THEMES = {
|
|
248
|
-
homebrew: {
|
|
249
|
-
background: '#000000', foreground: '#00ff00', cursor: '#00ff00', cursorAccent: '#000000',
|
|
250
|
-
selectionBackground: '#005500', selectionForeground: '#00ff00',
|
|
251
|
-
black: '#000000', red: '#990000', green: '#00a600', yellow: '#999900',
|
|
252
|
-
blue: '#0000b2', magenta: '#b200b2', cyan: '#00a6b2', white: '#bfbfbf',
|
|
253
|
-
brightBlack: '#666666', brightRed: '#e50000', brightGreen: '#00d900', brightYellow: '#e5e500',
|
|
254
|
-
brightBlue: '#0000ff', brightMagenta: '#e500e5', brightCyan: '#00e5e5', brightWhite: '#e5e5e5'
|
|
255
|
-
},
|
|
256
|
-
vscode: {
|
|
257
|
-
background: '#1e1e1e', foreground: '#d4d4d4', cursor: '#d4d4d4', cursorAccent: '#1e1e1e',
|
|
258
|
-
selectionBackground: '#264f78',
|
|
259
|
-
black: '#000000', red: '#cd3131', green: '#0dbc79', yellow: '#e5e510',
|
|
260
|
-
blue: '#2472c8', magenta: '#bc3fbc', cyan: '#11a8cd', white: '#e5e5e5',
|
|
261
|
-
brightBlack: '#666666', brightRed: '#f14c4c', brightGreen: '#23d18b', brightYellow: '#f5f543',
|
|
262
|
-
brightBlue: '#3b8eea', brightMagenta: '#d670d6', brightCyan: '#29b8db', brightWhite: '#ffffff'
|
|
263
|
-
},
|
|
264
|
-
dracula: {
|
|
265
|
-
background: '#282a36', foreground: '#f8f8f2', cursor: '#f8f8f2', cursorAccent: '#282a36',
|
|
266
|
-
selectionBackground: '#44475a',
|
|
267
|
-
black: '#21222c', red: '#ff5555', green: '#50fa7b', yellow: '#f1fa8c',
|
|
268
|
-
blue: '#bd93f9', magenta: '#ff79c6', cyan: '#8be9fd', white: '#f8f8f2',
|
|
269
|
-
brightBlack: '#6272a4', brightRed: '#ff6e6e', brightGreen: '#69ff94', brightYellow: '#ffffa5',
|
|
270
|
-
brightBlue: '#d6acff', brightMagenta: '#ff92df', brightCyan: '#a4ffff', brightWhite: '#ffffff'
|
|
271
|
-
},
|
|
272
|
-
monokai: {
|
|
273
|
-
background: '#272822', foreground: '#f8f8f2', cursor: '#f8f8f2', cursorAccent: '#272822',
|
|
274
|
-
selectionBackground: '#49483e',
|
|
275
|
-
black: '#272822', red: '#f92672', green: '#a6e22e', yellow: '#f4bf75',
|
|
276
|
-
blue: '#66d9ef', magenta: '#ae81ff', cyan: '#a1efe4', white: '#f8f8f2',
|
|
277
|
-
brightBlack: '#75715e', brightRed: '#f92672', brightGreen: '#a6e22e', brightYellow: '#f4bf75',
|
|
278
|
-
brightBlue: '#66d9ef', brightMagenta: '#ae81ff', brightCyan: '#a1efe4', brightWhite: '#f9f8f5'
|
|
279
|
-
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
function spawnSession(ws, createMsg) {
|
|
92
|
+
const sessionId = generateSessionId();
|
|
93
|
+
console.log(`New terminal session: ${sessionId} (projectId: ${createMsg.projectId ?? "none"}, threadId: ${createMsg.threadId ?? "none"})`);
|
|
94
|
+
const spawn = buildSpawnConfig(createMsg);
|
|
95
|
+
const isResumable = RESUME_CAPABLE_COMMANDS.has(spawn.command);
|
|
96
|
+
// Snapshot sessions before spawn so we can detect the new one
|
|
97
|
+
const snapshotPromise = isResumable ? snapshotClaudeSessions() : Promise.resolve(new Set());
|
|
98
|
+
// Build clean env — strip ANTHROPIC_API_KEY for CLI tools that use claude.ai auth
|
|
99
|
+
// to avoid the "Auth conflict: Both a token and an API key are set" warning.
|
|
100
|
+
const spawnEnv = {
|
|
101
|
+
...process.env,
|
|
102
|
+
TERM: PTY_CONFIG.termName,
|
|
103
|
+
COLORTERM: 'truecolor',
|
|
280
104
|
};
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
fontSize: 14,
|
|
301
|
-
fontFamily: '"Fira Code", "Menlo", "Monaco", "Courier New", monospace',
|
|
302
|
-
lineHeight: 1.2,
|
|
303
|
-
scrollback: 10000,
|
|
304
|
-
cursorBlink: true,
|
|
305
|
-
allowProposedApi: true,
|
|
105
|
+
if (isResumable) {
|
|
106
|
+
delete spawnEnv.ANTHROPIC_API_KEY;
|
|
107
|
+
}
|
|
108
|
+
// Pass project scope to MCP server so commands/queries are project-scoped.
|
|
109
|
+
// Server-configured projectId takes precedence over client-provided one
|
|
110
|
+
// to prevent clients from overriding the security boundary.
|
|
111
|
+
const serverProjectId = process.env.MCP_PROJECT_ID;
|
|
112
|
+
if (serverProjectId) {
|
|
113
|
+
spawnEnv.MCP_PROJECT_ID = serverProjectId;
|
|
114
|
+
}
|
|
115
|
+
else if (createMsg.projectId) {
|
|
116
|
+
spawnEnv.MCP_PROJECT_ID = createMsg.projectId;
|
|
117
|
+
}
|
|
118
|
+
const ptyProcess = pty.spawn(spawn.command, spawn.args, {
|
|
119
|
+
name: PTY_CONFIG.termName,
|
|
120
|
+
cols: PTY_CONFIG.cols,
|
|
121
|
+
rows: PTY_CONFIG.rows,
|
|
122
|
+
cwd: spawn.cwd,
|
|
123
|
+
env: spawnEnv,
|
|
306
124
|
});
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
125
|
+
sessions.set(sessionId, { pty: ptyProcess, ws, idleTimer: null });
|
|
126
|
+
sendMessage(ws, { type: 'session', sessionId });
|
|
127
|
+
resetIdleTimer(sessionId);
|
|
128
|
+
// Detect CLI session ID in the background (non-blocking)
|
|
129
|
+
if (isResumable) {
|
|
130
|
+
snapshotPromise.then(async (before) => {
|
|
131
|
+
const cliSessionId = await detectCliSessionId(before);
|
|
132
|
+
if (cliSessionId) {
|
|
133
|
+
console.log(`Detected CLI session ID: ${cliSessionId} for terminal session ${sessionId}`);
|
|
134
|
+
sendMessage(ws, { type: 'cliSessionId', cliSessionId });
|
|
135
|
+
// Start transcript sync if we have a threadId
|
|
136
|
+
if (createMsg.threadId) {
|
|
137
|
+
const session = sessions.get(sessionId);
|
|
138
|
+
if (session) {
|
|
139
|
+
session.transcriptWatcher = startTranscriptSync({
|
|
140
|
+
threadId: createMsg.threadId,
|
|
141
|
+
cliSessionId,
|
|
142
|
+
projectId: createMsg.projectId,
|
|
143
|
+
actorId: 'ai:claude-code',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}).catch(() => { });
|
|
149
|
+
}
|
|
150
|
+
ptyProcess.onData((data) => {
|
|
151
|
+
sendMessage(ws, { type: 'output', data });
|
|
321
152
|
});
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
// --- Copy button ---
|
|
333
|
-
document.getElementById('copy-btn').addEventListener('click', function() {
|
|
334
|
-
var content = '';
|
|
335
|
-
var buf = term.buffer.active;
|
|
336
|
-
for (var i = 0; i < buf.length; i++) {
|
|
337
|
-
var line = buf.getLine(i);
|
|
338
|
-
if (line) content += line.translateToString(true) + '\\n';
|
|
339
|
-
}
|
|
340
|
-
content = content.trimEnd();
|
|
341
|
-
if (content) {
|
|
342
|
-
navigator.clipboard.writeText(content).then(function() {
|
|
343
|
-
showToast('Copied to clipboard');
|
|
344
|
-
});
|
|
345
|
-
}
|
|
153
|
+
ptyProcess.onExit(({ exitCode, signal }) => {
|
|
154
|
+
console.log(`PTY exited: sessionId=${sessionId}, exitCode=${exitCode}, signal=${signal}`);
|
|
155
|
+
const session = sessions.get(sessionId);
|
|
156
|
+
if (session) {
|
|
157
|
+
clearIdleTimer(session);
|
|
158
|
+
session.transcriptWatcher?.stop();
|
|
159
|
+
}
|
|
160
|
+
sendMessage(ws, { type: 'exit', exitCode, signal });
|
|
161
|
+
ws.close();
|
|
162
|
+
sessions.delete(sessionId);
|
|
346
163
|
});
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
break;
|
|
371
|
-
case 'exit':
|
|
372
|
-
term.write('\\r\\n\\x1b[90mProcess exited with code ' + msg.exitCode + '\\x1b[0m');
|
|
373
|
-
break;
|
|
374
|
-
case 'error':
|
|
375
|
-
term.write('\\r\\n\\x1b[31mError: ' + msg.message + '\\x1b[0m');
|
|
376
|
-
break;
|
|
377
|
-
}
|
|
378
|
-
} catch(e) {
|
|
379
|
-
console.error('Failed to parse message:', e);
|
|
164
|
+
ws.on('message', (rawMessage) => {
|
|
165
|
+
const result = validateClientMessage(rawMessage.toString());
|
|
166
|
+
if (!result.valid) {
|
|
167
|
+
console.warn(`Invalid message from ${sessionId}: ${result.error}`);
|
|
168
|
+
sendMessage(ws, { type: 'error', message: result.error || 'Invalid message' });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const msg = result.message;
|
|
172
|
+
switch (msg.type) {
|
|
173
|
+
case 'create':
|
|
174
|
+
sendMessage(ws, { type: 'error', message: 'Session already created' });
|
|
175
|
+
break;
|
|
176
|
+
case 'input':
|
|
177
|
+
ptyProcess.write(msg.data);
|
|
178
|
+
resetIdleTimer(sessionId);
|
|
179
|
+
break;
|
|
180
|
+
case 'resize':
|
|
181
|
+
ptyProcess.resize(msg.cols, msg.rows);
|
|
182
|
+
resetIdleTimer(sessionId);
|
|
183
|
+
break;
|
|
184
|
+
case 'ping':
|
|
185
|
+
sendMessage(ws, { type: 'pong' });
|
|
186
|
+
break;
|
|
380
187
|
}
|
|
381
|
-
};
|
|
382
|
-
|
|
383
|
-
ws.onclose = function() {
|
|
384
|
-
updateConnectionStatus(false);
|
|
385
|
-
};
|
|
386
|
-
|
|
387
|
-
ws.onerror = function() {
|
|
388
|
-
updateConnectionStatus(false);
|
|
389
|
-
};
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
function updateConnectionStatus(connected) {
|
|
393
|
-
var titleEl = document.getElementById('header-title');
|
|
394
|
-
var baseTitle = titleEl.getAttribute('data-base-title') || titleEl.textContent;
|
|
395
|
-
titleEl.setAttribute('data-base-title', baseTitle);
|
|
396
|
-
titleEl.textContent = connected ? baseTitle : baseTitle + ' (Disconnected)';
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// --- Send input to terminal ---
|
|
400
|
-
term.onData(function(data) {
|
|
401
|
-
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
402
|
-
ws.send(JSON.stringify({ type: 'input', data: data }));
|
|
403
|
-
}
|
|
404
188
|
});
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
189
|
+
ws.on('close', () => {
|
|
190
|
+
console.log(`Session closed: ${sessionId}`);
|
|
191
|
+
const session = sessions.get(sessionId);
|
|
192
|
+
if (session) {
|
|
193
|
+
clearIdleTimer(session);
|
|
194
|
+
session.transcriptWatcher?.stop();
|
|
195
|
+
session.pty.kill();
|
|
196
|
+
sessions.delete(sessionId);
|
|
197
|
+
}
|
|
410
198
|
});
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
if (taskId) {
|
|
423
|
-
fetchBrief('/api/brief?taskId=' + encodeURIComponent(taskId));
|
|
424
|
-
} else if (workflowName) {
|
|
425
|
-
var url = '/api/workflow?name=' + encodeURIComponent(workflowName);
|
|
426
|
-
if (dryRun) url += '&dryRun=true';
|
|
427
|
-
fetchBrief(url);
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
function fetchBrief(url) {
|
|
432
|
-
fetch(url)
|
|
433
|
-
.then(function(res) {
|
|
434
|
-
if (!res.ok) throw new Error('Failed to fetch brief');
|
|
435
|
-
return res.json();
|
|
436
|
-
})
|
|
437
|
-
.then(function(brief) {
|
|
438
|
-
updateHeader(brief);
|
|
439
|
-
injectCommand(brief);
|
|
440
|
-
})
|
|
441
|
-
.catch(function(err) {
|
|
442
|
-
console.error('Failed to fetch brief:', err);
|
|
443
|
-
document.getElementById('header-error').style.display = 'inline';
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
function updateHeader(brief) {
|
|
448
|
-
var title;
|
|
449
|
-
if (brief.taskId !== undefined) {
|
|
450
|
-
title = 'Task #' + brief.taskId + ': ' + brief.name;
|
|
451
|
-
document.title = title + ' - Terminal';
|
|
452
|
-
} else if (brief.displayName) {
|
|
453
|
-
title = 'Workflow: ' + brief.displayName;
|
|
454
|
-
document.title = title + ' - Terminal';
|
|
455
|
-
}
|
|
456
|
-
if (title) {
|
|
457
|
-
var el = document.getElementById('header-title');
|
|
458
|
-
el.textContent = title;
|
|
459
|
-
el.setAttribute('data-base-title', title);
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
function injectCommand(brief) {
|
|
464
|
-
if (commandInjected) return;
|
|
465
|
-
commandInjected = true;
|
|
466
|
-
|
|
467
|
-
setTimeout(function() {
|
|
468
|
-
var command = brief.command;
|
|
469
|
-
if (skipPermissions && command.indexOf('claude ') === 0) {
|
|
470
|
-
command = command.replace('claude ', 'claude --dangerously-skip-permissions ');
|
|
199
|
+
ws.on('error', (err) => {
|
|
200
|
+
console.error(`WebSocket error for session ${sessionId}:`, err);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
function handleConnection(ws) {
|
|
204
|
+
ws.on('message', function onCreate(rawMessage) {
|
|
205
|
+
const result = validateClientMessage(rawMessage.toString());
|
|
206
|
+
if (!result.valid) {
|
|
207
|
+
sendMessage(ws, { type: 'error', message: result.error || 'Invalid message' });
|
|
208
|
+
return;
|
|
471
209
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
477
|
-
ws.send(JSON.stringify({ type: 'input', data: input }));
|
|
210
|
+
const msg = result.message;
|
|
211
|
+
if (msg.type === 'ping') {
|
|
212
|
+
sendMessage(ws, { type: 'pong' });
|
|
213
|
+
return;
|
|
478
214
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
var MAX_OUTPUT_LINES = 500;
|
|
493
|
-
var MAX_CONCURRENT_SESSIONS = 5;
|
|
494
|
-
var headlessSessions = /* @__PURE__ */ new Map();
|
|
495
|
-
function getRunningSessionCount() {
|
|
496
|
-
let count = 0;
|
|
497
|
-
for (const session of headlessSessions.values()) {
|
|
498
|
-
if (session.status === "running") count++;
|
|
499
|
-
}
|
|
500
|
-
return count;
|
|
501
|
-
}
|
|
502
|
-
function canSpawnSession() {
|
|
503
|
-
return getRunningSessionCount() < MAX_CONCURRENT_SESSIONS;
|
|
504
|
-
}
|
|
505
|
-
function spawnHeadlessSession(options) {
|
|
506
|
-
if (!canSpawnSession()) {
|
|
507
|
-
throw new Error(
|
|
508
|
-
`Cannot spawn session: at capacity (${MAX_CONCURRENT_SESSIONS} concurrent sessions)`
|
|
509
|
-
);
|
|
510
|
-
}
|
|
511
|
-
const sessionId = generateSessionId();
|
|
512
|
-
const cwd = options.cwd ?? getWorkingDirectory();
|
|
513
|
-
const escapedPrompt = options.prompt.replace(/'/g, "'\\''");
|
|
514
|
-
const command = `claude --dangerously-skip-permissions -p '${escapedPrompt}'`;
|
|
515
|
-
const ptyProcess = pty.spawn("/bin/bash", ["-c", command], {
|
|
516
|
-
name: PTY_CONFIG.termName,
|
|
517
|
-
cols: PTY_CONFIG.cols,
|
|
518
|
-
rows: PTY_CONFIG.rows,
|
|
519
|
-
cwd,
|
|
520
|
-
env: {
|
|
521
|
-
...process.env,
|
|
522
|
-
TERM: PTY_CONFIG.termName,
|
|
523
|
-
COLORTERM: "truecolor"
|
|
524
|
-
}
|
|
525
|
-
});
|
|
526
|
-
const session = {
|
|
527
|
-
id: sessionId,
|
|
528
|
-
taskExecutionId: options.taskExecutionId,
|
|
529
|
-
branchName: options.branchName,
|
|
530
|
-
pty: ptyProcess,
|
|
531
|
-
status: "running",
|
|
532
|
-
exitCode: null,
|
|
533
|
-
output: [],
|
|
534
|
-
startedAt: /* @__PURE__ */ new Date(),
|
|
535
|
-
completedAt: null
|
|
536
|
-
};
|
|
537
|
-
headlessSessions.set(sessionId, session);
|
|
538
|
-
ptyProcess.onData((data) => {
|
|
539
|
-
session.output.push(data);
|
|
540
|
-
if (session.output.length > MAX_OUTPUT_LINES) {
|
|
541
|
-
session.output.splice(0, session.output.length - MAX_OUTPUT_LINES);
|
|
542
|
-
}
|
|
543
|
-
});
|
|
544
|
-
ptyProcess.onExit(({ exitCode }) => {
|
|
545
|
-
session.exitCode = exitCode;
|
|
546
|
-
session.status = exitCode === 0 ? "succeeded" : "failed";
|
|
547
|
-
session.completedAt = /* @__PURE__ */ new Date();
|
|
548
|
-
console.log(
|
|
549
|
-
`[HeadlessPTY] Session ${sessionId} exited: code=${exitCode}, task=${options.taskExecutionId}, branch=${options.branchName}`
|
|
550
|
-
);
|
|
551
|
-
});
|
|
552
|
-
console.log(
|
|
553
|
-
`[HeadlessPTY] Spawned session ${sessionId} for task ${options.taskExecutionId} on branch ${options.branchName}`
|
|
554
|
-
);
|
|
555
|
-
return toSessionInfo(session);
|
|
556
|
-
}
|
|
557
|
-
function getSession(sessionId) {
|
|
558
|
-
const session = headlessSessions.get(sessionId);
|
|
559
|
-
if (!session) return null;
|
|
560
|
-
return toSessionInfo(session);
|
|
561
|
-
}
|
|
562
|
-
function getSessionByTaskExecution(taskExecutionId) {
|
|
563
|
-
for (const session of headlessSessions.values()) {
|
|
564
|
-
if (session.taskExecutionId === taskExecutionId) {
|
|
565
|
-
return toSessionInfo(session);
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
return null;
|
|
569
|
-
}
|
|
570
|
-
function listSessions(status) {
|
|
571
|
-
const results = [];
|
|
572
|
-
for (const session of headlessSessions.values()) {
|
|
573
|
-
if (!status || session.status === status) {
|
|
574
|
-
results.push(toSessionInfo(session));
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
return results;
|
|
578
|
-
}
|
|
579
|
-
function killSession(sessionId) {
|
|
580
|
-
const session = headlessSessions.get(sessionId);
|
|
581
|
-
if (!session || session.status !== "running") return false;
|
|
582
|
-
session.pty.kill();
|
|
583
|
-
session.status = "failed";
|
|
584
|
-
session.exitCode = -1;
|
|
585
|
-
session.completedAt = /* @__PURE__ */ new Date();
|
|
586
|
-
console.log(`[HeadlessPTY] Killed session ${sessionId}`);
|
|
587
|
-
return true;
|
|
588
|
-
}
|
|
589
|
-
function killAllSessions() {
|
|
590
|
-
for (const [id, session] of headlessSessions) {
|
|
591
|
-
if (session.status === "running") {
|
|
592
|
-
session.pty.kill();
|
|
593
|
-
session.status = "failed";
|
|
594
|
-
session.exitCode = -1;
|
|
595
|
-
session.completedAt = /* @__PURE__ */ new Date();
|
|
596
|
-
}
|
|
597
|
-
headlessSessions.delete(id);
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
function toSessionInfo(session) {
|
|
601
|
-
const tailCount = 50;
|
|
602
|
-
return {
|
|
603
|
-
id: session.id,
|
|
604
|
-
taskExecutionId: session.taskExecutionId,
|
|
605
|
-
branchName: session.branchName,
|
|
606
|
-
status: session.status,
|
|
607
|
-
exitCode: session.exitCode,
|
|
608
|
-
startedAt: session.startedAt,
|
|
609
|
-
completedAt: session.completedAt,
|
|
610
|
-
outputTail: session.output.slice(-tailCount)
|
|
611
|
-
};
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// src/index.ts
|
|
615
|
-
var APP_API_URL = process.env.COMPANYOS_APP_URL || "http://localhost:3000";
|
|
616
|
-
var sessions = /* @__PURE__ */ new Map();
|
|
617
|
-
function sendMessage(ws, message) {
|
|
618
|
-
if (ws.readyState === import_ws.WebSocket.OPEN) {
|
|
619
|
-
ws.send(JSON.stringify(message));
|
|
620
|
-
}
|
|
215
|
+
if (msg.type !== 'create') {
|
|
216
|
+
sendMessage(ws, { type: 'error', message: 'First message must be a create message' });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
ws.removeListener('message', onCreate);
|
|
220
|
+
spawnSession(ws, msg);
|
|
221
|
+
});
|
|
222
|
+
ws.on('close', () => {
|
|
223
|
+
console.log('Connection closed before session created');
|
|
224
|
+
});
|
|
225
|
+
ws.on('error', (err) => {
|
|
226
|
+
console.error('WebSocket error before session created:', err);
|
|
227
|
+
});
|
|
621
228
|
}
|
|
622
|
-
function
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
229
|
+
async function handleSessionsRequest(limit, res) {
|
|
230
|
+
try {
|
|
231
|
+
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
|
|
232
|
+
const sessionData = [];
|
|
233
|
+
let dirExists = false;
|
|
234
|
+
try {
|
|
235
|
+
const stat = await fsp.stat(claudeDir);
|
|
236
|
+
dirExists = stat.isDirectory();
|
|
237
|
+
}
|
|
238
|
+
catch { /* directory doesn't exist */ }
|
|
239
|
+
if (dirExists) {
|
|
240
|
+
const projectDirs = await fsp.readdir(claudeDir);
|
|
241
|
+
for (const projectDir of projectDirs) {
|
|
242
|
+
const projectPath = path.join(claudeDir, projectDir);
|
|
243
|
+
const stat = await fsp.stat(projectPath);
|
|
244
|
+
if (!stat.isDirectory())
|
|
245
|
+
continue;
|
|
246
|
+
const files = (await fsp.readdir(projectPath)).filter(f => f.endsWith('.jsonl'));
|
|
247
|
+
for (const file of files) {
|
|
248
|
+
const sessionId = file.replace('.jsonl', '');
|
|
249
|
+
const filePath = path.join(projectPath, file);
|
|
250
|
+
const fileStat = await fsp.stat(filePath);
|
|
251
|
+
let summary = '';
|
|
252
|
+
let firstPrompt = '';
|
|
253
|
+
try {
|
|
254
|
+
const content = await fsp.readFile(filePath, 'utf-8');
|
|
255
|
+
const lines = content.trim().split('\n');
|
|
256
|
+
if (lines.length > 0) {
|
|
257
|
+
const firstLine = JSON.parse(lines[0]);
|
|
258
|
+
if (firstLine.type === 'summary')
|
|
259
|
+
summary = firstLine.summary ?? '';
|
|
260
|
+
if (firstLine.message?.content)
|
|
261
|
+
firstPrompt = String(firstLine.message.content).slice(0, 200);
|
|
262
|
+
}
|
|
263
|
+
if (lines.length > 1) {
|
|
264
|
+
const lastLine = JSON.parse(lines[lines.length - 1]);
|
|
265
|
+
if (lastLine.type === 'summary')
|
|
266
|
+
summary = lastLine.summary ?? summary;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch { /* skip unreadable files */ }
|
|
270
|
+
sessionData.push({
|
|
271
|
+
sessionId,
|
|
272
|
+
summary,
|
|
273
|
+
lastModified: fileStat.mtimeMs,
|
|
274
|
+
createdAt: fileStat.birthtimeMs,
|
|
275
|
+
firstPrompt,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
sessionData.sort((a, b) => b.lastModified - a.lastModified);
|
|
281
|
+
const limited = sessionData.slice(0, limit);
|
|
282
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
283
|
+
res.end(JSON.stringify({ data: limited }));
|
|
670
284
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
const session = sessions.get(sessionId);
|
|
675
|
-
if (session) {
|
|
676
|
-
session.pty.kill();
|
|
677
|
-
sessions.delete(sessionId);
|
|
285
|
+
catch {
|
|
286
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
287
|
+
res.end(JSON.stringify({ data: [] }));
|
|
678
288
|
}
|
|
679
|
-
});
|
|
680
|
-
ws.on("error", (err) => {
|
|
681
|
-
console.error(`WebSocket error for session ${sessionId}:`, err);
|
|
682
|
-
});
|
|
683
289
|
}
|
|
684
290
|
function handleHttpRequest(req, res) {
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
res.
|
|
694
|
-
res.
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
if (!name) {
|
|
716
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
717
|
-
res.end(JSON.stringify({ error: "name is required" }));
|
|
718
|
-
return;
|
|
719
|
-
}
|
|
720
|
-
const upstream = `${APP_API_URL}/api/workflows/${encodeURIComponent(name)}/run`;
|
|
721
|
-
proxyPost(upstream, res, req.headers.cookie);
|
|
722
|
-
return;
|
|
723
|
-
}
|
|
724
|
-
const executionRouteMatch = url.pathname.match(/^\/api\/execution\/([^/]+)\/(start|pause|resume|cancel)$/);
|
|
725
|
-
if (executionRouteMatch && req.method === "POST") {
|
|
726
|
-
const executionId = executionRouteMatch[1];
|
|
727
|
-
const action = executionRouteMatch[2];
|
|
728
|
-
const upstream = `${APP_API_URL}/api/execution/${encodeURIComponent(executionId)}/${action}`;
|
|
729
|
-
proxyPost(upstream, res, req.headers.cookie);
|
|
730
|
-
return;
|
|
731
|
-
}
|
|
732
|
-
const executionHealthMatch = url.pathname.match(/^\/api\/execution\/([^/]+)\/(health|log)$/);
|
|
733
|
-
if (executionHealthMatch && req.method === "GET") {
|
|
734
|
-
const executionId = executionHealthMatch[1];
|
|
735
|
-
const endpoint = executionHealthMatch[2];
|
|
736
|
-
const query = url.search || "";
|
|
737
|
-
const upstream = `${APP_API_URL}/api/execution/${encodeURIComponent(executionId)}/${endpoint}${query}`;
|
|
738
|
-
proxyGet(upstream, res, req.headers.cookie);
|
|
739
|
-
return;
|
|
740
|
-
}
|
|
741
|
-
const executionStatusMatch = url.pathname.match(/^\/api\/execution\/([^/]+)$/);
|
|
742
|
-
if (executionStatusMatch && req.method === "GET") {
|
|
743
|
-
const executionId = executionStatusMatch[1];
|
|
744
|
-
const upstream = `${APP_API_URL}/api/execution/${encodeURIComponent(executionId)}`;
|
|
745
|
-
proxyGet(upstream, res, req.headers.cookie);
|
|
746
|
-
return;
|
|
747
|
-
}
|
|
748
|
-
if (url.pathname === "/api/headless/spawn" && req.method === "POST") {
|
|
749
|
-
readBody(req).then((body) => {
|
|
750
|
-
try {
|
|
751
|
-
const data = JSON.parse(body);
|
|
752
|
-
if (!data.taskExecutionId || !data.branchName || !data.prompt) {
|
|
753
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
754
|
-
res.end(JSON.stringify({ error: "taskExecutionId, branchName, and prompt are required" }));
|
|
755
|
-
return;
|
|
291
|
+
const reqOrigin = req.headers.origin;
|
|
292
|
+
if (reqOrigin && SERVER_CONFIG.allowedOrigins.includes(reqOrigin)) {
|
|
293
|
+
res.setHeader('Access-Control-Allow-Origin', reqOrigin);
|
|
294
|
+
}
|
|
295
|
+
else if (!reqOrigin || reqOrigin === 'null') {
|
|
296
|
+
// Electron file:// origin sends 'null' — allow since token auth is the security gate
|
|
297
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
298
|
+
}
|
|
299
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
300
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
301
|
+
if (req.method === 'OPTIONS') {
|
|
302
|
+
res.writeHead(204);
|
|
303
|
+
res.end();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (req.url === '/health') {
|
|
307
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
308
|
+
res.end(JSON.stringify({
|
|
309
|
+
status: 'ok',
|
|
310
|
+
sessions: sessions.size,
|
|
311
|
+
maxSessions: SERVER_CONFIG.maxSessions,
|
|
312
|
+
}));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
// Check if claude CLI is available (mirrors API's /api/terminal/status)
|
|
316
|
+
if (req.url === '/status') {
|
|
317
|
+
try {
|
|
318
|
+
execFileSync('which', ['claude'], { encoding: 'utf-8' });
|
|
319
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
320
|
+
res.end(JSON.stringify({ available: true }));
|
|
756
321
|
}
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
322
|
+
catch {
|
|
323
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
324
|
+
res.end(JSON.stringify({ available: false, message: 'claude CLI not found in PATH' }));
|
|
325
|
+
}
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
// List Claude Code sessions from ~/.claude/projects/ (mirrors API's /api/terminal/sessions)
|
|
329
|
+
if (req.url?.startsWith('/sessions')) {
|
|
330
|
+
const reqUrl = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
|
|
331
|
+
const limit = parseInt(reqUrl.searchParams.get('limit') ?? '50', 10);
|
|
332
|
+
handleSessionsRequest(limit, res);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (req.url === '/clis') {
|
|
336
|
+
const clis = detectClis();
|
|
337
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
338
|
+
res.end(JSON.stringify({ data: clis }));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
342
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
343
|
+
}
|
|
344
|
+
const MAX_PORT_RETRIES = 10;
|
|
345
|
+
async function tryListen(httpServer, port) {
|
|
346
|
+
return new Promise((resolve, reject) => {
|
|
347
|
+
const onError = (err) => {
|
|
348
|
+
httpServer.removeListener('error', onError);
|
|
349
|
+
reject(err);
|
|
350
|
+
};
|
|
351
|
+
httpServer.on('error', onError);
|
|
352
|
+
httpServer.listen(port, '127.0.0.1', () => {
|
|
353
|
+
httpServer.removeListener('error', onError);
|
|
354
|
+
resolve(port);
|
|
762
355
|
});
|
|
763
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
764
|
-
res.end(JSON.stringify(session));
|
|
765
|
-
} catch (err) {
|
|
766
|
-
const msg = err instanceof Error ? err.message : "Spawn failed";
|
|
767
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
768
|
-
res.end(JSON.stringify({ error: msg }));
|
|
769
|
-
}
|
|
770
|
-
}).catch(() => {
|
|
771
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
772
|
-
res.end(JSON.stringify({ error: "Invalid request body" }));
|
|
773
356
|
});
|
|
774
|
-
return;
|
|
775
|
-
}
|
|
776
|
-
if (url.pathname === "/api/headless/sessions" && req.method === "GET") {
|
|
777
|
-
const status = url.searchParams.get("status");
|
|
778
|
-
const sessions2 = listSessions(status ?? void 0);
|
|
779
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
780
|
-
res.end(JSON.stringify({ sessions: sessions2, runningCount: getRunningSessionCount() }));
|
|
781
|
-
return;
|
|
782
|
-
}
|
|
783
|
-
const headlessSessionMatch = url.pathname.match(/^\/api\/headless\/session\/([^/]+)$/);
|
|
784
|
-
if (headlessSessionMatch && req.method === "GET") {
|
|
785
|
-
const sessionId = headlessSessionMatch[1];
|
|
786
|
-
const session = getSession(sessionId);
|
|
787
|
-
if (!session) {
|
|
788
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
789
|
-
res.end(JSON.stringify({ error: "Session not found" }));
|
|
790
|
-
return;
|
|
791
|
-
}
|
|
792
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
793
|
-
res.end(JSON.stringify(session));
|
|
794
|
-
return;
|
|
795
|
-
}
|
|
796
|
-
const headlessTaskMatch = url.pathname.match(/^\/api\/headless\/task\/([^/]+)$/);
|
|
797
|
-
if (headlessTaskMatch && req.method === "GET") {
|
|
798
|
-
const taskExecId = headlessTaskMatch[1];
|
|
799
|
-
const session = getSessionByTaskExecution(taskExecId);
|
|
800
|
-
if (!session) {
|
|
801
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
802
|
-
res.end(JSON.stringify({ error: "No session found for task execution" }));
|
|
803
|
-
return;
|
|
804
|
-
}
|
|
805
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
806
|
-
res.end(JSON.stringify(session));
|
|
807
|
-
return;
|
|
808
|
-
}
|
|
809
|
-
const headlessKillMatch = url.pathname.match(/^\/api\/headless\/session\/([^/]+)\/kill$/);
|
|
810
|
-
if (headlessKillMatch && req.method === "POST") {
|
|
811
|
-
const sessionId = headlessKillMatch[1];
|
|
812
|
-
const killed = killSession(sessionId);
|
|
813
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
814
|
-
res.end(JSON.stringify({ killed }));
|
|
815
|
-
return;
|
|
816
|
-
}
|
|
817
|
-
if (url.pathname === "/api/headless/capacity" && req.method === "GET") {
|
|
818
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
819
|
-
res.end(JSON.stringify({
|
|
820
|
-
canSpawn: canSpawnSession(),
|
|
821
|
-
runningCount: getRunningSessionCount()
|
|
822
|
-
}));
|
|
823
|
-
return;
|
|
824
|
-
}
|
|
825
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
826
|
-
res.end(JSON.stringify({ error: "Not found" }));
|
|
827
|
-
}
|
|
828
|
-
function readBody(req) {
|
|
829
|
-
return new Promise((resolve, reject) => {
|
|
830
|
-
const chunks = [];
|
|
831
|
-
req.on("data", (chunk) => chunks.push(chunk));
|
|
832
|
-
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
833
|
-
req.on("error", reject);
|
|
834
|
-
});
|
|
835
|
-
}
|
|
836
|
-
function buildProxyHeaders(cookie) {
|
|
837
|
-
const headers = {};
|
|
838
|
-
if (cookie) headers["cookie"] = cookie;
|
|
839
|
-
return headers;
|
|
840
|
-
}
|
|
841
|
-
function forwardSetCookies(upstreamRes, res) {
|
|
842
|
-
const setCookies = upstreamRes.headers.getSetCookie?.();
|
|
843
|
-
if (setCookies) {
|
|
844
|
-
for (const c of setCookies) {
|
|
845
|
-
res.appendHeader("Set-Cookie", c);
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
function proxyGet(upstream, res, cookie) {
|
|
850
|
-
fetch(upstream, { headers: buildProxyHeaders(cookie) }).then(async (upstreamRes) => {
|
|
851
|
-
const body = await upstreamRes.text();
|
|
852
|
-
forwardSetCookies(upstreamRes, res);
|
|
853
|
-
res.writeHead(upstreamRes.status, { "Content-Type": "application/json" });
|
|
854
|
-
res.end(body);
|
|
855
|
-
}).catch((err) => {
|
|
856
|
-
console.error("Proxy GET error:", err);
|
|
857
|
-
res.writeHead(502, { "Content-Type": "application/json" });
|
|
858
|
-
res.end(JSON.stringify({ error: "Upstream request failed" }));
|
|
859
|
-
});
|
|
860
|
-
}
|
|
861
|
-
function proxyPost(upstream, res, cookie) {
|
|
862
|
-
fetch(upstream, { method: "POST", headers: buildProxyHeaders(cookie) }).then(async (upstreamRes) => {
|
|
863
|
-
const body = await upstreamRes.text();
|
|
864
|
-
forwardSetCookies(upstreamRes, res);
|
|
865
|
-
res.writeHead(upstreamRes.status, { "Content-Type": "application/json" });
|
|
866
|
-
res.end(body);
|
|
867
|
-
}).catch((err) => {
|
|
868
|
-
console.error("Proxy POST error:", err);
|
|
869
|
-
res.writeHead(502, { "Content-Type": "application/json" });
|
|
870
|
-
res.end(JSON.stringify({ error: "Upstream request failed" }));
|
|
871
|
-
});
|
|
872
357
|
}
|
|
873
358
|
async function startServer() {
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
359
|
+
const basePort = SERVER_CONFIG.defaultPort;
|
|
360
|
+
const httpServer = http.createServer(handleHttpRequest);
|
|
361
|
+
// Try ports starting from basePort, auto-increment on EADDRINUSE
|
|
362
|
+
let actualPort = basePort;
|
|
363
|
+
for (let attempt = 0; attempt < MAX_PORT_RETRIES; attempt++) {
|
|
364
|
+
const port = basePort + attempt;
|
|
365
|
+
try {
|
|
366
|
+
actualPort = await tryListen(httpServer, port);
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
const nodeErr = err;
|
|
371
|
+
if (nodeErr.code === 'EADDRINUSE') {
|
|
372
|
+
if (attempt === 0) {
|
|
373
|
+
console.warn(` Port ${port} is in use, trying next...`);
|
|
374
|
+
}
|
|
375
|
+
if (attempt === MAX_PORT_RETRIES - 1) {
|
|
376
|
+
console.error(`\n Error: All ports ${basePort}-${basePort + MAX_PORT_RETRIES - 1} are in use.\n`);
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
throw err;
|
|
382
|
+
}
|
|
889
383
|
}
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
384
|
+
// Create WebSocket server AFTER port is bound (avoids WSS emitting EADDRINUSE)
|
|
385
|
+
const wss = new WebSocketServer({
|
|
386
|
+
server: httpServer,
|
|
387
|
+
verifyClient: (info, cb) => {
|
|
388
|
+
const origin = info.origin;
|
|
389
|
+
if (origin && !SERVER_CONFIG.allowedOrigins.includes(origin)) {
|
|
390
|
+
console.warn(`Rejected connection from origin: ${origin}`);
|
|
391
|
+
cb(false, 403, 'Origin not allowed');
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (sessions.size >= SERVER_CONFIG.maxSessions) {
|
|
395
|
+
console.warn(`Rejected connection: max sessions (${SERVER_CONFIG.maxSessions}) reached`);
|
|
396
|
+
cb(false, 503, 'Max sessions reached');
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (authConfig.token) {
|
|
400
|
+
const provided = extractTokenFromRequest(info.req);
|
|
401
|
+
if (!provided || !verifyToken(provided, authConfig.token)) {
|
|
402
|
+
console.warn('Rejected connection: invalid or missing token');
|
|
403
|
+
cb(false, 401, 'Unauthorized');
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
cb(true);
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
wss.on('connection', (ws) => {
|
|
411
|
+
handleConnection(ws);
|
|
412
|
+
});
|
|
413
|
+
console.log('');
|
|
414
|
+
console.log(' CompanyOS Terminal Server');
|
|
415
|
+
console.log(' ────────────────────────');
|
|
416
|
+
console.log(` Local: http://localhost:${actualPort}`);
|
|
417
|
+
console.log(` WebSocket: ws://localhost:${actualPort}`);
|
|
418
|
+
if (actualPort !== basePort) {
|
|
419
|
+
console.log(` Note: Default port ${basePort} was in use, using ${actualPort} instead`);
|
|
420
|
+
}
|
|
421
|
+
console.log(` Auth: ${authConfig.token ? 'enabled (token required)' : 'disabled (no token set)'}`);
|
|
422
|
+
console.log(` Max sessions: ${SERVER_CONFIG.maxSessions}`);
|
|
423
|
+
console.log(` Idle timeout: ${SERVER_CONFIG.idleTimeoutMs / 1000}s`);
|
|
424
|
+
console.log('');
|
|
425
|
+
if (!authConfig.token) {
|
|
426
|
+
console.warn(' ⚠ TERMINAL_SERVER_TOKEN not set — connections are unauthenticated');
|
|
427
|
+
}
|
|
428
|
+
console.log(' Allowed origins:');
|
|
901
429
|
for (const origin of SERVER_CONFIG.allowedOrigins) {
|
|
902
|
-
|
|
903
|
-
}
|
|
904
|
-
console.log(
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
430
|
+
console.log(` ${origin}`);
|
|
431
|
+
}
|
|
432
|
+
console.log('');
|
|
433
|
+
process.on('SIGINT', () => {
|
|
434
|
+
console.log('\nShutting down terminal server...');
|
|
435
|
+
for (const [sessionId, session] of sessions) {
|
|
436
|
+
console.log(`Killing session: ${sessionId}`);
|
|
437
|
+
clearIdleTimer(session);
|
|
438
|
+
session.pty.kill();
|
|
439
|
+
session.ws.close();
|
|
440
|
+
}
|
|
441
|
+
wss.close();
|
|
442
|
+
httpServer.close(() => {
|
|
443
|
+
console.log('Terminal server stopped');
|
|
444
|
+
process.exit(0);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
process.on('SIGTERM', () => {
|
|
448
|
+
process.emit('SIGINT');
|
|
920
449
|
});
|
|
921
|
-
});
|
|
922
|
-
process.on("SIGTERM", () => {
|
|
923
|
-
process.emit("SIGINT");
|
|
924
|
-
});
|
|
925
450
|
}
|
|
926
451
|
startServer().catch((err) => {
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
});
|
|
930
|
-
var DEFAULT_PORT = SERVER_CONFIG.defaultPort;
|
|
931
|
-
// Annotate the CommonJS export names for ESM import in node:
|
|
932
|
-
0 && (module.exports = {
|
|
933
|
-
DEFAULT_PORT,
|
|
934
|
-
SERVER_CONFIG
|
|
452
|
+
console.error('Failed to start terminal server:', err);
|
|
453
|
+
process.exit(1);
|
|
935
454
|
});
|
|
455
|
+
export { SERVER_CONFIG };
|
|
456
|
+
export { buildSpawnConfig } from './spawn-config.js';
|
|
457
|
+
//# sourceMappingURL=index.js.map
|