osborn 0.9.148 → 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.
@@ -332,9 +332,13 @@ export const NAMED_AGENTS = {
332
332
  'Flag any change that could break existing callers, even if no test currently covers it.',
333
333
  '',
334
334
  '## How to work',
335
+ '0. **Get the diff first (MANDATORY):** Run `git diff HEAD~1 HEAD --name-only` to get the list of',
336
+ ' changed files, then `git diff HEAD~1 HEAD` for the full diff. Build your entire test plan around',
337
+ ' the SPECIFIC files and functions that changed — not a generic sweep.',
335
338
  '1. Identify the correct test / build command from package.json, Makefile, or the task brief.',
336
339
  '2. Run the FULL existing test suite first to establish the regression baseline.',
337
- '3. Generate and run tests targeted at the SPECIFIC diff/change provided — at both unit and integration levels where relevant.',
340
+ '3. Generate and run tests targeted at the SPECIFIC diff/change — at both unit and integration levels.',
341
+ ' Focus on: the changed functions/components, their callers, and any behavior the diff modifies.',
338
342
  '4. Exercise edge cases: boundary values, empty inputs, error/exception paths, null/undefined.',
339
343
  '5. Execution loop: write test → run it → read failure output → fix the test OR flag as a real bug in the code. Do NOT silently paper over a real defect.',
340
344
  '6. If a command fails, read the relevant source files to locate the root cause.',
@@ -440,7 +444,19 @@ export const NAMED_AGENTS = {
440
444
  'You must NEVER write code, config, or source files (.ts, .js, .json, .env, etc.) — the write gate',
441
445
  'enforces this at the system level and will deny any such attempt.',
442
446
  '',
447
+ '## Backward-compatibility mandate',
448
+ 'For every change, explicitly verify:',
449
+ '- No existing exported function signature changed (parameter count/order/type, return type)',
450
+ '- No existing data-channel message schema changed (field names, types, required fields)',
451
+ '- No existing API endpoint behavior changed silently',
452
+ '- No existing UI prop interface changed in a breaking way',
453
+ '- Existing callers and consumers of the changed code still work',
454
+ 'Tag any violation BLOCKER — silent compat breaks are the hardest bugs to catch after the fact.',
455
+ '',
443
456
  '## How to work',
457
+ '0. **Get the diff first (MANDATORY):** Run `git diff HEAD~1 HEAD --stat` then `git diff HEAD~1 HEAD`.',
458
+ ' Build your entire review around what ACTUALLY changed — not the writer\'s narrative alone.',
459
+ ' If the task provides a diff, still verify it matches git history.',
444
460
  '1. Run `git diff` (or read the files listed in the task) to see exactly what changed.',
445
461
  '2. Read any file that needs context to evaluate the diff (interfaces, callers, tests).',
446
462
  '3. Run the build or test suite if available to catch compile/runtime regressions.',
@@ -1181,13 +1197,26 @@ export class ClaudeLLM extends llm.LLM {
1181
1197
  const idxPathReviewer = (this.#sessionId && this.#opts.workingDirectory)
1182
1198
  ? getIndexPath(this.#sessionId, this.#opts.workingDirectory)
1183
1199
  : null;
1200
+ // Get the actual diff to give reviewer concrete evidence instead of just the narrative
1201
+ let gitDiff = '';
1202
+ try {
1203
+ const { execSync } = await import('child_process');
1204
+ const diffStat = execSync('git diff HEAD~1 HEAD --stat 2>/dev/null', { cwd: this.#opts.workingDirectory, timeout: 5000 }).toString().trim();
1205
+ const diff = execSync('git diff HEAD~1 HEAD 2>/dev/null', { cwd: this.#opts.workingDirectory, timeout: 5000 }).toString().trim();
1206
+ gitDiff = diffStat ? `\n\n<git_diff_stat>\n${diffStat}\n</git_diff_stat>\n\n<git_diff>\n${diff.slice(0, 6000)}\n</git_diff>` : '';
1207
+ }
1208
+ catch {
1209
+ // non-fatal: if git fails, proceed without diff
1210
+ }
1184
1211
  const prompt = [
1185
1212
  'Use the reviewer sub-agent to review this writer output for correctness/spec-adherence/obvious bugs.',
1213
+ 'The git diff is provided below — use it as the authoritative source of what changed.',
1186
1214
  'End your reply with exactly `VERDICT: ACCEPT` or `VERDICT: REJECT`.',
1187
1215
  ...(idxPathReviewer ? [`The session index is at ${idxPathReviewer} — you MUST read it before reviewing.`] : []),
1216
+ gitDiff,
1188
1217
  '',
1189
1218
  '<writer_output>',
1190
- writerOutput.slice(0, 8000),
1219
+ writerOutput.slice(0, 6000),
1191
1220
  '</writer_output>',
1192
1221
  ].join('\n');
1193
1222
  // Do NOT pass agents here — the reviewer must be review-only and must not
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
- // No WebSocket upgrade routes — meeting audio in/out moved off LiveKit to
1471
- // a polling architecture (see MeetingTranscriptPoller). The /meeting-audio
1472
- // and /meeting-audio-in routes were the old WebSocket-audio pipeline; both
1473
- // are gone. Reject all upgrade attempts.
1474
- server.on('upgrade', (_req, socket) => {
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.148",
3
+ "version": "0.9.150",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {