@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.
Files changed (42) hide show
  1. package/README.md +33 -47
  2. package/dist/__tests__/auth.test.d.ts +2 -0
  3. package/dist/__tests__/auth.test.d.ts.map +1 -0
  4. package/dist/__tests__/auth.test.js +61 -0
  5. package/dist/__tests__/auth.test.js.map +1 -0
  6. package/dist/__tests__/spawn-config.test.d.ts +2 -0
  7. package/dist/__tests__/spawn-config.test.d.ts.map +1 -0
  8. package/dist/__tests__/spawn-config.test.js +79 -0
  9. package/dist/__tests__/spawn-config.test.js.map +1 -0
  10. package/dist/__tests__/validation.test.d.ts +2 -0
  11. package/dist/__tests__/validation.test.d.ts.map +1 -0
  12. package/dist/__tests__/validation.test.js +102 -0
  13. package/dist/__tests__/validation.test.js.map +1 -0
  14. package/dist/auth.d.ts +8 -0
  15. package/dist/auth.d.ts.map +1 -0
  16. package/dist/auth.js +21 -0
  17. package/dist/auth.js.map +1 -0
  18. package/dist/config.d.ts +25 -0
  19. package/dist/config.d.ts.map +1 -0
  20. package/dist/config.js +77 -0
  21. package/dist/config.js.map +1 -0
  22. package/dist/index.d.ts +7 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +426 -904
  25. package/dist/index.js.map +1 -0
  26. package/dist/spawn-config.d.ts +16 -0
  27. package/dist/spawn-config.d.ts.map +1 -0
  28. package/dist/spawn-config.js +14 -0
  29. package/dist/spawn-config.js.map +1 -0
  30. package/dist/transcript-sync.d.ts +24 -0
  31. package/dist/transcript-sync.d.ts.map +1 -0
  32. package/dist/transcript-sync.js +176 -0
  33. package/dist/transcript-sync.js.map +1 -0
  34. package/dist/types.d.ts +38 -0
  35. package/dist/types.d.ts.map +1 -0
  36. package/dist/types.js +2 -0
  37. package/dist/types.js.map +1 -0
  38. package/dist/validation.d.ts +16 -0
  39. package/dist/validation.d.ts.map +1 -0
  40. package/dist/validation.js +135 -0
  41. package/dist/validation.js.map +1 -0
  42. package/package.json +16 -16
package/dist/index.js CHANGED
@@ -1,935 +1,457 @@
1
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 } };
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 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 } };
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
- // 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;
37
+ function clearIdleTimer(session) {
38
+ if (session.idleTimer) {
39
+ clearTimeout(session.idleTimer);
40
+ session.idleTimer = null;
168
41
  }
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;
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
- #toast.show {
219
- opacity: 1;
220
- transform: translateX(-50%) translateY(0);
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
- </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
- }
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
- // --- 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,
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
- 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 */ }
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
- 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
- }
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
- // --- 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);
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
- 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
- }
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
- // --- 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 ');
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
- 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 }));
210
+ const msg = result.message;
211
+ if (msg.type === 'ping') {
212
+ sendMessage(ws, { type: 'pong' });
213
+ return;
478
214
  }
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
- }
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 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;
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
- 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);
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
- 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;
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
- const session = spawnHeadlessSession({
758
- taskExecutionId: data.taskExecutionId,
759
- branchName: data.branchName,
760
- prompt: data.prompt,
761
- cwd: data.cwd
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
- 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);
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
- 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:`);
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
- 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);
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
- 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
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