osborn 0.9.149 → 0.9.151

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.
@@ -14,7 +14,7 @@ import { statusManager } from './status-manager.js';
14
14
  import { getResearchSystemPrompt, getDirectModeResearchPrompt } from './prompts.js';
15
15
  import { getIndexPath } from './summary-index.js';
16
16
  import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
17
- import { join, dirname } from 'node:path';
17
+ import { join, dirname, resolve, basename } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { homedir } from 'node:os';
20
20
  // Directory of this module — used to locate co-located prompt files (e.g., turn-shape reminder).
@@ -183,6 +183,11 @@ export const NAMED_AGENTS = {
183
183
  'Gather information the main agent needs to answer the user\'s question or make a decision.',
184
184
  'You are a scout — go find things, read them carefully, and report back.',
185
185
  '',
186
+ '## Grounding — check the session index first',
187
+ 'Before researching, locate the session index (search-index.txt — a compact line-per-message log of this mission, under .claude/projects/<slug>/osb/<session>/; if several exist pick the most recently modified) and Grep it for the topic you are about to investigate. Read ONLY the matching slice, never the whole file.',
188
+ 'Purpose: find what has ALREADY been decided, answered, or ruled out so you do not re-research a settled question. If the index already establishes the answer, report that (with the index reference) instead of redoing the work.',
189
+ 'If you cannot find the index, proceed normally — this is an optimization, not a hard dependency.',
190
+ '',
186
191
  '## How to work',
187
192
  '1. Understand what information is needed and why.',
188
193
  '2. Search broadly first (Glob, Grep, WebSearch), then read deeply (Read specific files).',
@@ -278,6 +283,10 @@ export const NAMED_AGENTS = {
278
283
  'Handle ALL file operations — code, config, documentation, scripts, data files.',
279
284
  'You are the only agent that writes. The main agent and reasoner produce plans; you execute them.',
280
285
  '',
286
+ '## Grounding — consult the session index (light touch)',
287
+ 'Before editing, locate the session index (search-index.txt under .claude/projects/<slug>/osb/<session>/; newest if several) and Grep it ONLY for: (a) the files/symbols you are about to change, and (b) any recorded DECISIONS or known GOTCHAS relevant to this change. Read only the matching lines — do NOT read the whole index (thousands of lines). This is a lighter dose than the reviewer: a targeted lookup.',
288
+ 'If a decision or gotcha contradicts your task, STOP and report to the main agent before editing. If you find nothing or no index exists, proceed normally.',
289
+ '',
281
290
  '## VERIFY-FIRST workflow (mandatory)',
282
291
  '',
283
292
  '### Step 1: Verify assumptions',
@@ -317,7 +326,7 @@ export const NAMED_AGENTS = {
317
326
  'CI failures, checking compilation errors, verifying that a change did not break anything.',
318
327
  'Returns structured pass/fail results with exact output — does NOT edit files.',
319
328
  ].join(' '),
320
- tools: ['Bash', 'Read', 'Glob', 'Grep'],
329
+ tools: ['Bash', 'Read', 'Glob', 'Grep', 'Write', 'Edit'],
321
330
  model: 'sonnet',
322
331
  prompt: [
323
332
  'You are Osborn\'s tester agent. Your job is running tests and builds, then reporting results.',
@@ -326,6 +335,11 @@ export const NAMED_AGENTS = {
326
335
  'Execute test suites, build commands, and linters. Interpret failures clearly.',
327
336
  'You are a quality gate — find out whether the code works, and say exactly what broke.',
328
337
  '',
338
+ '## Grounding — consult shared context before writing or running tests',
339
+ 'Before deciding what to test, locate the session index (search-index.txt under .claude/projects/<slug>/osb/<session>/; newest if several) and Grep it for the changes/work under test. Also check any project docs and known-issues files (e.g. CONVENTIONS.md, docs/, a known-issues or gotchas doc if present) for: (a) KNOWN ISSUES and gotchas already recorded, and (b) what behavior is ALREADY covered by existing tests.',
340
+ 'Purpose: target regression coverage at real GAPS and known-risk areas rather than testing blind or duplicating coverage — and stay IN SYNC with the reviewer, which reads the same sources.',
341
+ 'Read only the relevant slice of the index, never the whole file. If you find nothing or no index/docs exist, proceed normally — this is an optimization, not a hard dependency.',
342
+ '',
329
343
  '## Backward-compatibility / regression mandate (CRITICAL)',
330
344
  'Existing test suites MUST still pass — any pre-existing test that breaks is a BLOCKER; report it as such.',
331
345
  'The public API surface (function signatures, exported types, return shapes, behavior) must NOT silently change.',
@@ -354,6 +368,16 @@ export const NAMED_AGENTS = {
354
368
  '- REGRESSIONS / COMPAT BREAKS: explicit list of any pre-existing tests that now fail or API changes that could break existing callers — tag each as BLOCKER',
355
369
  '- What you checked but found to be unrelated',
356
370
  '',
371
+ '## Backward-compatibility testing & building the test library',
372
+ 'GROW THE LIBRARY OVER TIME: where coverage is missing for the behavior being verified,',
373
+ 'CREATE a targeted regression test so the suite accumulates over time. If NO test suite or',
374
+ 'test infrastructure exists yet, establish a MINIMAL one — a single test file plus the',
375
+ 'smallest runner wiring needed — do NOT stand up a heavy framework; keep it small and incremental.',
376
+ '',
377
+ 'HARD RESTRICTION: you may ONLY write TEST files — files whose names contain `.test.` or `.spec.`,',
378
+ 'or files located under a `__tests__/` or `tests/` directory.',
379
+ 'NEVER write source, config, or other files. The write-gate enforces this and will deny any non-test path.',
380
+ '',
357
381
  '## What NOT to do',
358
382
  '- Do NOT edit or write production files — report failures so the writer agent can fix them',
359
383
  '- Do NOT run destructive commands (no rm, no git push, no npm publish)',
@@ -382,6 +406,18 @@ export const NAMED_AGENTS = {
382
406
  'Turn a vague or large request into a precise, ordered implementation plan the writer can execute',
383
407
  'step by step without guessing. You are the bridge between "what" and "how".',
384
408
  '',
409
+ '## Grounding — plan against what already exists',
410
+ 'Before drafting a plan, locate the session index (search-index.txt under .claude/projects/<slug>/osb/<session>/; newest if several) and Grep it for prior DECISIONS, constraints, and known GOTCHAS relevant to the task. Read only the matching slice, never the whole file.',
411
+ 'Purpose: make the plan fit what has already been decided or tried — do not propose an approach the mission already ruled out. If a prior decision conflicts with the obvious plan, surface it in the plan rather than silently contradicting it.',
412
+ 'If you cannot find the index, proceed normally.',
413
+ '',
414
+ '## Documentation at delivery milestones',
415
+ 'Plans should account for documentation reconciliation at the point work is DELIVERED — when it ships,',
416
+ 'goes live, or reaches a release milestone. Include a step (or an explicit note in VERIFY) to check',
417
+ 'whether existing documentation still reflects what is changing, and flag it as a task if updates are',
418
+ 'needed. Do NOT schedule doc updates for every incremental step; anchor them to the delivery boundary',
419
+ 'so documentation stays stable between releases and does not silently drift out of sync when the work ships.',
420
+ '',
385
421
  '## How to work',
386
422
  '1. Read enough of the codebase to understand the current structure (Glob, Grep, Read).',
387
423
  '2. Identify every file that needs to change and why.',
@@ -439,10 +475,18 @@ export const NAMED_AGENTS = {
439
475
  '## Maintain documentation',
440
476
  'You MAY create and maintain project standards and conventions files. Specifically: adopt an existing',
441
477
  'standards doc if one is found, or create one (e.g. CONVENTIONS.md, docs/standards.md) when none exists',
442
- 'and the review reveals patterns worth recording. Keep documentation current as you review.',
478
+ 'and the review reveals patterns worth recording.',
479
+ '',
480
+ 'WHEN to reconcile docs: at the moment work is being DELIVERED — when a unit of work ships, goes live,',
481
+ 'or reaches a release milestone. That is the checkpoint to verify whether existing documentation still',
482
+ 'reflects what actually changed and update it if it has drifted. Do NOT update docs on every small',
483
+ 'incremental change; wait for the natural delivery boundary so docs stay stable between releases.',
484
+ 'Between delivery milestones, note discrepancies as findings in your verdict (MINOR or NIT) rather',
485
+ 'than immediately rewriting documentation.',
486
+ '',
443
487
  'RESTRICTION: you may ONLY write files with documentation extensions: .md, .markdown, .mdx, .txt, .rst, .adoc.',
444
- 'You must NEVER write code, config, or source files (.ts, .js, .json, .env, etc.) — the write gate',
445
- 'enforces this at the system level and will deny any such attempt.',
488
+ 'You must NEVER write source, config, or executable files — the write gate enforces this at the system',
489
+ 'level and will deny any such attempt.',
446
490
  '',
447
491
  '## Backward-compatibility mandate',
448
492
  'For every change, explicitly verify:',
@@ -453,6 +497,15 @@ export const NAMED_AGENTS = {
453
497
  '- Existing callers and consumers of the changed code still work',
454
498
  'Tag any violation BLOCKER — silent compat breaks are the hardest bugs to catch after the fact.',
455
499
  '',
500
+ '## Regression test suite — run and correlate',
501
+ 'If a backward-compatibility/regression test suite exists in the project (any test/ or __tests__/',
502
+ 'directory, *.test.* or *.spec.* files, or a test script in package.json), RUN IT as part of your',
503
+ 'review using Bash. Correlate the results into your verdict:',
504
+ '- A passing suite with no regressions: note it in WHAT IT CHECKED-AND-CLEARED.',
505
+ '- Any pre-existing test that now fails is a BLOCKER — include it as an ISSUES entry tagged BLOCKER.',
506
+ 'Do not skip this step when a suite exists; a failing regression test is a hard blocker regardless',
507
+ 'of whether the diff looks clean.',
508
+ '',
456
509
  '## How to work',
457
510
  '0. **Get the diff first (MANDATORY):** Run `git diff HEAD~1 HEAD --stat` then `git diff HEAD~1 HEAD`.',
458
511
  ' Build your entire review around what ACTUALLY changed — not the writer\'s narrative alone.',
@@ -1621,6 +1674,22 @@ class ClaudeLLMStream extends llm.LLMStream {
1621
1674
  console.log(`📝 Reviewer doc write allowed: ${reviewerPath}`);
1622
1675
  return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask' } };
1623
1676
  }
1677
+ // Tester agent: ONLY test files allowed — fail closed
1678
+ if (agentType === 'tester') {
1679
+ const testerPath = String(toolInput.file_path || '');
1680
+ const STRICT_TEST_FILE = /\.(test|spec)\.[jt]sx?$/;
1681
+ const resolvedBase = testerPath ? basename(resolve(testerPath)) : '';
1682
+ if (!testerPath || !STRICT_TEST_FILE.test(resolvedBase)) {
1683
+ const reason = testerPath
1684
+ ? `Tester write denied: ${testerPath} is not a test file (basename must match .test.ts/tsx/js/jsx or .spec.ts/tsx/js/jsx). Tester may only write test files.`
1685
+ : 'Tester write denied: could not determine target file path. Failing closed.';
1686
+ console.log(`🚫 Tester write blocked: ${testerPath || '(no path)'} — not a test file`);
1687
+ this.#eventEmitter.emit('tool_blocked', { name: toolName, reason });
1688
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' }, reason };
1689
+ }
1690
+ console.log(`🧪 Tester test-file write allowed: ${testerPath}`);
1691
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask' } };
1692
+ }
1624
1693
  // All other agents (main, researcher, reasoner, etc.): workspace only
1625
1694
  const filePath = String(toolInput.file_path || '');
1626
1695
  if (filePath && !filePath.includes('/osb/') && !filePath.includes('.osborn/sessions/') && !filePath.includes('.osborn/research/')) {
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,77 @@ 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, {
1500
+ 'Content-Type': 'text/html; charset=utf-8',
1501
+ 'Cache-Control': 'no-store, must-revalidate',
1502
+ 'Pragma': 'no-cache',
1503
+ });
1504
+ res.end(`<!DOCTYPE html><html><head>
1505
+ <meta charset="utf-8">
1506
+ <meta http-equiv="refresh" content="3;url=/editor">
1507
+ <title>Starting editor…</title>
1508
+ <style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#1e1e1e;color:#ccc}</style>
1509
+ </head><body><p>Starting your editor…</p></body></html>`);
1510
+ }
1511
+ return;
1512
+ }
1513
+ // ────────────────────────────────────────────────────────────────────────
1514
+ // ── IDE reverse proxy (cookie-gated fall-through) ────────────────────────
1515
+ // Forwards to code-server ONLY when:
1516
+ // 1. ideProxyEnabled is true (code-server is confirmed ready), AND
1517
+ // 2. the request carries a valid osborn_ide session cookie (primary gate), AND
1518
+ // 3. the path is not an agent route (secondary backstop).
1519
+ // A missing/invalid cookie means the request came from somewhere other than
1520
+ // the /editor door — refuse it so a forgotten agent route is never swallowed.
1521
+ if (ideProxyEnabled &&
1522
+ hasValidIdeCookie(req) &&
1523
+ !AGENT_ROUTE_PREFIXES.some(p => url.pathname === p || url.pathname.startsWith(p + '/'))) {
1524
+ ideLastProxiedActivity = Date.now();
1525
+ const targetUrl = new URL(req.url || '/', IDE_TARGET);
1526
+ const options = {
1527
+ hostname: '127.0.0.1',
1528
+ port: 8300,
1529
+ path: targetUrl.pathname + (targetUrl.search || ''),
1530
+ method: req.method,
1531
+ headers: { ...req.headers, host: '127.0.0.1:8300' },
1532
+ };
1533
+ const proxyReq = httpRequest(options, (proxyRes) => {
1534
+ res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
1535
+ proxyRes.pipe(res);
1536
+ });
1537
+ proxyReq.on('error', () => {
1538
+ if (!res.headersSent) {
1539
+ res.writeHead(502);
1540
+ res.end('IDE proxy error');
1541
+ }
1542
+ });
1543
+ req.pipe(proxyReq);
1544
+ return;
1545
+ }
1546
+ // ────────────────────────────────────────────────────────────────────────
1433
1547
  res.writeHead(404, { 'Content-Type': 'application/json' });
1434
1548
  res.end(JSON.stringify({ error: 'Not found' }));
1435
1549
  });
@@ -1467,11 +1581,53 @@ function startApiServer(workingDir, port) {
1467
1581
  };
1468
1582
  cleanStaleUploadDirs();
1469
1583
  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) => {
1584
+ // WebSocket upgrade handler: forward to code-server when the IDE proxy is
1585
+ // active, the request carries a valid osborn_ide session cookie, and the path
1586
+ // is not an agent route. code-server's terminal and editor are websocket-heavy
1587
+ // — this must work for the integrated terminal to connect.
1588
+ // Reject all other upgrades (meeting audio moved to polling in 0.9.xx).
1589
+ server.on('upgrade', (req, socket, head) => {
1590
+ const upgradePath = new URL(req.url || '/', `http://localhost`).pathname;
1591
+ const isAgentRoute = AGENT_ROUTE_PREFIXES.some(p => upgradePath === p || upgradePath.startsWith(p + '/'));
1592
+ if (ideProxyEnabled && hasValidIdeCookie(req) && !isAgentRoute) {
1593
+ ideLastProxiedActivity = Date.now();
1594
+ // Raw TCP tunnel: connect to code-server and splice the socket.
1595
+ // Pause the client socket before initiating the connect so that any
1596
+ // bytes the browser sends before the connect callback fires are buffered
1597
+ // by Node rather than silently dropped — the classic silent-hang shape
1598
+ // for a websocket terminal on a slow connect.
1599
+ socket.pause();
1600
+ const conn = netConnect(8300, '127.0.0.1', () => {
1601
+ // Forward the original HTTP upgrade request headers.
1602
+ const headerLines = [`${req.method} ${req.url} HTTP/1.1`];
1603
+ for (const [k, v] of Object.entries(req.headers)) {
1604
+ if (k.toLowerCase() === 'host')
1605
+ continue;
1606
+ if (Array.isArray(v)) {
1607
+ for (const vv of v)
1608
+ headerLines.push(`${k}: ${vv}`);
1609
+ }
1610
+ else if (v != null)
1611
+ headerLines.push(`${k}: ${v}`);
1612
+ }
1613
+ headerLines.push('host: 127.0.0.1:8300');
1614
+ conn.write(headerLines.join('\r\n') + '\r\n\r\n');
1615
+ if (head && head.length)
1616
+ conn.write(head);
1617
+ conn.pipe(socket);
1618
+ socket.pipe(conn);
1619
+ socket.resume();
1620
+ });
1621
+ conn.on('error', () => { try {
1622
+ socket.destroy();
1623
+ }
1624
+ catch { } });
1625
+ socket.on('error', () => { try {
1626
+ conn.destroy();
1627
+ }
1628
+ catch { } });
1629
+ return;
1630
+ }
1475
1631
  socket.destroy();
1476
1632
  });
1477
1633
  server.on('error', (err) => {
@@ -1990,6 +2146,19 @@ async function main() {
1990
2146
  // the fresh session's latch was never opened by the name).
1991
2147
  const meetingSpeakers = new Set();
1992
2148
  let activeMeetingPoller = null; // Transcript poller bound to that bot
2149
+ // ── IDE (code-server) state ──────────────────────────────────────────────
2150
+ // All fields reset together in stopIde(). Never auto-started; only spawned
2151
+ // by an explicit start_ide data-channel command from the frontend.
2152
+ // Cloudflared is gone — exposure is via the agent's own HTTP server proxy
2153
+ // (ideProxyEnabled at module scope, set/cleared by start_ide/stopIde).
2154
+ let ideCodeServerProc = null;
2155
+ let ideLastActivity = 0; // bumped on code-server stdout/stderr
2156
+ let ideIdleWatcher = null;
2157
+ // In-flight start promise: set for the duration of startCodeServer()'s async
2158
+ // body, cleared in its finally. Concurrent callers (e.g. /editor page refreshes
2159
+ // every 3s) return early instead of killing the process that is still booting.
2160
+ let ideStartInProgress = null;
2161
+ // ────────────────────────────────────────────────────────────────────────
1993
2162
  // LIVE meeting transcript → LLM (buffered webhook finals). See recall.on('transcript').
1994
2163
  const meetingTranscriptBuffer = [];
1995
2164
  let meetingFlushTimer = null;
@@ -2164,6 +2333,206 @@ async function main() {
2164
2333
  }
2165
2334
  }, MEETING_MAX_MS);
2166
2335
  };
2336
+ // ── IDE helpers ─────────────────────────────────────────────────────────
2337
+ // ensureCodeServer() — idempotent: installs code-server if missing.
2338
+ // Cloudflared is no longer downloaded here (exposure via agent's own HTTP proxy).
2339
+ // Emits ide_status:'installing' before any long download so the frontend can
2340
+ // show a spinner. (~36s + ~740MB first run, once per machine lifetime).
2341
+ const ensureCodeServer = async () => {
2342
+ // Resolve code-server binary
2343
+ const which = await new Promise((res) => {
2344
+ const p = spawn('which', ['code-server']);
2345
+ let out = '';
2346
+ p.stdout?.on('data', (d) => { out += d.toString(); });
2347
+ p.on('close', () => res(out.trim()));
2348
+ });
2349
+ if (!which) {
2350
+ await sendToFrontend({ type: 'ide_status', status: 'installing' });
2351
+ console.log('⬇️ Installing code-server via official script...');
2352
+ await new Promise((res, rej) => {
2353
+ const install = spawn('sh', ['-c', 'curl -fsSL https://code-server.dev/install.sh | sh'], {
2354
+ env: { ...process.env },
2355
+ stdio: ['ignore', 'pipe', 'pipe'],
2356
+ });
2357
+ install.stdout?.on('data', (d) => process.stdout.write(d));
2358
+ install.stderr?.on('data', (d) => process.stderr.write(d));
2359
+ install.on('close', (code) => code === 0 ? res() : rej(new Error(`code-server install exited ${code}`)));
2360
+ });
2361
+ }
2362
+ };
2363
+ // Kill code-server, disable proxy, clear interval, reset IDE state.
2364
+ const stopIde = (reason) => {
2365
+ ideProxyEnabled = false;
2366
+ ideSessionToken = null;
2367
+ ideStartInProgress = null;
2368
+ if (ideIdleWatcher) {
2369
+ clearInterval(ideIdleWatcher);
2370
+ ideIdleWatcher = null;
2371
+ }
2372
+ if (ideCodeServerProc) {
2373
+ try {
2374
+ ideCodeServerProc.kill();
2375
+ }
2376
+ catch { }
2377
+ ideCodeServerProc = null;
2378
+ }
2379
+ ideLastActivity = 0;
2380
+ ideLastProxiedActivity = 0;
2381
+ const msg = { type: 'ide_stopped' };
2382
+ if (reason)
2383
+ msg.reason = reason;
2384
+ sendToFrontend(msg).catch(() => { });
2385
+ console.log(`🛑 IDE stopped${reason ? ` (${reason})` : ''}`);
2386
+ };
2387
+ // Idle watcher: poll every 60s; stop IDE if no proxied HTTP/WS activity
2388
+ // within the threshold. Uses ideLastProxiedActivity (real editor traffic seen
2389
+ // by the agent's proxy) as the primary signal — more reliable than log noise
2390
+ // on code-server's stdout/stderr. Falls back to ideLastActivity (process output)
2391
+ // if no proxied activity has ever been recorded (pre-first-browser-open).
2392
+ const startIdeIdleWatcher = () => {
2393
+ if (ideIdleWatcher) {
2394
+ clearInterval(ideIdleWatcher);
2395
+ ideIdleWatcher = null;
2396
+ }
2397
+ const idleMinutes = Math.max(1, parseInt(process.env.OSBORN_IDE_IDLE_MIN || '10', 10));
2398
+ const idleMs = idleMinutes * 60 * 1000;
2399
+ ideIdleWatcher = setInterval(() => {
2400
+ if (!ideCodeServerProc) {
2401
+ clearInterval(ideIdleWatcher);
2402
+ ideIdleWatcher = null;
2403
+ return;
2404
+ }
2405
+ // Prefer proxied-activity timestamp; fall back to process output timestamp.
2406
+ const lastSeen = ideLastProxiedActivity > 0 ? ideLastProxiedActivity : ideLastActivity;
2407
+ const elapsed = Date.now() - lastSeen;
2408
+ if (elapsed > idleMs) {
2409
+ console.log(`⏱️ IDE idle for ${Math.round(elapsed / 60000)} min — stopping`);
2410
+ stopIde('idle');
2411
+ }
2412
+ }, 60_000);
2413
+ };
2414
+ // ── startCodeServer() ───────────────────────────────────────────────────────
2415
+ // Shared helper called by both the start_ide data-channel handler and the
2416
+ // GET /editor HTTP route. Idempotent: no-op if code-server is already running
2417
+ // and the proxy is enabled. On fresh start it:
2418
+ // 1. ensureCodeServer() — installs if missing
2419
+ // 2. probes --idle-timeout-seconds flag support
2420
+ // 3. spawns code-server on 127.0.0.1:8300 with --auth none, cwd /workspace
2421
+ // 4. polls until ready (up to 20 s)
2422
+ // 5. mints ideSessionToken (the cookie marker) and sets ideProxyEnabled = true
2423
+ const startCodeServer = async () => {
2424
+ // Fast path 1: already fully up — nothing to do.
2425
+ if (ideCodeServerProc && ideProxyEnabled) {
2426
+ console.log('🖥️ IDE already running — reusing existing instance');
2427
+ return;
2428
+ }
2429
+ // Fast path 2: a start is already in progress — join it instead of killing
2430
+ // the process that is still booting (prevents the /editor 3s-refresh self-DoS).
2431
+ if (ideStartInProgress) {
2432
+ console.log('🖥️ IDE start already in progress — awaiting in-flight promise');
2433
+ return ideStartInProgress;
2434
+ }
2435
+ // Fresh start: kill any stale proc (shouldn't normally exist here, but be safe).
2436
+ if (ideCodeServerProc)
2437
+ stopIde();
2438
+ let resolveFn;
2439
+ let rejectFn;
2440
+ ideStartInProgress = new Promise((res, rej) => { resolveFn = res; rejectFn = rej; });
2441
+ try {
2442
+ await ensureCodeServer();
2443
+ // Probe whether this code-server version supports --idle-timeout-seconds
2444
+ // (landed ~Oct 2025; older installs predate it). Only pass the flag if
2445
+ // the binary reports it — an unknown flag prevents code-server from starting.
2446
+ const idleMinutes = Math.max(1, parseInt(process.env.OSBORN_IDE_IDLE_MIN || '10', 10));
2447
+ const idleSeconds = idleMinutes * 60;
2448
+ const supportsIdleFlag = await new Promise((res) => {
2449
+ const h = spawn('sh', ['-c', 'code-server --help 2>&1 | grep -q idle-timeout-seconds && echo yes || echo no'], {
2450
+ env: { ...process.env }, stdio: ['ignore', 'pipe', 'pipe'],
2451
+ });
2452
+ let out = '';
2453
+ h.stdout?.on('data', (d) => { out += d.toString(); });
2454
+ h.on('close', () => res(out.trim() === 'yes'));
2455
+ });
2456
+ console.log(`🖥️ code-server idle-timeout-seconds flag: ${supportsIdleFlag ? 'supported' : 'absent (using watcher)'}`);
2457
+ // Spawn code-server bound to 127.0.0.1:8300 (never 8741).
2458
+ console.log('🖥️ Spawning code-server on 127.0.0.1:8300...');
2459
+ const csArgs = [
2460
+ '--bind-addr', '127.0.0.1:8300',
2461
+ '--auth', 'none',
2462
+ '/workspace',
2463
+ ];
2464
+ if (supportsIdleFlag) {
2465
+ csArgs.push('--idle-timeout-seconds', String(Math.max(60, idleSeconds)));
2466
+ }
2467
+ const codeServerProc = spawn('code-server', csArgs, {
2468
+ env: { ...process.env },
2469
+ stdio: ['ignore', 'pipe', 'pipe'],
2470
+ });
2471
+ ideCodeServerProc = codeServerProc;
2472
+ ideLastActivity = Date.now();
2473
+ ideLastProxiedActivity = 0;
2474
+ // Bump lastActivity on any output from code-server (fallback idle signal).
2475
+ codeServerProc.stdout?.on('data', () => { ideLastActivity = Date.now(); });
2476
+ codeServerProc.stderr?.on('data', () => { ideLastActivity = Date.now(); });
2477
+ codeServerProc.on('exit', (code) => {
2478
+ console.log(`🖥️ code-server exited (${code})`);
2479
+ if (ideCodeServerProc === codeServerProc)
2480
+ stopIde();
2481
+ });
2482
+ // Poll until code-server is ready (302 or 200 on the root, up to 20s).
2483
+ // Minor fix: if the process we spawned has been replaced or killed mid-poll,
2484
+ // reject immediately rather than burning the rest of the 20s timeout.
2485
+ await new Promise((res, rej) => {
2486
+ let attempts = 0;
2487
+ const poll = setInterval(() => {
2488
+ // Early-exit: the proc we spawned is no longer the active one (stopIde
2489
+ // was called, or a concurrent path replaced it).
2490
+ if (ideCodeServerProc !== codeServerProc) {
2491
+ clearInterval(poll);
2492
+ rej(new Error('code-server process was replaced or stopped mid-poll'));
2493
+ return;
2494
+ }
2495
+ attempts++;
2496
+ const check = spawn('curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', 'http://127.0.0.1:8300']);
2497
+ let out = '';
2498
+ check.stdout?.on('data', (d) => { out += d.toString(); });
2499
+ check.on('close', () => {
2500
+ const httpCode = parseInt(out.trim(), 10);
2501
+ if (httpCode === 200 || httpCode === 302 || httpCode === 301) {
2502
+ clearInterval(poll);
2503
+ res();
2504
+ }
2505
+ else if (attempts >= 40) { // 40 * 500ms = 20s
2506
+ clearInterval(poll);
2507
+ rej(new Error('code-server did not become ready within 20s'));
2508
+ }
2509
+ });
2510
+ }, 500);
2511
+ });
2512
+ console.log('✅ code-server ready on 8300');
2513
+ // Mint the session cookie token and enable the proxy. From this point the
2514
+ // /editor route will 302+Set-Cookie and the fall-through proxy will only
2515
+ // forward requests that carry the valid osborn_ide cookie.
2516
+ ideSessionToken = randomBytes(24).toString('hex');
2517
+ ideProxyEnabled = true;
2518
+ // Start idle watcher only when the native idle flag is absent.
2519
+ if (!supportsIdleFlag) {
2520
+ startIdeIdleWatcher();
2521
+ }
2522
+ resolveFn();
2523
+ }
2524
+ catch (err) {
2525
+ rejectFn(err);
2526
+ throw err;
2527
+ }
2528
+ finally {
2529
+ ideStartInProgress = null;
2530
+ }
2531
+ };
2532
+ // Wire the module-scope hook so startApiServer()'s /editor route can call
2533
+ // startCodeServer() without reaching into main()'s closure directly.
2534
+ codeServerStartHook = startCodeServer;
2535
+ // ────────────────────────────────────────────────────────────────────────
2167
2536
  // Track the active resume session ID across scopes (ParticipantConnected + DataReceived)
2168
2537
  // Updated by resume_session, session_selected, continue_session, switch_session handlers
2169
2538
  let currentResumeSessionId;
@@ -4900,6 +5269,11 @@ async function main() {
4900
5269
  armMeetingLeaveGrace(); // no-op when no meeting, kept for symmetry
4901
5270
  armFastLeaveTimer();
4902
5271
  }
5272
+ // Tear down IDE if one is running — user has disconnected, no point keeping
5273
+ // code-server alive for a departed session.
5274
+ if (ideCodeServerProc) {
5275
+ stopIde();
5276
+ }
4903
5277
  console.log('⏳ Waiting for new user...\n');
4904
5278
  };
4905
5279
  const handleDataReceived = async (payload, participant, kind, topic) => {
@@ -5663,6 +6037,32 @@ async function main() {
5663
6037
  await sendToFrontend({ type: 'meeting_error', message: err.message });
5664
6038
  }
5665
6039
  }
6040
+ // ── IDE (code-server) handlers ──────────────────────────────────────
6041
+ else if (data.type === 'start_ide') {
6042
+ try {
6043
+ await startCodeServer();
6044
+ // Build the /editor entry-point URL for the frontend to open.
6045
+ const flyAppHost = process.env.FLY_APP_NAME
6046
+ ? `${process.env.FLY_APP_NAME}.fly.dev`
6047
+ : (process.env.OSBORN_PUBLIC_HOST || `localhost:${apiPort}`);
6048
+ const ideUrl = process.env.FLY_APP_NAME
6049
+ ? `https://${flyAppHost}/editor`
6050
+ : `http://${flyAppHost}/editor`;
6051
+ console.log(`✅ IDE proxy ready: ${ideUrl}`);
6052
+ await sendToFrontend({ type: 'ide_ready', url: ideUrl });
6053
+ }
6054
+ catch (err) {
6055
+ console.error('❌ start_ide error:', err);
6056
+ // Clean up any partial state.
6057
+ if (ideCodeServerProc)
6058
+ stopIde();
6059
+ await sendToFrontend({ type: 'ide_error', error: err.message || String(err) });
6060
+ }
6061
+ }
6062
+ else if (data.type === 'stop_ide') {
6063
+ stopIde();
6064
+ }
6065
+ // ───────────────────────────────────────────────────────────────────
5666
6066
  else if (data.type === 'session_selected') {
5667
6067
  const sessionId = data.sessionId;
5668
6068
  console.log(`🚪 Session gate completed: ${sessionId ? `resume ${sessionId}` : 'fresh start'}`);
@@ -5815,6 +6215,45 @@ async function main() {
5815
6215
  slots: [...focusedEntry, ...bgEntries],
5816
6216
  });
5817
6217
  }
6218
+ else if (data.type === 'list_processes') {
6219
+ // Read process list from /proc. Each numeric subdir is a PID.
6220
+ // We read /proc/<pid>/comm (process name) and VmRSS from /proc/<pid>/status.
6221
+ // Skips any PID that errors mid-scan (race: process may have exited).
6222
+ try {
6223
+ const procEntries = readdirSync('/proc');
6224
+ const processes = [];
6225
+ for (const entry of procEntries) {
6226
+ if (!/^\d+$/.test(entry))
6227
+ continue;
6228
+ const pid = parseInt(entry, 10);
6229
+ try {
6230
+ const comm = readFileSync(`/proc/${entry}/comm`, 'utf8').trim();
6231
+ const status = readFileSync(`/proc/${entry}/status`, 'utf8');
6232
+ const vmRssMatch = status.match(/^VmRSS:\s*(\d+)\s*kB/m);
6233
+ const rssMb = vmRssMatch ? Math.round(parseInt(vmRssMatch[1], 10) / 1024) : 0;
6234
+ processes.push({ pid, name: comm, rssMb });
6235
+ }
6236
+ catch {
6237
+ // Process exited between readdir and read — skip it
6238
+ }
6239
+ }
6240
+ // Sort by resident memory descending, cap at 40 entries
6241
+ processes.sort((a, b) => b.rssMb - a.rssMb);
6242
+ const topProcesses = processes.slice(0, 40);
6243
+ // Reuse the existing os module memory totals (same source as /health endpoint)
6244
+ const totalMb = Math.round(totalmem() / 1024 / 1024);
6245
+ const freeMb = Math.round(freemem() / 1024 / 1024);
6246
+ const usedMb = totalMb - freeMb;
6247
+ await sendToFrontend({
6248
+ type: 'process_list',
6249
+ processes: topProcesses,
6250
+ memory: { usedMb, totalMb, freeMb },
6251
+ });
6252
+ }
6253
+ catch (err) {
6254
+ console.error('❌ list_processes: failed to read /proc:', err);
6255
+ }
6256
+ }
5818
6257
  }
5819
6258
  catch { }
5820
6259
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.149",
3
+ "version": "0.9.151",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {