osborn 0.9.152 → 0.9.154

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.
@@ -45,7 +45,8 @@ RUN apt-get update -qq && \
45
45
  git \
46
46
  make \
47
47
  g++ \
48
- python-is-python3 && \
48
+ python-is-python3 \
49
+ util-linux && \
49
50
  rm -rf /var/lib/apt/lists/*
50
51
 
51
52
  ARG OSBORN_VERSION=latest
@@ -122,6 +123,25 @@ fi
122
123
  echo "[sandbox-d] === boot at $(date -Iseconds) ===" >> "$LOGFILE"
123
124
  exec > >(tee -a "$LOGFILE") 2>&1
124
125
 
126
+ # === Swap on volume ===
127
+ # 8GB swapfile on /workspace (persistent volume). Created once on first boot,
128
+ # reactivated on every subsequent boot (swapon state is lost across cold starts).
129
+ # Gives 8GB RAM + 8GB swap = 16GB addressable — pressure relief when the dev
130
+ # server or concurrent sub-agents spike memory without killing the voice loop.
131
+ SWAPFILE=/workspace/swapfile
132
+ if [ ! -f "$SWAPFILE" ]; then
133
+ echo "[sandbox-d] Creating 8GB swapfile at $SWAPFILE..."
134
+ fallocate -l 8G "$SWAPFILE" 2>/dev/null || dd if=/dev/zero of="$SWAPFILE" bs=1M count=8192 status=none
135
+ chmod 600 "$SWAPFILE"
136
+ mkswap "$SWAPFILE"
137
+ echo "[sandbox-d] Swapfile created"
138
+ fi
139
+ if swapon "$SWAPFILE" 2>/dev/null; then
140
+ echo "[sandbox-d] Swap active: $(free -h | grep Swap)"
141
+ else
142
+ echo "[sandbox-d] swapon skipped (already active or kernel restriction)"
143
+ fi
144
+
125
145
  # Onboarding-suppression JSON
126
146
  ONBOARDING_JSON='{"numStartups":10,"installMethod":"npm","autoUpdates":false,"hasCompletedOnboarding":true,"hasTrustDialogAccepted":true,"hasTrustDialogHooksAccepted":true,"hasCompletedProjectOnboarding":true,"hasAcknowledgedCostThreshold":true,"effortCalloutV2Dismissed":true,"theme":"dark","projects":{"/workspace":{"hasTrustDialogAccepted":true,"hasTrustDialogHooksAccepted":true,"hasCompletedProjectOnboarding":true}}}'
127
147
 
package/dist/index.js CHANGED
@@ -298,6 +298,12 @@ wsProxy.on('error', (err, _req, socket) => {
298
298
  // valid cookie are forwarded, so a forgotten agent route can never be silently
299
299
  // swallowed by the IDE proxy. Cleared in stopIde().
300
300
  let ideSessionToken = null;
301
+ // Cancellation-generation guard for startCodeServer(). Incremented by stopIde()
302
+ // every time the IDE is torn down. startCodeServer() captures myGen at entry;
303
+ // if ideStartGeneration !== myGen when the poll resolves, the start has been
304
+ // superseded (stopIde ran mid-flight) and the result is discarded instead of
305
+ // re-enabling the proxy with no participant present.
306
+ let ideStartGeneration = 0;
301
307
  // Helper: parse the osborn_ide cookie from a request and return true iff it
302
308
  // matches the current ideSessionToken (and a token exists).
303
309
  function hasValidIdeCookie(req) {
@@ -1539,7 +1545,7 @@ function startApiServer(workingDir, port) {
1539
1545
  port: 8300,
1540
1546
  path: targetUrl.pathname + (targetUrl.search || ''),
1541
1547
  method: req.method,
1542
- headers: { ...req.headers, host: '127.0.0.1:8300' },
1548
+ headers: { ...req.headers, host: '127.0.0.1:8300', 'x-forwarded-host': req.headers.host },
1543
1549
  };
1544
1550
  const proxyReq = httpRequest(options, (proxyRes) => {
1545
1551
  res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
@@ -1602,6 +1608,13 @@ function startApiServer(workingDir, port) {
1602
1608
  const isAgentRoute = AGENT_ROUTE_PREFIXES.some(p => upgradePath === p || upgradePath.startsWith(p + '/'));
1603
1609
  if (ideProxyEnabled && hasValidIdeCookie(req) && !isAgentRoute) {
1604
1610
  ideLastProxiedActivity = Date.now();
1611
+ // code-server's authenticateOrigin() compares Origin against the effective host
1612
+ // (X-Forwarded-Host first). changeOrigin:true rewrites Host→127.0.0.1:8300 while the
1613
+ // browser's Origin stays the public hostname, causing a 403 → WS 1006. Preserve the
1614
+ // real public host so the origin check matches.
1615
+ if (!req.headers['x-forwarded-host'] && req.headers.host) {
1616
+ req.headers['x-forwarded-host'] = req.headers.host;
1617
+ }
1605
1618
  wsProxy.ws(req, socket, head, { target: IDE_TARGET });
1606
1619
  return;
1607
1620
  }
@@ -2339,6 +2352,9 @@ async function main() {
2339
2352
  };
2340
2353
  // Kill code-server, disable proxy, clear interval, reset IDE state.
2341
2354
  const stopIde = (reason) => {
2355
+ // Bump the generation counter so any in-flight startCodeServer() knows it
2356
+ // has been cancelled and must not re-enable the proxy or mint a new token.
2357
+ ideStartGeneration++;
2342
2358
  ideProxyEnabled = false;
2343
2359
  ideSessionToken = null;
2344
2360
  ideStartInProgress = null;
@@ -2412,6 +2428,11 @@ async function main() {
2412
2428
  // Fresh start: kill any stale proc (shouldn't normally exist here, but be safe).
2413
2429
  if (ideCodeServerProc)
2414
2430
  stopIde();
2431
+ // Capture the current generation AFTER any preceding stopIde() call so that
2432
+ // if stopIde() already ran (above), myGen reflects the incremented counter.
2433
+ // A concurrent stopIde() after this point will increment ideStartGeneration
2434
+ // beyond myGen, signalling cancellation to the poll loop and success branch.
2435
+ const myGen = ideStartGeneration;
2415
2436
  let resolveFn;
2416
2437
  let rejectFn;
2417
2438
  ideStartInProgress = new Promise((res, rej) => { resolveFn = res; rejectFn = rej; });
@@ -2462,11 +2483,13 @@ async function main() {
2462
2483
  await new Promise((res, rej) => {
2463
2484
  let attempts = 0;
2464
2485
  const poll = setInterval(() => {
2465
- // Early-exit: the proc we spawned is no longer the active one (stopIde
2466
- // was called, or a concurrent path replaced it).
2467
- if (ideCodeServerProc !== codeServerProc) {
2486
+ // Early-exit: stopIde() was called while we were waiting (generation bumped),
2487
+ // OR the proc we spawned is no longer the active one for another reason.
2488
+ // Either way, cancel the poll immediately so we don't keep hitting 8300
2489
+ // for a boot that has already been invalidated.
2490
+ if (ideStartGeneration !== myGen || ideCodeServerProc !== codeServerProc) {
2468
2491
  clearInterval(poll);
2469
- rej(new Error('code-server process was replaced or stopped mid-poll'));
2492
+ rej(new Error('code-server start cancelled (stopIde ran mid-poll)'));
2470
2493
  return;
2471
2494
  }
2472
2495
  attempts++;
@@ -2487,6 +2510,25 @@ async function main() {
2487
2510
  }, 500);
2488
2511
  });
2489
2512
  console.log('✅ code-server ready on 8300');
2513
+ // Generation guard: check again right before enabling the proxy. The poll
2514
+ // already checks this on each tick, but there is a narrow window between the
2515
+ // last tick resolving and reaching here. If stopIde() ran in that window,
2516
+ // ideStartGeneration will have been incremented beyond myGen — we must NOT
2517
+ // enable the proxy or mint a token, and we must kill the orphan process so
2518
+ // no zombie code-server is left running with no participant present.
2519
+ if (ideStartGeneration !== myGen) {
2520
+ console.log('🖥️ IDE start cancelled (stopIde ran while code-server was booting) — killing orphan proc');
2521
+ try {
2522
+ codeServerProc.kill();
2523
+ }
2524
+ catch { }
2525
+ // ideCodeServerProc may already be null (stopIde cleared it); only null it
2526
+ // if it still points to our proc to avoid clobbering a newer start.
2527
+ if (ideCodeServerProc === codeServerProc)
2528
+ ideCodeServerProc = null;
2529
+ resolveFn(); // resolve (not reject) so the promise chain exits cleanly
2530
+ return;
2531
+ }
2490
2532
  // Mint the session cookie token and enable the proxy. From this point the
2491
2533
  // /editor route will 302+Set-Cookie and the fall-through proxy will only
2492
2534
  // forward requests that carry the valid osborn_ide cookie.
@@ -2667,7 +2709,7 @@ async function main() {
2667
2709
  const { readSessionHistory } = await import('./session-access.js');
2668
2710
  const history = readSessionHistory(sessionId, workingDir, {
2669
2711
  lastN: 10,
2670
- types: ['assistant'],
2712
+ types: ['assistant', 'tool_use'], // tool_use entries also carry Claude's spoken text
2671
2713
  });
2672
2714
  recentMessages = history
2673
2715
  .filter((m) => m.text)
@@ -2683,8 +2725,14 @@ async function main() {
2683
2725
  // BEFORE the previous TTS completed, and we may have already suppressed in-flight
2684
2726
  // tts_say events that arrived during that overlap).
2685
2727
  const carriedSuppressed = lastInterruption?.suppressedText ?? '';
2686
- lastInterruption = { spokenText: fullText, recentMessages, suppressedText: carriedSuppressed, timestamp: Date.now() };
2687
- console.log(`📋 Interruption context stored (text: ${fullText.length} chars, JSONL: ${recentMessages.length} chars, suppressed carried: ${carriedSuppressed.length} chars)`);
2728
+ lastInterruption = {
2729
+ spokenText: fullText,
2730
+ recentMessages,
2731
+ suppressedText: carriedSuppressed,
2732
+ fullBlockText: fullBlockText && fullBlockText !== fullText ? fullBlockText : undefined,
2733
+ timestamp: Date.now(),
2734
+ };
2735
+ console.log(`📋 Interruption context stored (text: ${fullText.length} chars, JSONL: ${recentMessages.length} chars, block: ${fullBlockText?.length ?? 0} chars, suppressed carried: ${carriedSuppressed.length} chars)`);
2688
2736
  }
2689
2737
  /**
2690
2738
  * Append text the agent tried to say while the user was speaking, but which we
@@ -2724,6 +2772,7 @@ async function main() {
2724
2772
  spokenText: lastInterruption.spokenText,
2725
2773
  recentMessages: lastInterruption.recentMessages,
2726
2774
  suppressedText: lastInterruption.suppressedText,
2775
+ fullBlockText: lastInterruption.fullBlockText,
2727
2776
  };
2728
2777
  lastInterruption = null;
2729
2778
  return ctx;
@@ -21,6 +21,8 @@ export interface InterruptionContext {
21
21
  * and can re-articulate the relevant bits in its next response.
22
22
  */
23
23
  suppressedText: string;
24
+ /** Full text of the interrupted TTS block — fallback when JSONL read returns empty */
25
+ fullBlockText?: string;
24
26
  }
25
27
  export interface PipelineDirectOptions extends ClaudeLLMOptions {
26
28
  onFastBrainResult?: (result: FastBrainPanelResult) => void;
@@ -204,7 +204,10 @@ export class PipelineDirectLLM extends llm.LLM {
204
204
  `Anything in "Your recent messages" below that appears AFTER the quoted heard text is content the user did not hear. The user has no memory of it.`,
205
205
  ``,
206
206
  `Your recent messages (full untruncated — you wrote these):`,
207
- interruptCtx.recentMessages || '(no recent messages found)',
207
+ interruptCtx.recentMessages
208
+ || (interruptCtx.fullBlockText
209
+ ? `(JSONL unavailable — full TTS block text follows)\n"${interruptCtx.fullBlockText}"`
210
+ : '(no recent messages found)'),
208
211
  suppressedBlock,
209
212
  ``,
210
213
  `User's message: "${userText}"`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.152",
3
+ "version": "0.9.154",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {