@company-os/terminal-server 1.0.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 +60 -0
- package/dist/index.js +935 -0
- package/package.json +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# @company-os/terminal-server
|
|
2
|
+
|
|
3
|
+
Local terminal server for [CompanyOS](https://app.company-os.ai). Run it on your machine to get a live terminal alongside the chat panel in your browser.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @company-os/terminal-server
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Then open [app.company-os.ai](https://app.company-os.ai), click the terminal toggle in chat, and click **Reconnect**.
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- **Node.js 18+**
|
|
16
|
+
- **C++ build toolchain** (required by `node-pty`):
|
|
17
|
+
- macOS: Xcode Command Line Tools (`xcode-select --install`)
|
|
18
|
+
- Windows: Visual Studio Build Tools (`npm install -g windows-build-tools`)
|
|
19
|
+
- Linux: `build-essential` (`sudo apt install build-essential`)
|
|
20
|
+
|
|
21
|
+
## How it works
|
|
22
|
+
|
|
23
|
+
The server runs on `localhost:3002` and exposes a WebSocket endpoint. Your browser connects to `ws://localhost:3002` directly — CompanyOS never proxies or receives your shell access.
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
Browser (app.company-os.ai) ──ws://localhost:3002──► terminal-server (your machine)
|
|
27
|
+
│
|
|
28
|
+
▼
|
|
29
|
+
PTY (shell)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
| Environment variable | Default | Description |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `TERMINAL_SERVER_PORT` | `3002` | Port to listen on |
|
|
37
|
+
| `ALLOWED_ORIGINS` | `http://localhost:3000,http://localhost:3002,https://app.company-os.ai` | Comma-separated allowed origins |
|
|
38
|
+
| `COMPANYOS_WORKSPACE_ROOT` | Current directory | Working directory for new shells |
|
|
39
|
+
|
|
40
|
+
## Troubleshooting
|
|
41
|
+
|
|
42
|
+
**Port 3002 is in use**
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
TERMINAL_SERVER_PORT=3003 npx @company-os/terminal-server
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**node-pty fails to install**
|
|
49
|
+
|
|
50
|
+
You're missing the C++ build toolchain. See Requirements above.
|
|
51
|
+
|
|
52
|
+
**Browser says "Connect your terminal" but server is running**
|
|
53
|
+
|
|
54
|
+
Check the server output — it prints the port it's listening on. If it's not 3002, the browser won't find it (it defaults to 3002).
|
|
55
|
+
|
|
56
|
+
## Security model
|
|
57
|
+
|
|
58
|
+
- Binds to **localhost only** — not accessible from the network
|
|
59
|
+
- **Origin allowlist** — only connections from listed origins are accepted; missing origins are rejected
|
|
60
|
+
- Your shell runs on your machine; CompanyOS servers never see your terminal traffic
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,935 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __export = (target, all) => {
|
|
10
|
+
for (var name in all)
|
|
11
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
12
|
+
};
|
|
13
|
+
var __copyProps = (to, from, except, desc) => {
|
|
14
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
15
|
+
for (let key of __getOwnPropNames(from))
|
|
16
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
17
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
18
|
+
}
|
|
19
|
+
return to;
|
|
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 } };
|
|
120
|
+
}
|
|
121
|
+
function validateResizeMessage(msg) {
|
|
122
|
+
if (typeof msg.cols !== "number" || typeof msg.rows !== "number") {
|
|
123
|
+
return { valid: false, error: "Resize requires numeric cols and rows" };
|
|
124
|
+
}
|
|
125
|
+
if (!Number.isInteger(msg.cols) || !Number.isInteger(msg.rows)) {
|
|
126
|
+
return { valid: false, error: "cols and rows must be integers" };
|
|
127
|
+
}
|
|
128
|
+
if (msg.cols < VALIDATION_LIMITS.minCols || msg.cols > VALIDATION_LIMITS.maxCols || msg.rows < VALIDATION_LIMITS.minRows || msg.rows > VALIDATION_LIMITS.maxRows) {
|
|
129
|
+
return {
|
|
130
|
+
valid: false,
|
|
131
|
+
error: `Dimensions out of range: cols ${VALIDATION_LIMITS.minCols}-${VALIDATION_LIMITS.maxCols}, rows ${VALIDATION_LIMITS.minRows}-${VALIDATION_LIMITS.maxRows}`
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return { valid: true, message: { type: "resize", cols: msg.cols, rows: msg.rows } };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// src/terminalPage.ts
|
|
138
|
+
function renderTerminalPage() {
|
|
139
|
+
return `<!DOCTYPE html>
|
|
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;
|
|
168
|
+
}
|
|
169
|
+
#header-title {
|
|
170
|
+
font-size: 14px;
|
|
171
|
+
font-weight: 600;
|
|
172
|
+
color: #e0e0e0;
|
|
173
|
+
overflow: hidden;
|
|
174
|
+
text-overflow: ellipsis;
|
|
175
|
+
white-space: nowrap;
|
|
176
|
+
max-width: 60vw;
|
|
177
|
+
}
|
|
178
|
+
#header-error {
|
|
179
|
+
font-size: 12px;
|
|
180
|
+
color: #f44336;
|
|
181
|
+
display: none;
|
|
182
|
+
}
|
|
183
|
+
#copy-btn {
|
|
184
|
+
background: none;
|
|
185
|
+
border: 1px solid #555;
|
|
186
|
+
color: #ccc;
|
|
187
|
+
padding: 6px 12px;
|
|
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;
|
|
217
|
+
}
|
|
218
|
+
#toast.show {
|
|
219
|
+
opacity: 1;
|
|
220
|
+
transform: translateX(-50%) translateY(0);
|
|
221
|
+
}
|
|
222
|
+
</style>
|
|
223
|
+
</head>
|
|
224
|
+
<body>
|
|
225
|
+
<div id="header">
|
|
226
|
+
<div id="header-left">
|
|
227
|
+
<span id="header-title">Terminal</span>
|
|
228
|
+
<span id="header-error">(Failed to load task)</span>
|
|
229
|
+
</div>
|
|
230
|
+
<button id="copy-btn" title="Copy terminal contents">
|
|
231
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
232
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
233
|
+
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
|
|
234
|
+
</svg>
|
|
235
|
+
Copy
|
|
236
|
+
</button>
|
|
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
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// --- Parse query params ---
|
|
283
|
+
var params = new URLSearchParams(window.location.search);
|
|
284
|
+
var taskId = params.get('taskId');
|
|
285
|
+
var workflowName = params.get('workflow');
|
|
286
|
+
var dryRun = params.get('dryRun') === 'true';
|
|
287
|
+
var themeName = params.get('theme') || 'homebrew';
|
|
288
|
+
var autoExecute = params.get('autoExecute') !== 'false';
|
|
289
|
+
var skipPermissions = params.get('skipPermissions') === 'true';
|
|
290
|
+
|
|
291
|
+
var theme = THEMES[themeName] || THEMES.homebrew;
|
|
292
|
+
|
|
293
|
+
// Apply background to body and terminal container
|
|
294
|
+
document.body.style.background = theme.background;
|
|
295
|
+
document.getElementById('terminal-container').style.background = theme.background;
|
|
296
|
+
|
|
297
|
+
// --- Create xterm Terminal ---
|
|
298
|
+
var term = new window.Terminal({
|
|
299
|
+
theme: theme,
|
|
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,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
var fitAddon = new window.FitAddon.FitAddon();
|
|
309
|
+
var webLinksAddon = new window.WebLinksAddon.WebLinksAddon();
|
|
310
|
+
|
|
311
|
+
term.loadAddon(fitAddon);
|
|
312
|
+
term.loadAddon(webLinksAddon);
|
|
313
|
+
|
|
314
|
+
var container = document.getElementById('terminal-container');
|
|
315
|
+
term.open(container);
|
|
316
|
+
fitAddon.fit();
|
|
317
|
+
|
|
318
|
+
// Auto-fit on resize
|
|
319
|
+
var resizeObserver = new ResizeObserver(function() {
|
|
320
|
+
try { fitAddon.fit(); } catch(e) { /* ignore */ }
|
|
321
|
+
});
|
|
322
|
+
resizeObserver.observe(container);
|
|
323
|
+
|
|
324
|
+
// --- Toast helper ---
|
|
325
|
+
function showToast(msg) {
|
|
326
|
+
var toast = document.getElementById('toast');
|
|
327
|
+
toast.textContent = msg;
|
|
328
|
+
toast.classList.add('show');
|
|
329
|
+
setTimeout(function() { toast.classList.remove('show'); }, 3000);
|
|
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
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// --- WebSocket connection ---
|
|
349
|
+
var wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
350
|
+
var wsUrl = wsProtocol + '//' + window.location.host;
|
|
351
|
+
var ws = null;
|
|
352
|
+
var commandInjected = false;
|
|
353
|
+
|
|
354
|
+
function connectWs() {
|
|
355
|
+
ws = new WebSocket(wsUrl);
|
|
356
|
+
|
|
357
|
+
ws.onopen = function() {
|
|
358
|
+
updateConnectionStatus(true);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
ws.onmessage = function(event) {
|
|
362
|
+
try {
|
|
363
|
+
var msg = JSON.parse(event.data);
|
|
364
|
+
switch (msg.type) {
|
|
365
|
+
case 'session':
|
|
366
|
+
onSessionReady(msg.sessionId);
|
|
367
|
+
break;
|
|
368
|
+
case 'output':
|
|
369
|
+
term.write(msg.data);
|
|
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);
|
|
380
|
+
}
|
|
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
|
+
});
|
|
405
|
+
|
|
406
|
+
term.onResize(function(size) {
|
|
407
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
408
|
+
ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// --- Fetch brief and inject command ---
|
|
413
|
+
function onSessionReady(sessionId) {
|
|
414
|
+
// Send initial resize
|
|
415
|
+
var dims = fitAddon.proposeDimensions();
|
|
416
|
+
if (dims && ws && ws.readyState === WebSocket.OPEN) {
|
|
417
|
+
ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (commandInjected) return;
|
|
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 ');
|
|
471
|
+
}
|
|
472
|
+
var isTask = brief.taskId !== undefined;
|
|
473
|
+
var shouldAutoExecute = isTask ? autoExecute : true;
|
|
474
|
+
var input = shouldAutoExecute ? command + '\\n' : command;
|
|
475
|
+
|
|
476
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
477
|
+
ws.send(JSON.stringify({ type: 'input', data: input }));
|
|
478
|
+
}
|
|
479
|
+
}, 500);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// --- Start connection ---
|
|
483
|
+
connectWs();
|
|
484
|
+
})();
|
|
485
|
+
</script>
|
|
486
|
+
</body>
|
|
487
|
+
</html>`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// src/headlessPtyManager.ts
|
|
491
|
+
var pty = __toESM(require("node-pty"));
|
|
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
|
+
}
|
|
621
|
+
}
|
|
622
|
+
function handleConnection(ws, origin) {
|
|
623
|
+
if (!origin || !SERVER_CONFIG.allowedOrigins.includes(origin)) {
|
|
624
|
+
console.warn(`Rejected connection from origin: ${origin ?? "(none)"}`);
|
|
625
|
+
ws.close(1008, "Origin not allowed");
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const sessionId = generateSessionId();
|
|
629
|
+
console.log(`New terminal session: ${sessionId}`);
|
|
630
|
+
const ptyProcess = pty2.spawn(getDefaultShell(), [], {
|
|
631
|
+
name: PTY_CONFIG.termName,
|
|
632
|
+
cols: PTY_CONFIG.cols,
|
|
633
|
+
rows: PTY_CONFIG.rows,
|
|
634
|
+
cwd: getWorkingDirectory(),
|
|
635
|
+
env: {
|
|
636
|
+
...process.env,
|
|
637
|
+
TERM: PTY_CONFIG.termName,
|
|
638
|
+
COLORTERM: "truecolor"
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
sessions.set(sessionId, { pty: ptyProcess, ws });
|
|
642
|
+
sendMessage(ws, { type: "session", sessionId });
|
|
643
|
+
ptyProcess.onData((data) => {
|
|
644
|
+
sendMessage(ws, { type: "output", data });
|
|
645
|
+
});
|
|
646
|
+
ptyProcess.onExit(({ exitCode, signal }) => {
|
|
647
|
+
console.log(`PTY exited: sessionId=${sessionId}, exitCode=${exitCode}, signal=${signal}`);
|
|
648
|
+
sendMessage(ws, { type: "exit", exitCode, signal });
|
|
649
|
+
ws.close();
|
|
650
|
+
sessions.delete(sessionId);
|
|
651
|
+
});
|
|
652
|
+
ws.on("message", (rawMessage) => {
|
|
653
|
+
const result = validateClientMessage(rawMessage.toString());
|
|
654
|
+
if (!result.valid) {
|
|
655
|
+
console.warn(`Invalid message from ${sessionId}: ${result.error}`);
|
|
656
|
+
sendMessage(ws, { type: "error", message: result.error || "Invalid message" });
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
const msg = result.message;
|
|
660
|
+
switch (msg.type) {
|
|
661
|
+
case "input":
|
|
662
|
+
ptyProcess.write(msg.data);
|
|
663
|
+
break;
|
|
664
|
+
case "resize":
|
|
665
|
+
ptyProcess.resize(msg.cols, msg.rows);
|
|
666
|
+
break;
|
|
667
|
+
case "ping":
|
|
668
|
+
sendMessage(ws, { type: "pong" });
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
ws.on("close", () => {
|
|
673
|
+
console.log(`Session closed: ${sessionId}`);
|
|
674
|
+
const session = sessions.get(sessionId);
|
|
675
|
+
if (session) {
|
|
676
|
+
session.pty.kill();
|
|
677
|
+
sessions.delete(sessionId);
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
ws.on("error", (err) => {
|
|
681
|
+
console.error(`WebSocket error for session ${sessionId}:`, err);
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
function handleHttpRequest(req, res) {
|
|
685
|
+
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
686
|
+
const reqOrigin = req.headers.origin;
|
|
687
|
+
if (reqOrigin && SERVER_CONFIG.allowedOrigins.includes(reqOrigin)) {
|
|
688
|
+
res.setHeader("Access-Control-Allow-Origin", reqOrigin);
|
|
689
|
+
}
|
|
690
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
691
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
692
|
+
if (req.method === "OPTIONS") {
|
|
693
|
+
res.writeHead(204);
|
|
694
|
+
res.end();
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (url.pathname === "/terminal" && req.method === "GET") {
|
|
698
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
699
|
+
res.end(renderTerminalPage());
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (url.pathname === "/api/brief" && req.method === "GET") {
|
|
703
|
+
const taskId = url.searchParams.get("taskId");
|
|
704
|
+
if (!taskId) {
|
|
705
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
706
|
+
res.end(JSON.stringify({ error: "taskId is required" }));
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const upstream = `${APP_API_URL}/api/tasks/${encodeURIComponent(taskId)}/brief`;
|
|
710
|
+
proxyGet(upstream, res, req.headers.cookie);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (url.pathname === "/api/workflow" && req.method === "GET") {
|
|
714
|
+
const name = url.searchParams.get("name");
|
|
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;
|
|
756
|
+
}
|
|
757
|
+
const session = spawnHeadlessSession({
|
|
758
|
+
taskExecutionId: data.taskExecutionId,
|
|
759
|
+
branchName: data.branchName,
|
|
760
|
+
prompt: data.prompt,
|
|
761
|
+
cwd: data.cwd
|
|
762
|
+
});
|
|
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
|
+
});
|
|
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
|
+
}
|
|
873
|
+
async function startServer() {
|
|
874
|
+
const PORT = SERVER_CONFIG.defaultPort;
|
|
875
|
+
const httpServer = http.createServer(handleHttpRequest);
|
|
876
|
+
const wss = new import_ws.WebSocketServer({ server: httpServer });
|
|
877
|
+
wss.on("connection", (ws, req) => {
|
|
878
|
+
handleConnection(ws, req.headers.origin);
|
|
879
|
+
});
|
|
880
|
+
httpServer.on("error", (err) => {
|
|
881
|
+
if (err.code === "EADDRINUSE") {
|
|
882
|
+
console.error("");
|
|
883
|
+
console.error(` Error: Port ${PORT} is already in use.`);
|
|
884
|
+
console.error("");
|
|
885
|
+
console.error(` Fix: stop the other process on port ${PORT}, or run with a custom port:`);
|
|
886
|
+
console.error(` TERMINAL_SERVER_PORT=3003 npx @company-os/terminal-server`);
|
|
887
|
+
console.error("");
|
|
888
|
+
process.exit(1);
|
|
889
|
+
}
|
|
890
|
+
throw err;
|
|
891
|
+
});
|
|
892
|
+
httpServer.listen(PORT, "127.0.0.1", () => {
|
|
893
|
+
console.log("");
|
|
894
|
+
console.log(` CompanyOS Terminal Server`);
|
|
895
|
+
console.log(` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`);
|
|
896
|
+
console.log(` Local: http://localhost:${PORT}`);
|
|
897
|
+
console.log(` WebSocket: ws://localhost:${PORT}`);
|
|
898
|
+
console.log(` Terminal: http://localhost:${PORT}/terminal`);
|
|
899
|
+
console.log("");
|
|
900
|
+
console.log(` Allowed origins:`);
|
|
901
|
+
for (const origin of SERVER_CONFIG.allowedOrigins) {
|
|
902
|
+
console.log(` \u2022 ${origin}`);
|
|
903
|
+
}
|
|
904
|
+
console.log("");
|
|
905
|
+
console.log(` Open app.company-os.ai and toggle the terminal in chat.`);
|
|
906
|
+
console.log("");
|
|
907
|
+
});
|
|
908
|
+
process.on("SIGINT", () => {
|
|
909
|
+
console.log("\nShutting down terminal server...");
|
|
910
|
+
killAllSessions();
|
|
911
|
+
for (const [sessionId, session] of sessions) {
|
|
912
|
+
console.log(`Killing session: ${sessionId}`);
|
|
913
|
+
session.pty.kill();
|
|
914
|
+
session.ws.close();
|
|
915
|
+
}
|
|
916
|
+
wss.close();
|
|
917
|
+
httpServer.close(() => {
|
|
918
|
+
console.log("Terminal server stopped");
|
|
919
|
+
process.exit(0);
|
|
920
|
+
});
|
|
921
|
+
});
|
|
922
|
+
process.on("SIGTERM", () => {
|
|
923
|
+
process.emit("SIGINT");
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
startServer().catch((err) => {
|
|
927
|
+
console.error("Failed to start terminal server:", err);
|
|
928
|
+
process.exit(1);
|
|
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
|
|
935
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@company-os/terminal-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Local terminal server for CompanyOS — run on your machine, connect from app.company-os.ai",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"companyos-terminal": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsup",
|
|
19
|
+
"dev": "tsx src/index.ts",
|
|
20
|
+
"start": "node dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"node-pty": "^1.0.0",
|
|
24
|
+
"ws": "^8.18.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@companyos/typescript-config": "*",
|
|
28
|
+
"@types/node": "^20",
|
|
29
|
+
"@types/ws": "^8.5.13",
|
|
30
|
+
"tsup": "^8.4.0",
|
|
31
|
+
"tsx": "^4.19.2",
|
|
32
|
+
"typescript": "5.9.3"
|
|
33
|
+
}
|
|
34
|
+
}
|