osborn 0.9.149 → 0.9.150
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/dist/index.js +442 -7
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -9,12 +9,13 @@ initializeLogger({ pretty: true, level: 'info' });
|
|
|
9
9
|
// Each resumed query() adds listeners to the shared signal; default limit is 10
|
|
10
10
|
import { setMaxListeners } from 'node:events';
|
|
11
11
|
setMaxListeners(50);
|
|
12
|
-
import { createServer } from 'http';
|
|
12
|
+
import { createServer, request as httpRequest } from 'http';
|
|
13
13
|
import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, mkdtempSync, cpSync, rmSync, statSync, utimesSync, createWriteStream } from 'node:fs';
|
|
14
14
|
import { dirname, join } from 'node:path';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
16
|
import { spawn } from 'node:child_process';
|
|
17
|
-
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import { randomUUID, randomBytes } from 'node:crypto';
|
|
18
|
+
import { createConnection as netConnect } from 'node:net';
|
|
18
19
|
import { createRequire } from 'node:module';
|
|
19
20
|
// 0.9.71: createRequire for resolving package.json versions inside ESM
|
|
20
21
|
const __sdkVersionRequire = createRequire(import.meta.url);
|
|
@@ -258,6 +259,48 @@ let leaveRoomHook = null;
|
|
|
258
259
|
// as the existing fetch-log/save-log flow so we don't ship Supabase credentials
|
|
259
260
|
// to the Fly machine.
|
|
260
261
|
let bugReportHook = null;
|
|
262
|
+
// Module-scope hook wired by main() once startCodeServer() is defined in its
|
|
263
|
+
// closure. The /editor route in startApiServer() calls this to start the IDE
|
|
264
|
+
// in the background without depending on main()'s closure directly.
|
|
265
|
+
let codeServerStartHook = null;
|
|
266
|
+
// ── IDE reverse-proxy (module scope — shared between startApiServer + main) ──
|
|
267
|
+
// code-server runs on 127.0.0.1:8300; the agent proxies it through its own
|
|
268
|
+
// public HTTP server so no Cloudflare tunnel is needed.
|
|
269
|
+
const IDE_TARGET = 'http://127.0.0.1:8300';
|
|
270
|
+
// Route prefixes that belong to the agent itself. The proxy fall-through checks
|
|
271
|
+
// this list FIRST so it never intercepts an agent API path.
|
|
272
|
+
const AGENT_ROUTE_PREFIXES = [
|
|
273
|
+
'/health', '/sessions', '/skills', '/agents', '/canvas', '/canvas-token',
|
|
274
|
+
'/canvas-stream', '/events', '/room-code', '/connect-room', '/leave-room',
|
|
275
|
+
'/report-bug', '/restart', '/tts', '/webhook', '/sessions/export',
|
|
276
|
+
'/sessions/import', '/sessions/manifest',
|
|
277
|
+
'/editor',
|
|
278
|
+
];
|
|
279
|
+
// Set true when code-server is confirmed ready; false when stopped/not running.
|
|
280
|
+
// Proxy returns 404 while this is false.
|
|
281
|
+
let ideProxyEnabled = false;
|
|
282
|
+
// Bumped on every proxied HTTP request and WS upgrade so the idle watcher sees
|
|
283
|
+
// real editor activity rather than code-server log noise.
|
|
284
|
+
let ideLastProxiedActivity = 0;
|
|
285
|
+
// Per-session cookie token minted when code-server becomes ready. The proxy
|
|
286
|
+
// gate checks this token via the osborn_ide cookie — only requests carrying a
|
|
287
|
+
// valid cookie are forwarded, so a forgotten agent route can never be silently
|
|
288
|
+
// swallowed by the IDE proxy. Cleared in stopIde().
|
|
289
|
+
let ideSessionToken = null;
|
|
290
|
+
// Helper: parse the osborn_ide cookie from a request and return true iff it
|
|
291
|
+
// matches the current ideSessionToken (and a token exists).
|
|
292
|
+
function hasValidIdeCookie(req) {
|
|
293
|
+
if (!ideSessionToken)
|
|
294
|
+
return false;
|
|
295
|
+
const cookieHeader = req.headers.cookie || '';
|
|
296
|
+
for (const part of cookieHeader.split(';')) {
|
|
297
|
+
const [name, ...rest] = part.trim().split('=');
|
|
298
|
+
if (name.trim() === 'osborn_ide' && rest.join('=').trim() === ideSessionToken) {
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
261
304
|
const canvasClients = new Set();
|
|
262
305
|
let latestCanvasShow = null; // last 'show' so a reconnecting canvas resyncs its visual
|
|
263
306
|
function pushCanvas(evt) {
|
|
@@ -1430,6 +1473,73 @@ function startApiServer(workingDir, port) {
|
|
|
1430
1473
|
}
|
|
1431
1474
|
return;
|
|
1432
1475
|
}
|
|
1476
|
+
// ── /editor entry point ──────────────────────────────────────────────────
|
|
1477
|
+
// Explicit door into the IDE. When code-server is ready this route mints the
|
|
1478
|
+
// osborn_ide session cookie and 302s to / (where the proxy then forwards). If
|
|
1479
|
+
// code-server is not yet running, it starts it in the background and returns a
|
|
1480
|
+
// self-refreshing "Starting…" page so the user naturally re-hits /editor every
|
|
1481
|
+
// 3 s until ready.
|
|
1482
|
+
if (url.pathname === '/editor' && req.method === 'GET') {
|
|
1483
|
+
// AUTH GATE (future): validate key / signed session before minting the cookie
|
|
1484
|
+
if (ideProxyEnabled && ideSessionToken) {
|
|
1485
|
+
// code-server is up — set cookie and redirect to root (code-server's asset base)
|
|
1486
|
+
res.writeHead(302, {
|
|
1487
|
+
'Set-Cookie': `osborn_ide=${ideSessionToken}; Path=/; HttpOnly; SameSite=Lax`,
|
|
1488
|
+
'Location': '/',
|
|
1489
|
+
});
|
|
1490
|
+
res.end();
|
|
1491
|
+
}
|
|
1492
|
+
else {
|
|
1493
|
+
// Not yet running — kick off start in the background, show "Starting…" page
|
|
1494
|
+
if (codeServerStartHook) {
|
|
1495
|
+
codeServerStartHook().catch((err) => {
|
|
1496
|
+
console.error('❌ startCodeServer() from /editor failed:', err);
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1500
|
+
res.end(`<!DOCTYPE html><html><head>
|
|
1501
|
+
<meta charset="utf-8">
|
|
1502
|
+
<meta http-equiv="refresh" content="3;url=/editor">
|
|
1503
|
+
<title>Starting editor…</title>
|
|
1504
|
+
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#1e1e1e;color:#ccc}</style>
|
|
1505
|
+
</head><body><p>Starting your editor…</p></body></html>`);
|
|
1506
|
+
}
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
1510
|
+
// ── IDE reverse proxy (cookie-gated fall-through) ────────────────────────
|
|
1511
|
+
// Forwards to code-server ONLY when:
|
|
1512
|
+
// 1. ideProxyEnabled is true (code-server is confirmed ready), AND
|
|
1513
|
+
// 2. the request carries a valid osborn_ide session cookie (primary gate), AND
|
|
1514
|
+
// 3. the path is not an agent route (secondary backstop).
|
|
1515
|
+
// A missing/invalid cookie means the request came from somewhere other than
|
|
1516
|
+
// the /editor door — refuse it so a forgotten agent route is never swallowed.
|
|
1517
|
+
if (ideProxyEnabled &&
|
|
1518
|
+
hasValidIdeCookie(req) &&
|
|
1519
|
+
!AGENT_ROUTE_PREFIXES.some(p => url.pathname === p || url.pathname.startsWith(p + '/'))) {
|
|
1520
|
+
ideLastProxiedActivity = Date.now();
|
|
1521
|
+
const targetUrl = new URL(req.url || '/', IDE_TARGET);
|
|
1522
|
+
const options = {
|
|
1523
|
+
hostname: '127.0.0.1',
|
|
1524
|
+
port: 8300,
|
|
1525
|
+
path: targetUrl.pathname + (targetUrl.search || ''),
|
|
1526
|
+
method: req.method,
|
|
1527
|
+
headers: { ...req.headers, host: '127.0.0.1:8300' },
|
|
1528
|
+
};
|
|
1529
|
+
const proxyReq = httpRequest(options, (proxyRes) => {
|
|
1530
|
+
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
|
|
1531
|
+
proxyRes.pipe(res);
|
|
1532
|
+
});
|
|
1533
|
+
proxyReq.on('error', () => {
|
|
1534
|
+
if (!res.headersSent) {
|
|
1535
|
+
res.writeHead(502);
|
|
1536
|
+
res.end('IDE proxy error');
|
|
1537
|
+
}
|
|
1538
|
+
});
|
|
1539
|
+
req.pipe(proxyReq);
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
1433
1543
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1434
1544
|
res.end(JSON.stringify({ error: 'Not found' }));
|
|
1435
1545
|
});
|
|
@@ -1467,11 +1577,53 @@ function startApiServer(workingDir, port) {
|
|
|
1467
1577
|
};
|
|
1468
1578
|
cleanStaleUploadDirs();
|
|
1469
1579
|
setInterval(cleanStaleUploadDirs, 10 * 60 * 1000);
|
|
1470
|
-
//
|
|
1471
|
-
// a
|
|
1472
|
-
//
|
|
1473
|
-
//
|
|
1474
|
-
|
|
1580
|
+
// WebSocket upgrade handler: forward to code-server when the IDE proxy is
|
|
1581
|
+
// active, the request carries a valid osborn_ide session cookie, and the path
|
|
1582
|
+
// is not an agent route. code-server's terminal and editor are websocket-heavy
|
|
1583
|
+
// — this must work for the integrated terminal to connect.
|
|
1584
|
+
// Reject all other upgrades (meeting audio moved to polling in 0.9.xx).
|
|
1585
|
+
server.on('upgrade', (req, socket, head) => {
|
|
1586
|
+
const upgradePath = new URL(req.url || '/', `http://localhost`).pathname;
|
|
1587
|
+
const isAgentRoute = AGENT_ROUTE_PREFIXES.some(p => upgradePath === p || upgradePath.startsWith(p + '/'));
|
|
1588
|
+
if (ideProxyEnabled && hasValidIdeCookie(req) && !isAgentRoute) {
|
|
1589
|
+
ideLastProxiedActivity = Date.now();
|
|
1590
|
+
// Raw TCP tunnel: connect to code-server and splice the socket.
|
|
1591
|
+
// Pause the client socket before initiating the connect so that any
|
|
1592
|
+
// bytes the browser sends before the connect callback fires are buffered
|
|
1593
|
+
// by Node rather than silently dropped — the classic silent-hang shape
|
|
1594
|
+
// for a websocket terminal on a slow connect.
|
|
1595
|
+
socket.pause();
|
|
1596
|
+
const conn = netConnect(8300, '127.0.0.1', () => {
|
|
1597
|
+
// Forward the original HTTP upgrade request headers.
|
|
1598
|
+
const headerLines = [`${req.method} ${req.url} HTTP/1.1`];
|
|
1599
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
1600
|
+
if (k.toLowerCase() === 'host')
|
|
1601
|
+
continue;
|
|
1602
|
+
if (Array.isArray(v)) {
|
|
1603
|
+
for (const vv of v)
|
|
1604
|
+
headerLines.push(`${k}: ${vv}`);
|
|
1605
|
+
}
|
|
1606
|
+
else if (v != null)
|
|
1607
|
+
headerLines.push(`${k}: ${v}`);
|
|
1608
|
+
}
|
|
1609
|
+
headerLines.push('host: 127.0.0.1:8300');
|
|
1610
|
+
conn.write(headerLines.join('\r\n') + '\r\n\r\n');
|
|
1611
|
+
if (head && head.length)
|
|
1612
|
+
conn.write(head);
|
|
1613
|
+
conn.pipe(socket);
|
|
1614
|
+
socket.pipe(conn);
|
|
1615
|
+
socket.resume();
|
|
1616
|
+
});
|
|
1617
|
+
conn.on('error', () => { try {
|
|
1618
|
+
socket.destroy();
|
|
1619
|
+
}
|
|
1620
|
+
catch { } });
|
|
1621
|
+
socket.on('error', () => { try {
|
|
1622
|
+
conn.destroy();
|
|
1623
|
+
}
|
|
1624
|
+
catch { } });
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1475
1627
|
socket.destroy();
|
|
1476
1628
|
});
|
|
1477
1629
|
server.on('error', (err) => {
|
|
@@ -1990,6 +2142,19 @@ async function main() {
|
|
|
1990
2142
|
// the fresh session's latch was never opened by the name).
|
|
1991
2143
|
const meetingSpeakers = new Set();
|
|
1992
2144
|
let activeMeetingPoller = null; // Transcript poller bound to that bot
|
|
2145
|
+
// ── IDE (code-server) state ──────────────────────────────────────────────
|
|
2146
|
+
// All fields reset together in stopIde(). Never auto-started; only spawned
|
|
2147
|
+
// by an explicit start_ide data-channel command from the frontend.
|
|
2148
|
+
// Cloudflared is gone — exposure is via the agent's own HTTP server proxy
|
|
2149
|
+
// (ideProxyEnabled at module scope, set/cleared by start_ide/stopIde).
|
|
2150
|
+
let ideCodeServerProc = null;
|
|
2151
|
+
let ideLastActivity = 0; // bumped on code-server stdout/stderr
|
|
2152
|
+
let ideIdleWatcher = null;
|
|
2153
|
+
// In-flight start promise: set for the duration of startCodeServer()'s async
|
|
2154
|
+
// body, cleared in its finally. Concurrent callers (e.g. /editor page refreshes
|
|
2155
|
+
// every 3s) return early instead of killing the process that is still booting.
|
|
2156
|
+
let ideStartInProgress = null;
|
|
2157
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
1993
2158
|
// LIVE meeting transcript → LLM (buffered webhook finals). See recall.on('transcript').
|
|
1994
2159
|
const meetingTranscriptBuffer = [];
|
|
1995
2160
|
let meetingFlushTimer = null;
|
|
@@ -2164,6 +2329,206 @@ async function main() {
|
|
|
2164
2329
|
}
|
|
2165
2330
|
}, MEETING_MAX_MS);
|
|
2166
2331
|
};
|
|
2332
|
+
// ── IDE helpers ─────────────────────────────────────────────────────────
|
|
2333
|
+
// ensureCodeServer() — idempotent: installs code-server if missing.
|
|
2334
|
+
// Cloudflared is no longer downloaded here (exposure via agent's own HTTP proxy).
|
|
2335
|
+
// Emits ide_status:'installing' before any long download so the frontend can
|
|
2336
|
+
// show a spinner. (~36s + ~740MB first run, once per machine lifetime).
|
|
2337
|
+
const ensureCodeServer = async () => {
|
|
2338
|
+
// Resolve code-server binary
|
|
2339
|
+
const which = await new Promise((res) => {
|
|
2340
|
+
const p = spawn('which', ['code-server']);
|
|
2341
|
+
let out = '';
|
|
2342
|
+
p.stdout?.on('data', (d) => { out += d.toString(); });
|
|
2343
|
+
p.on('close', () => res(out.trim()));
|
|
2344
|
+
});
|
|
2345
|
+
if (!which) {
|
|
2346
|
+
await sendToFrontend({ type: 'ide_status', status: 'installing' });
|
|
2347
|
+
console.log('⬇️ Installing code-server via official script...');
|
|
2348
|
+
await new Promise((res, rej) => {
|
|
2349
|
+
const install = spawn('sh', ['-c', 'curl -fsSL https://code-server.dev/install.sh | sh'], {
|
|
2350
|
+
env: { ...process.env },
|
|
2351
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2352
|
+
});
|
|
2353
|
+
install.stdout?.on('data', (d) => process.stdout.write(d));
|
|
2354
|
+
install.stderr?.on('data', (d) => process.stderr.write(d));
|
|
2355
|
+
install.on('close', (code) => code === 0 ? res() : rej(new Error(`code-server install exited ${code}`)));
|
|
2356
|
+
});
|
|
2357
|
+
}
|
|
2358
|
+
};
|
|
2359
|
+
// Kill code-server, disable proxy, clear interval, reset IDE state.
|
|
2360
|
+
const stopIde = (reason) => {
|
|
2361
|
+
ideProxyEnabled = false;
|
|
2362
|
+
ideSessionToken = null;
|
|
2363
|
+
ideStartInProgress = null;
|
|
2364
|
+
if (ideIdleWatcher) {
|
|
2365
|
+
clearInterval(ideIdleWatcher);
|
|
2366
|
+
ideIdleWatcher = null;
|
|
2367
|
+
}
|
|
2368
|
+
if (ideCodeServerProc) {
|
|
2369
|
+
try {
|
|
2370
|
+
ideCodeServerProc.kill();
|
|
2371
|
+
}
|
|
2372
|
+
catch { }
|
|
2373
|
+
ideCodeServerProc = null;
|
|
2374
|
+
}
|
|
2375
|
+
ideLastActivity = 0;
|
|
2376
|
+
ideLastProxiedActivity = 0;
|
|
2377
|
+
const msg = { type: 'ide_stopped' };
|
|
2378
|
+
if (reason)
|
|
2379
|
+
msg.reason = reason;
|
|
2380
|
+
sendToFrontend(msg).catch(() => { });
|
|
2381
|
+
console.log(`🛑 IDE stopped${reason ? ` (${reason})` : ''}`);
|
|
2382
|
+
};
|
|
2383
|
+
// Idle watcher: poll every 60s; stop IDE if no proxied HTTP/WS activity
|
|
2384
|
+
// within the threshold. Uses ideLastProxiedActivity (real editor traffic seen
|
|
2385
|
+
// by the agent's proxy) as the primary signal — more reliable than log noise
|
|
2386
|
+
// on code-server's stdout/stderr. Falls back to ideLastActivity (process output)
|
|
2387
|
+
// if no proxied activity has ever been recorded (pre-first-browser-open).
|
|
2388
|
+
const startIdeIdleWatcher = () => {
|
|
2389
|
+
if (ideIdleWatcher) {
|
|
2390
|
+
clearInterval(ideIdleWatcher);
|
|
2391
|
+
ideIdleWatcher = null;
|
|
2392
|
+
}
|
|
2393
|
+
const idleMinutes = Math.max(1, parseInt(process.env.OSBORN_IDE_IDLE_MIN || '10', 10));
|
|
2394
|
+
const idleMs = idleMinutes * 60 * 1000;
|
|
2395
|
+
ideIdleWatcher = setInterval(() => {
|
|
2396
|
+
if (!ideCodeServerProc) {
|
|
2397
|
+
clearInterval(ideIdleWatcher);
|
|
2398
|
+
ideIdleWatcher = null;
|
|
2399
|
+
return;
|
|
2400
|
+
}
|
|
2401
|
+
// Prefer proxied-activity timestamp; fall back to process output timestamp.
|
|
2402
|
+
const lastSeen = ideLastProxiedActivity > 0 ? ideLastProxiedActivity : ideLastActivity;
|
|
2403
|
+
const elapsed = Date.now() - lastSeen;
|
|
2404
|
+
if (elapsed > idleMs) {
|
|
2405
|
+
console.log(`⏱️ IDE idle for ${Math.round(elapsed / 60000)} min — stopping`);
|
|
2406
|
+
stopIde('idle');
|
|
2407
|
+
}
|
|
2408
|
+
}, 60_000);
|
|
2409
|
+
};
|
|
2410
|
+
// ── startCodeServer() ───────────────────────────────────────────────────────
|
|
2411
|
+
// Shared helper called by both the start_ide data-channel handler and the
|
|
2412
|
+
// GET /editor HTTP route. Idempotent: no-op if code-server is already running
|
|
2413
|
+
// and the proxy is enabled. On fresh start it:
|
|
2414
|
+
// 1. ensureCodeServer() — installs if missing
|
|
2415
|
+
// 2. probes --idle-timeout-seconds flag support
|
|
2416
|
+
// 3. spawns code-server on 127.0.0.1:8300 with --auth none, cwd /workspace
|
|
2417
|
+
// 4. polls until ready (up to 20 s)
|
|
2418
|
+
// 5. mints ideSessionToken (the cookie marker) and sets ideProxyEnabled = true
|
|
2419
|
+
const startCodeServer = async () => {
|
|
2420
|
+
// Fast path 1: already fully up — nothing to do.
|
|
2421
|
+
if (ideCodeServerProc && ideProxyEnabled) {
|
|
2422
|
+
console.log('🖥️ IDE already running — reusing existing instance');
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
// Fast path 2: a start is already in progress — join it instead of killing
|
|
2426
|
+
// the process that is still booting (prevents the /editor 3s-refresh self-DoS).
|
|
2427
|
+
if (ideStartInProgress) {
|
|
2428
|
+
console.log('🖥️ IDE start already in progress — awaiting in-flight promise');
|
|
2429
|
+
return ideStartInProgress;
|
|
2430
|
+
}
|
|
2431
|
+
// Fresh start: kill any stale proc (shouldn't normally exist here, but be safe).
|
|
2432
|
+
if (ideCodeServerProc)
|
|
2433
|
+
stopIde();
|
|
2434
|
+
let resolveFn;
|
|
2435
|
+
let rejectFn;
|
|
2436
|
+
ideStartInProgress = new Promise((res, rej) => { resolveFn = res; rejectFn = rej; });
|
|
2437
|
+
try {
|
|
2438
|
+
await ensureCodeServer();
|
|
2439
|
+
// Probe whether this code-server version supports --idle-timeout-seconds
|
|
2440
|
+
// (landed ~Oct 2025; older installs predate it). Only pass the flag if
|
|
2441
|
+
// the binary reports it — an unknown flag prevents code-server from starting.
|
|
2442
|
+
const idleMinutes = Math.max(1, parseInt(process.env.OSBORN_IDE_IDLE_MIN || '10', 10));
|
|
2443
|
+
const idleSeconds = idleMinutes * 60;
|
|
2444
|
+
const supportsIdleFlag = await new Promise((res) => {
|
|
2445
|
+
const h = spawn('sh', ['-c', 'code-server --help 2>&1 | grep -q idle-timeout-seconds && echo yes || echo no'], {
|
|
2446
|
+
env: { ...process.env }, stdio: ['ignore', 'pipe', 'pipe'],
|
|
2447
|
+
});
|
|
2448
|
+
let out = '';
|
|
2449
|
+
h.stdout?.on('data', (d) => { out += d.toString(); });
|
|
2450
|
+
h.on('close', () => res(out.trim() === 'yes'));
|
|
2451
|
+
});
|
|
2452
|
+
console.log(`🖥️ code-server idle-timeout-seconds flag: ${supportsIdleFlag ? 'supported' : 'absent (using watcher)'}`);
|
|
2453
|
+
// Spawn code-server bound to 127.0.0.1:8300 (never 8741).
|
|
2454
|
+
console.log('🖥️ Spawning code-server on 127.0.0.1:8300...');
|
|
2455
|
+
const csArgs = [
|
|
2456
|
+
'--bind-addr', '127.0.0.1:8300',
|
|
2457
|
+
'--auth', 'none',
|
|
2458
|
+
'/workspace',
|
|
2459
|
+
];
|
|
2460
|
+
if (supportsIdleFlag) {
|
|
2461
|
+
csArgs.push('--idle-timeout-seconds', String(Math.max(60, idleSeconds)));
|
|
2462
|
+
}
|
|
2463
|
+
const codeServerProc = spawn('code-server', csArgs, {
|
|
2464
|
+
env: { ...process.env },
|
|
2465
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2466
|
+
});
|
|
2467
|
+
ideCodeServerProc = codeServerProc;
|
|
2468
|
+
ideLastActivity = Date.now();
|
|
2469
|
+
ideLastProxiedActivity = 0;
|
|
2470
|
+
// Bump lastActivity on any output from code-server (fallback idle signal).
|
|
2471
|
+
codeServerProc.stdout?.on('data', () => { ideLastActivity = Date.now(); });
|
|
2472
|
+
codeServerProc.stderr?.on('data', () => { ideLastActivity = Date.now(); });
|
|
2473
|
+
codeServerProc.on('exit', (code) => {
|
|
2474
|
+
console.log(`🖥️ code-server exited (${code})`);
|
|
2475
|
+
if (ideCodeServerProc === codeServerProc)
|
|
2476
|
+
stopIde();
|
|
2477
|
+
});
|
|
2478
|
+
// Poll until code-server is ready (302 or 200 on the root, up to 20s).
|
|
2479
|
+
// Minor fix: if the process we spawned has been replaced or killed mid-poll,
|
|
2480
|
+
// reject immediately rather than burning the rest of the 20s timeout.
|
|
2481
|
+
await new Promise((res, rej) => {
|
|
2482
|
+
let attempts = 0;
|
|
2483
|
+
const poll = setInterval(() => {
|
|
2484
|
+
// Early-exit: the proc we spawned is no longer the active one (stopIde
|
|
2485
|
+
// was called, or a concurrent path replaced it).
|
|
2486
|
+
if (ideCodeServerProc !== codeServerProc) {
|
|
2487
|
+
clearInterval(poll);
|
|
2488
|
+
rej(new Error('code-server process was replaced or stopped mid-poll'));
|
|
2489
|
+
return;
|
|
2490
|
+
}
|
|
2491
|
+
attempts++;
|
|
2492
|
+
const check = spawn('curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', 'http://127.0.0.1:8300']);
|
|
2493
|
+
let out = '';
|
|
2494
|
+
check.stdout?.on('data', (d) => { out += d.toString(); });
|
|
2495
|
+
check.on('close', () => {
|
|
2496
|
+
const httpCode = parseInt(out.trim(), 10);
|
|
2497
|
+
if (httpCode === 200 || httpCode === 302 || httpCode === 301) {
|
|
2498
|
+
clearInterval(poll);
|
|
2499
|
+
res();
|
|
2500
|
+
}
|
|
2501
|
+
else if (attempts >= 40) { // 40 * 500ms = 20s
|
|
2502
|
+
clearInterval(poll);
|
|
2503
|
+
rej(new Error('code-server did not become ready within 20s'));
|
|
2504
|
+
}
|
|
2505
|
+
});
|
|
2506
|
+
}, 500);
|
|
2507
|
+
});
|
|
2508
|
+
console.log('✅ code-server ready on 8300');
|
|
2509
|
+
// Mint the session cookie token and enable the proxy. From this point the
|
|
2510
|
+
// /editor route will 302+Set-Cookie and the fall-through proxy will only
|
|
2511
|
+
// forward requests that carry the valid osborn_ide cookie.
|
|
2512
|
+
ideSessionToken = randomBytes(24).toString('hex');
|
|
2513
|
+
ideProxyEnabled = true;
|
|
2514
|
+
// Start idle watcher only when the native idle flag is absent.
|
|
2515
|
+
if (!supportsIdleFlag) {
|
|
2516
|
+
startIdeIdleWatcher();
|
|
2517
|
+
}
|
|
2518
|
+
resolveFn();
|
|
2519
|
+
}
|
|
2520
|
+
catch (err) {
|
|
2521
|
+
rejectFn(err);
|
|
2522
|
+
throw err;
|
|
2523
|
+
}
|
|
2524
|
+
finally {
|
|
2525
|
+
ideStartInProgress = null;
|
|
2526
|
+
}
|
|
2527
|
+
};
|
|
2528
|
+
// Wire the module-scope hook so startApiServer()'s /editor route can call
|
|
2529
|
+
// startCodeServer() without reaching into main()'s closure directly.
|
|
2530
|
+
codeServerStartHook = startCodeServer;
|
|
2531
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
2167
2532
|
// Track the active resume session ID across scopes (ParticipantConnected + DataReceived)
|
|
2168
2533
|
// Updated by resume_session, session_selected, continue_session, switch_session handlers
|
|
2169
2534
|
let currentResumeSessionId;
|
|
@@ -4900,6 +5265,11 @@ async function main() {
|
|
|
4900
5265
|
armMeetingLeaveGrace(); // no-op when no meeting, kept for symmetry
|
|
4901
5266
|
armFastLeaveTimer();
|
|
4902
5267
|
}
|
|
5268
|
+
// Tear down IDE if one is running — user has disconnected, no point keeping
|
|
5269
|
+
// code-server alive for a departed session.
|
|
5270
|
+
if (ideCodeServerProc) {
|
|
5271
|
+
stopIde();
|
|
5272
|
+
}
|
|
4903
5273
|
console.log('⏳ Waiting for new user...\n');
|
|
4904
5274
|
};
|
|
4905
5275
|
const handleDataReceived = async (payload, participant, kind, topic) => {
|
|
@@ -5663,6 +6033,32 @@ async function main() {
|
|
|
5663
6033
|
await sendToFrontend({ type: 'meeting_error', message: err.message });
|
|
5664
6034
|
}
|
|
5665
6035
|
}
|
|
6036
|
+
// ── IDE (code-server) handlers ──────────────────────────────────────
|
|
6037
|
+
else if (data.type === 'start_ide') {
|
|
6038
|
+
try {
|
|
6039
|
+
await startCodeServer();
|
|
6040
|
+
// Build the /editor entry-point URL for the frontend to open.
|
|
6041
|
+
const flyAppHost = process.env.FLY_APP_NAME
|
|
6042
|
+
? `${process.env.FLY_APP_NAME}.fly.dev`
|
|
6043
|
+
: (process.env.OSBORN_PUBLIC_HOST || `localhost:${apiPort}`);
|
|
6044
|
+
const ideUrl = process.env.FLY_APP_NAME
|
|
6045
|
+
? `https://${flyAppHost}/editor`
|
|
6046
|
+
: `http://${flyAppHost}/editor`;
|
|
6047
|
+
console.log(`✅ IDE proxy ready: ${ideUrl}`);
|
|
6048
|
+
await sendToFrontend({ type: 'ide_ready', url: ideUrl });
|
|
6049
|
+
}
|
|
6050
|
+
catch (err) {
|
|
6051
|
+
console.error('❌ start_ide error:', err);
|
|
6052
|
+
// Clean up any partial state.
|
|
6053
|
+
if (ideCodeServerProc)
|
|
6054
|
+
stopIde();
|
|
6055
|
+
await sendToFrontend({ type: 'ide_error', error: err.message || String(err) });
|
|
6056
|
+
}
|
|
6057
|
+
}
|
|
6058
|
+
else if (data.type === 'stop_ide') {
|
|
6059
|
+
stopIde();
|
|
6060
|
+
}
|
|
6061
|
+
// ───────────────────────────────────────────────────────────────────
|
|
5666
6062
|
else if (data.type === 'session_selected') {
|
|
5667
6063
|
const sessionId = data.sessionId;
|
|
5668
6064
|
console.log(`🚪 Session gate completed: ${sessionId ? `resume ${sessionId}` : 'fresh start'}`);
|
|
@@ -5815,6 +6211,45 @@ async function main() {
|
|
|
5815
6211
|
slots: [...focusedEntry, ...bgEntries],
|
|
5816
6212
|
});
|
|
5817
6213
|
}
|
|
6214
|
+
else if (data.type === 'list_processes') {
|
|
6215
|
+
// Read process list from /proc. Each numeric subdir is a PID.
|
|
6216
|
+
// We read /proc/<pid>/comm (process name) and VmRSS from /proc/<pid>/status.
|
|
6217
|
+
// Skips any PID that errors mid-scan (race: process may have exited).
|
|
6218
|
+
try {
|
|
6219
|
+
const procEntries = readdirSync('/proc');
|
|
6220
|
+
const processes = [];
|
|
6221
|
+
for (const entry of procEntries) {
|
|
6222
|
+
if (!/^\d+$/.test(entry))
|
|
6223
|
+
continue;
|
|
6224
|
+
const pid = parseInt(entry, 10);
|
|
6225
|
+
try {
|
|
6226
|
+
const comm = readFileSync(`/proc/${entry}/comm`, 'utf8').trim();
|
|
6227
|
+
const status = readFileSync(`/proc/${entry}/status`, 'utf8');
|
|
6228
|
+
const vmRssMatch = status.match(/^VmRSS:\s*(\d+)\s*kB/m);
|
|
6229
|
+
const rssMb = vmRssMatch ? Math.round(parseInt(vmRssMatch[1], 10) / 1024) : 0;
|
|
6230
|
+
processes.push({ pid, name: comm, rssMb });
|
|
6231
|
+
}
|
|
6232
|
+
catch {
|
|
6233
|
+
// Process exited between readdir and read — skip it
|
|
6234
|
+
}
|
|
6235
|
+
}
|
|
6236
|
+
// Sort by resident memory descending, cap at 40 entries
|
|
6237
|
+
processes.sort((a, b) => b.rssMb - a.rssMb);
|
|
6238
|
+
const topProcesses = processes.slice(0, 40);
|
|
6239
|
+
// Reuse the existing os module memory totals (same source as /health endpoint)
|
|
6240
|
+
const totalMb = Math.round(totalmem() / 1024 / 1024);
|
|
6241
|
+
const freeMb = Math.round(freemem() / 1024 / 1024);
|
|
6242
|
+
const usedMb = totalMb - freeMb;
|
|
6243
|
+
await sendToFrontend({
|
|
6244
|
+
type: 'process_list',
|
|
6245
|
+
processes: topProcesses,
|
|
6246
|
+
memory: { usedMb, totalMb, freeMb },
|
|
6247
|
+
});
|
|
6248
|
+
}
|
|
6249
|
+
catch (err) {
|
|
6250
|
+
console.error('❌ list_processes: failed to read /proc:', err);
|
|
6251
|
+
}
|
|
6252
|
+
}
|
|
5818
6253
|
}
|
|
5819
6254
|
catch { }
|
|
5820
6255
|
};
|