osborn 0.9.251 → 0.9.252-swap.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2016,6 +2016,12 @@ async function main() {
2016
2016
  let participantConnectedHandler = null;
2017
2017
  let currentAgent = null; // For updateChatCtx() context injection
2018
2018
  let currentLLM = null;
2019
+ // Voice bus for instance-swap handoff (assigned in createDirectSession). Declared
2020
+ // here so the swap_foreground handler (different scope) can re-pipe the voice to a
2021
+ // promoted background slot. See the "Voice bus" block in createDirectSession.
2022
+ let voiceBus = null;
2023
+ let unpipeVoice = null;
2024
+ let pipeVoiceFrom = null;
2019
2025
  const slots = new Map();
2020
2026
  let focusedSlotId = null; // reserved for future focused-slot wiring; not used yet
2021
2027
  // P1 host-agent anchor: the dashboard-started session that owns the room's env + keeps it alive.
@@ -2212,6 +2218,44 @@ async function main() {
2212
2218
  // Output events are forwarded to the frontend data channel only,
2213
2219
  // tagged with the slot id so the frontend can distinguish them.
2214
2220
  // ============================================================
2221
+ // Attach a background slot's UI forwarders to the emitter its consumer emits on, and
2222
+ // return a disposer. Forwards turn events to the frontend tagged with slotId but
2223
+ // DELIBERATELY never forwards tts_say — a background slot must stay silent. Shared by
2224
+ // spawnBackgroundSession (fresh slot) and the swap demote path (adopted old foreground)
2225
+ // so both slot kinds behave identically, and so promotion can cleanly stop background
2226
+ // forwarding (call the disposer) before re-piping that same emitter to the voice bus.
2227
+ function attachBackgroundForwarders(emitter, slotId) {
2228
+ const handlers = [];
2229
+ const on = (ev, fn) => { emitter.on(ev, fn); handlers.push([ev, fn]); };
2230
+ on('assistant_text', ({ text }) => { sendToFrontend({ type: 'claude_output', slotId, text }).catch(() => { }); });
2231
+ on('tool_use', ({ name, input, agentRole, agentId }) => { sendToFrontend({ type: 'tool_use', slotId, name, input, agentRole, agentId }).catch(() => { }); });
2232
+ on('tool_result', ({ name, response, agentRole, agentId }) => { sendToFrontend({ type: 'tool_result', slotId, name, response, agentRole, agentId }).catch(() => { }); });
2233
+ on('tool_blocked', ({ name, reason }) => { sendToFrontend({ type: 'tool_blocked', slotId, name, reason }).catch(() => { }); });
2234
+ on('session_id', ({ sessionId: sid }) => { sendToFrontend({ type: 'background_session_ready', slotId, sessionId: sid }).catch(() => { }); });
2235
+ on('session_resume_failed', ({ requestedSessionId, actualSessionId }) => {
2236
+ console.error(`❌ Background slot ${slotId.substring(0, 8)}: resume failed — requested ${requestedSessionId?.substring(0, 8)} got ${actualSessionId?.substring(0, 8)}`);
2237
+ sendToFrontend({ type: 'background_session_error', slotId, error: 'Session resume failed' }).catch(() => { });
2238
+ });
2239
+ on('consumer_error', ({ message }) => {
2240
+ console.error(`❌ Background slot ${slotId.substring(0, 8)}: consumer error — ${message}`);
2241
+ sendToFrontend({ type: 'background_session_error', slotId, error: String(message || 'Background session failed to start') }).catch(() => { });
2242
+ });
2243
+ on('assistant_result', ({ text }) => {
2244
+ const slot = slots.get(slotId);
2245
+ if (!slot)
2246
+ return;
2247
+ if (!slot.kickoffDone) {
2248
+ slot.kickoffDone = true;
2249
+ return;
2250
+ }
2251
+ if (!slot.dispatchInFlight)
2252
+ return;
2253
+ slot.dispatchInFlight = false;
2254
+ sendToFrontend({ type: 'background_update', sessionId: slotId, stage: 'done', summary: (text || '').trim().slice(0, 500) }).catch(() => { });
2255
+ });
2256
+ return () => { for (const [ev, fn] of handlers)
2257
+ emitter.off(ev, fn); };
2258
+ }
2215
2259
  async function spawnBackgroundSession(sessionId, bgWorkingDir) {
2216
2260
  // Hard cap: focused session + background slots must not exceed 3 total.
2217
2261
  // slots Map holds ONLY background slots; focused session is separate.
@@ -2224,73 +2268,30 @@ async function main() {
2224
2268
  const slotWorkingDir = bgWorkingDir || workingDir;
2225
2269
  const slotId = sessionId; // use sessionId as the slot id for traceability
2226
2270
  console.log(`🔲 Spawning background slot: sessionId=${slotId.substring(0, 8)} cwd=${slotWorkingDir}`);
2227
- // Create a headless LLM instance — reuse the same factory, no voice options.
2271
+ // Create the headless LLM in the SAME SHAPE as the foreground inner so it can be
2272
+ // promoted to the voice session with a simple re-pipe (no reconfiguration):
2273
+ // skipTTSQueue: true → its consumer emits `tts_say` (speech), exactly like the
2274
+ // foreground, instead of queue.put. All events land on the instance's own
2275
+ // `.events` emitter (the unified bus), so promote = pipe the voice bus from
2276
+ // slot.llm.events; background = forward those same events to the UI minus tts_say.
2228
2277
  // permissionMode bypassPermissions: no dialogs (headless — nobody to click Allow).
2229
- // No AgentSession, no room involvement.
2230
2278
  const bgLLM = createClaudeLLM({
2231
2279
  workingDirectory: slotWorkingDir,
2232
2280
  sessionBaseDir: slotWorkingDir,
2233
2281
  resumeSessionId: sessionId,
2234
2282
  voiceMode: 'direct', // picks up the research system prompt
2235
- skipTTSQueue: false, // not going through TTS at all
2283
+ skipTTSQueue: true, // emit tts_say like the foreground (promotable)
2236
2284
  permissionMode: 'bypassPermissions',
2237
2285
  });
2238
2286
  // Register slot BEFORE cold-starting so list_slots is accurate from the
2239
2287
  // first moment even if the subprocess takes a few seconds to init.
2240
2288
  const slot = { id: slotId, llm: bgLLM, isFocused: false, workingDir: slotWorkingDir };
2241
2289
  slots.set(slotId, slot);
2242
- // Dedicated EventEmitter for this background slot's SDK events.
2243
- // Forwards every event to the frontend data channel tagged with slotId.
2244
- // NEVER touches session.say() / TTS / voice.
2245
- const { EventEmitter: SlotEventEmitter } = await import('node:events');
2246
- const bgEmitter = new SlotEventEmitter();
2247
- bgEmitter.on('assistant_text', ({ text }) => {
2248
- sendToFrontend({ type: 'claude_output', slotId, text }).catch(() => { });
2249
- });
2250
- bgEmitter.on('tool_use', ({ name, input, agentRole }) => {
2251
- sendToFrontend({ type: 'tool_use', slotId, name, input, agentRole }).catch(() => { });
2252
- });
2253
- bgEmitter.on('tool_result', ({ name, response, agentRole }) => {
2254
- sendToFrontend({ type: 'tool_result', slotId, name, response, agentRole }).catch(() => { });
2255
- });
2256
- bgEmitter.on('tool_blocked', ({ name, reason }) => {
2257
- sendToFrontend({ type: 'tool_blocked', slotId, name, reason }).catch(() => { });
2258
- });
2259
- bgEmitter.on('session_id', ({ sessionId: sid }) => {
2260
- sendToFrontend({ type: 'background_session_ready', slotId, sessionId: sid }).catch(() => { });
2261
- });
2262
- bgEmitter.on('session_resume_failed', ({ requestedSessionId, actualSessionId }) => {
2263
- console.error(`❌ Background slot ${slotId.substring(0, 8)}: resume failed — requested ${requestedSessionId?.substring(0, 8)} got ${actualSessionId?.substring(0, 8)}`);
2264
- sendToFrontend({ type: 'background_session_error', slotId, error: 'Session resume failed' }).catch(() => { });
2265
- });
2266
- // Cold-start crash in the background consumer (claude-llm #startBackgroundConsumer
2267
- // catch): surface it so the frontend's "Spawning…" clears instead of hanging forever.
2268
- bgEmitter.on('consumer_error', ({ message }) => {
2269
- console.error(`❌ Background slot ${slotId.substring(0, 8)}: consumer error — ${message}`);
2270
- sendToFrontend({ type: 'background_session_error', slotId, error: String(message || 'Background session failed to start') }).catch(() => { });
2271
- });
2272
- // P1: a real dispatch finished — notify the frontend. The [BACKGROUND_INIT]
2273
- // ack also fires assistant_result, so gate on dispatchInFlight to skip it.
2274
- bgEmitter.on('assistant_result', ({ text }) => {
2275
- const slot = slots.get(slotId);
2276
- if (!slot)
2277
- return;
2278
- // The [BACKGROUND_INIT] kickoff also fires assistant_result and, on the cold-spawn
2279
- // dispatch path, can arrive AFTER dispatchInFlight was set. Consume that first result
2280
- // as the kickoff ack so it's never mis-reported as (and never clobbers) the dispatch's
2281
- // real answer.
2282
- if (!slot.kickoffDone) {
2283
- slot.kickoffDone = true;
2284
- return;
2285
- }
2286
- if (!slot.dispatchInFlight)
2287
- return; // only notify for a real dispatched instruction
2288
- slot.dispatchInFlight = false;
2289
- sendToFrontend({ type: 'background_update', sessionId: slotId, stage: 'done', summary: (text || '').trim().slice(0, 500) }).catch(() => { });
2290
- });
2291
- // Wire up the LLM's internal EventEmitter so hook-emitted events also flow.
2292
- bgLLM.events.on('session_id', (data) => bgEmitter.emit('session_id', data));
2293
- bgLLM.events.on('tool_blocked', (data) => bgEmitter.emit('tool_blocked', data));
2290
+ // Background UI forwarders on the instance's own emitter (the unified bus). Silent
2291
+ // (no tts_say). Disposer stored so promotion can stop background forwarding before
2292
+ // the same emitter is piped to the voice bus.
2293
+ const unforward = attachBackgroundForwarders(bgLLM.events, slotId);
2294
+ slot.unforward = unforward;
2294
2295
  // Build minimal sdkOptions for the headless cold start.
2295
2296
  // resume: sessionId brings up real prior context from JSONL.
2296
2297
  // env: CLAUDE_CODE_DISABLE_AUTO_MEMORY prevents concurrent subprocesses
@@ -2320,11 +2321,11 @@ async function main() {
2320
2321
  settingSources: ['project', 'user'],
2321
2322
  env: bgEnv,
2322
2323
  };
2323
- // P1: persist the cold-start emitter + sdkOptions on the slot so a later
2324
- // dispatch_background can reuse them. CRITICAL: pushMessage's fast path
2325
- // ignores the callbacks/emitter arg once the subprocess exists, so a
2326
- // dispatch MUST reuse THIS emitter — otherwise its output goes nowhere.
2327
- slot.emitter = bgEmitter;
2324
+ // Persist the cold-start emitter + sdkOptions on the slot so a later
2325
+ // dispatch_background can reuse them. The slot's output emitter IS its own
2326
+ // bgLLM.events (unified bus) — pushMessage's fast path ignores the callbacks/emitter
2327
+ // arg once the subprocess exists, so the consumer stays bound to bgLLM.events.
2328
+ slot.emitter = bgLLM.events;
2328
2329
  slot.sdkOptions = bgSdkOptions;
2329
2330
  // Cold-start: deliver an init message so the subprocess actually spawns
2330
2331
  // and begins consuming the session JSONL.
@@ -2332,10 +2333,10 @@ async function main() {
2332
2333
  bgLLM.pushMessage(KICKOFF_TEXT, bgSdkOptions, {
2333
2334
  onSessionId: (sid) => {
2334
2335
  console.log(`✅ Background slot ${slotId.substring(0, 8)}: session confirmed ${sid.substring(0, 8)}`);
2335
- bgEmitter.emit('session_id', { sessionId: sid });
2336
+ bgLLM.events.emit('session_id', { sessionId: sid });
2336
2337
  },
2337
2338
  onCheckpoint: (_ckpt) => { },
2338
- eventEmitter: bgEmitter,
2339
+ eventEmitter: bgLLM.events,
2339
2340
  });
2340
2341
  console.log(`✅ Background slot registered: id=${slotId.substring(0, 8)} workingDir=${slotWorkingDir}`);
2341
2342
  await sendToFrontend({ type: 'background_session_spawned', slotId, workingDir: slotWorkingDir });
@@ -3352,6 +3353,31 @@ async function main() {
3352
3353
  // PipelineDirectLLM (llmOverride path) wraps its own ClaudeLLM and may not expose setTurbo.
3353
3354
  directLLM?.setTurbo?.(turboMode);
3354
3355
  currentLLM = directLLM;
3356
+ // ── Voice bus (instance-swap handoff) ───────────────────────────────────────
3357
+ // The heavy LLM-event handlers below attach ONCE to this stable bus. A thin
3358
+ // pipe forwards events from whichever inner ClaudeLLM is currently foreground.
3359
+ // To hand the voice to a different, already-running subprocess (a background
3360
+ // slot) we just re-pipe the bus (swap_foreground) — the handlers never move,
3361
+ // and neither subprocess is killed. Handlers that need the live foreground LLM
3362
+ // (respondToPermission / suppressMeetingTTS) read `currentLLM`, which the swap
3363
+ // keeps pointed at the voice-owning wrapper.
3364
+ const { EventEmitter: VoiceBusEmitter } = await import('node:events');
3365
+ const vbus = new VoiceBusEmitter();
3366
+ vbus.setMaxListeners(50);
3367
+ voiceBus = vbus;
3368
+ const VOICE_EVENTS = ['mcp_servers_changed', 'tool_use', 'tool_result', 'assistant_text', 'assistant_result', 'tts_chunks', 'permission_request', 'tts_say', 'session_resume_failed', 'checkpoint_captured', 'dispatch_rejected', 'task_completed', 'agent_started'];
3369
+ pipeVoiceFrom = (emitter) => {
3370
+ const forwarders = [];
3371
+ for (const ev of VOICE_EVENTS) {
3372
+ const fn = (...args) => { vbus.emit(ev, ...args); };
3373
+ emitter.on(ev, fn);
3374
+ forwarders.push([ev, fn]);
3375
+ }
3376
+ return () => { for (const [ev, fn] of forwarders)
3377
+ emitter.off(ev, fn); };
3378
+ };
3379
+ // Pipe the initial foreground inner. Reassigned by swap_foreground.
3380
+ unpipeVoice = pipeVoiceFrom(directLLM.events);
3355
3381
  // P1 host-agent anchor: this dashboard-started session owns the room's env
3356
3382
  // and keeps it alive (agentsActive → true) so background dispatches survive.
3357
3383
  // Keep-alive keys off the hostActive boolean, NOT a session_id event;
@@ -3383,7 +3409,7 @@ async function main() {
3383
3409
  prewarmBM25Index(resumeSessionId, workingDir).catch(() => { });
3384
3410
  }
3385
3411
  // Wire up MCP server changes to frontend
3386
- directLLM.events.on('mcp_servers_changed', (data) => {
3412
+ vbus.on('mcp_servers_changed', (data) => {
3387
3413
  console.log(`🔌 MCP servers changed: ${data.enabledKeys.join(', ') || 'none'}`);
3388
3414
  sendToFrontend({
3389
3415
  type: 'mcp_servers_changed',
@@ -3392,11 +3418,11 @@ async function main() {
3392
3418
  });
3393
3419
  });
3394
3420
  // Wire up events from the Claude SDK wrapper to frontend
3395
- directLLM.events.on('tool_use', (data) => {
3421
+ vbus.on('tool_use', (data) => {
3396
3422
  console.log(`🔧 Claude: ${data.name}`);
3397
3423
  sendToFrontend(buildToolLogEvent(data.name, data.input, 'running', data.agentRole || 'main', data.agentId));
3398
3424
  });
3399
- directLLM.events.on('tool_result', (data) => {
3425
+ vbus.on('tool_result', (data) => {
3400
3426
  console.log(`✅ Done: ${data.name}`);
3401
3427
  sendToFrontend(buildToolLogEvent(data.name, data.input, 'completed', data.agentRole || 'main', data.agentId));
3402
3428
  // Detect research artifact writes (session workspace or legacy research dir)
@@ -3412,7 +3438,7 @@ async function main() {
3412
3438
  }
3413
3439
  });
3414
3440
  // Wire up Claude text output - RAW text goes to frontend for chat bubbles
3415
- directLLM.events.on('assistant_text', (data) => {
3441
+ vbus.on('assistant_text', (data) => {
3416
3442
  console.log(`💬 Claude text (${data.text?.length || 0} chars): ${data.text || ''}`);
3417
3443
  sendToFrontend({
3418
3444
  type: 'claude_output',
@@ -3424,7 +3450,7 @@ async function main() {
3424
3450
  });
3425
3451
  });
3426
3452
  // Wire up Claude final result - RAW result goes to frontend
3427
- directLLM.events.on('assistant_result', (data) => {
3453
+ vbus.on('assistant_result', (data) => {
3428
3454
  console.log(`📋 Claude result (${data.text?.length || 0} chars): ${data.text || ''}`);
3429
3455
  sendToFrontend({
3430
3456
  type: 'claude_output',
@@ -3436,7 +3462,7 @@ async function main() {
3436
3462
  });
3437
3463
  });
3438
3464
  // Wire up ordered TTS chunk list — emitted once per turn for read-along
3439
- directLLM.events.on('tts_chunks', (data) => {
3465
+ vbus.on('tts_chunks', (data) => {
3440
3466
  sendToFrontend({
3441
3467
  type: 'tts_chunks',
3442
3468
  messageId: data.messageId,
@@ -3444,7 +3470,7 @@ async function main() {
3444
3470
  });
3445
3471
  });
3446
3472
  // Wire up permission requests - sends to frontend for user approval
3447
- directLLM.events.on('permission_request', (data) => {
3473
+ vbus.on('permission_request', (data) => {
3448
3474
  console.log(`⚠️ Permission needed: ${data.toolName}`);
3449
3475
  const toolName = data.toolName;
3450
3476
  const input = data.input || {};
@@ -3453,7 +3479,7 @@ async function main() {
3453
3479
  const filePath = String(input?.file_path || '');
3454
3480
  if (filePath && sessionAlwaysAllowPaths.has(filePath)) {
3455
3481
  console.log(`✅ Session always-allow: ${filePath}`);
3456
- directLLM.respondToPermission(true);
3482
+ currentLLM?.respondToPermission(true);
3457
3483
  return;
3458
3484
  }
3459
3485
  }
@@ -3549,7 +3575,7 @@ async function main() {
3549
3575
  });
3550
3576
  // Wire up TTS say — bypass LiveKit's BufferedTokenStream, speak directly via session.say()
3551
3577
  // Each text block from Claude gets spoken immediately as it arrives, no internal buffering
3552
- directLLM.events.on('tts_say', (data) => {
3578
+ vbus.on('tts_say', (data) => {
3553
3579
  if (!data.text?.trim()) {
3554
3580
  console.log(`🔇 tts_say fired but text is empty — skipping`);
3555
3581
  return;
@@ -3563,7 +3589,7 @@ async function main() {
3563
3589
  // nulled currentSession and EVERY meeting reply was "text dropped" — the
3564
3590
  // deaf-bot bug proven live 2026-08-01 via synthetic transcript inject.
3565
3591
  // Set by PipelineDirectLLM.chat() when the turn is a [MEETING —] chunk.
3566
- if (directLLM.suppressMeetingTTS) {
3592
+ if (currentLLM.suppressMeetingTTS) {
3567
3593
  // Parity relay (behind OSBORN_MEETING_AUDIO=parity) speaks via
3568
3594
  // session.say, so it still needs a live session — it falls through to
3569
3595
  // the normal browser-TTS path below only when currentSession exists.
@@ -3718,7 +3744,7 @@ async function main() {
3718
3744
  }
3719
3745
  });
3720
3746
  // Wire up session resume failure - notify frontend when SDK creates new session instead
3721
- directLLM.events.on('session_resume_failed', (data) => {
3747
+ vbus.on('session_resume_failed', (data) => {
3722
3748
  console.error(`❌ Session resume failed: ${data.requestedSessionId} → ${data.actualSessionId}`);
3723
3749
  sendToFrontend({
3724
3750
  type: 'session_resume_failed',
@@ -3727,7 +3753,7 @@ async function main() {
3727
3753
  });
3728
3754
  });
3729
3755
  // Wire up file checkpoint capture - track restore points for file rewind
3730
- directLLM.events.on('checkpoint_captured', (data) => {
3756
+ vbus.on('checkpoint_captured', (data) => {
3731
3757
  console.log(`📍 Checkpoint: ${data.checkpointId.substring(0, 8)}...`);
3732
3758
  sendToFrontend({
3733
3759
  type: 'checkpoint_captured',
@@ -3735,7 +3761,7 @@ async function main() {
3735
3761
  });
3736
3762
  });
3737
3763
  // Dispatcher v1 — writer reviewer rejected verdict → frontend
3738
- directLLM.events.on('dispatch_rejected', (data) => {
3764
+ vbus.on('dispatch_rejected', (data) => {
3739
3765
  console.log(`[DISPATCH] dispatch_rejected tuid=${(data.tuid ?? '').slice(0, 8)} sending to frontend`);
3740
3766
  sendToFrontend({
3741
3767
  type: 'task_review',
@@ -3745,14 +3771,14 @@ async function main() {
3745
3771
  });
3746
3772
  });
3747
3773
  // Dispatcher v1 — sub-agent completed → frontend (Feature A)
3748
- directLLM.events.on('task_completed', (d) => sendToFrontend({
3774
+ vbus.on('task_completed', (d) => sendToFrontend({
3749
3775
  type: 'task_completed',
3750
3776
  agent_type: d.agent_type,
3751
3777
  agent_id: d.agent_id,
3752
3778
  last_assistant_message: d.last_assistant_message,
3753
3779
  }));
3754
3780
  // Dispatcher v1 — sub-agent started → frontend stop-key signal (Feature A2)
3755
- directLLM.events.on('agent_started', (d) => sendToFrontend({
3781
+ vbus.on('agent_started', (d) => sendToFrontend({
3756
3782
  type: 'agent_started',
3757
3783
  agent_type: d.agent_type,
3758
3784
  agent_id: d.agent_id,
@@ -5937,6 +5963,10 @@ async function main() {
5937
5963
  }
5938
5964
  else {
5939
5965
  const slot = slots.get(key);
5966
+ try {
5967
+ slot.unforward?.();
5968
+ }
5969
+ catch { } // remove its UI forwarders (no leak on repeat)
5940
5970
  try {
5941
5971
  slot.llm.abortQuery?.();
5942
5972
  slot.llm.abortAgent?.();
@@ -5954,6 +5984,96 @@ async function main() {
5954
5984
  armAloneTimer();
5955
5985
  }
5956
5986
  }
5987
+ else if (data.type === 'swap_foreground') {
5988
+ // Promote a background slot to the live voice session, and DEMOTE the current
5989
+ // foreground to a background slot — KEEPING BOTH SUBPROCESSES ALIVE (so the old
5990
+ // primary's in-flight sub-agents keep running). Mechanics:
5991
+ // 1. detach the voice bus from the old inner, 2. swapInner → the slot's ClaudeLLM,
5992
+ // 3. re-pipe the bus from the promoted inner's events, 4. adopt the old inner as a
5993
+ // background slot with its own UI-forwarding, 5. drop the promoted slot from the map.
5994
+ // The voice AgentSession is untouched (it holds the wrapper); no reconnect.
5995
+ const sid = data.sessionId;
5996
+ let key;
5997
+ if (sid) {
5998
+ if (slots.has(sid))
5999
+ key = sid;
6000
+ else
6001
+ key = [...slots.keys()].find(k => k === sid || k.startsWith(sid) || sid.startsWith(k));
6002
+ }
6003
+ const wrapper = currentLLM;
6004
+ if (!currentLLM || !voiceBus || !pipeVoiceFrom || typeof wrapper?.swapInner !== 'function') {
6005
+ await sendToFrontend({ type: 'background_session_error', slotId: sid ?? null, error: 'Swap unavailable (no live voice session or unsupported mode)' });
6006
+ }
6007
+ else if (!key) {
6008
+ await sendToFrontend({ type: 'background_session_error', slotId: sid ?? null, error: 'No matching background instance to promote' });
6009
+ }
6010
+ else {
6011
+ const slot = slots.get(key);
6012
+ const oldInner = wrapper.inner;
6013
+ // Require a CONFIRMED session id for the session being demoted — never fall back
6014
+ // to currentResumeSessionId, which can be stale from a prior swap and would tag the
6015
+ // demoted row (and its stop/re-promote key) with the wrong id. If the foreground
6016
+ // hasn't established its session yet (cold replay), refuse rather than mis-adopt.
6017
+ const oldId = oldInner?.sessionId || null;
6018
+ const oldWorkingDir = workingDir;
6019
+ if (!oldId || !oldInner) {
6020
+ await sendToFrontend({ type: 'background_session_error', slotId: sid ?? null, error: 'Foreground session not ready to demote yet — try again in a moment' });
6021
+ }
6022
+ else {
6023
+ console.log(`🔀 Swap foreground: promote ${key.substring(0, 8)} ⇄ demote ${oldId.substring(0, 8)}`);
6024
+ // PROMOTE — the slot was spawned in foreground shape (skipTTSQueue:true, events on
6025
+ // slot.llm.events). Stop forwarding it as a (silent) background row, then pipe the
6026
+ // voice bus from that SAME emitter so its tts_say/text/tools now drive the voice.
6027
+ try {
6028
+ slot.unforward?.();
6029
+ }
6030
+ catch { }
6031
+ try {
6032
+ unpipeVoice?.();
6033
+ }
6034
+ catch { }
6035
+ wrapper.swapInner(slot.llm); // also aborts old inner's in-flight fast-brain turn
6036
+ wrapper.setWorkingContext?.(slot.workingDir);
6037
+ unpipeVoice = pipeVoiceFrom(slot.llm.events);
6038
+ currentResumeSessionId = key;
6039
+ if (hostSessionId)
6040
+ hostSessionId = key;
6041
+ // DEMOTE — adopt the old foreground as a LIVE, full background slot (not killed).
6042
+ // Its consumer emits on oldInner.events, so attach the shared background forwarders
6043
+ // there (silent — no tts_say). Give it sdkOptions so dispatch_background accepts it,
6044
+ // and kickoffDone=true (it's already live; no [BACKGROUND_INIT] to consume).
6045
+ const demoteUnforward = attachBackgroundForwarders(oldInner.events, oldId);
6046
+ const demotedEnv = {};
6047
+ for (const [k, v] of Object.entries(process.env)) {
6048
+ if (v !== undefined)
6049
+ demotedEnv[k] = v;
6050
+ }
6051
+ demotedEnv['CLAUDE_CODE_DISABLE_AUTO_MEMORY'] = '1';
6052
+ demotedEnv['IS_SANDBOX'] = '1';
6053
+ const demoted = {
6054
+ id: oldId, llm: oldInner, isFocused: false, workingDir: oldWorkingDir,
6055
+ emitter: oldInner.events, unforward: demoteUnforward, kickoffDone: true,
6056
+ sdkOptions: { cwd: oldWorkingDir, resume: oldId, permissionMode: 'bypassPermissions', enableFileCheckpointing: false, settingSources: ['project', 'user'], env: demotedEnv },
6057
+ };
6058
+ slots.set(oldId, demoted);
6059
+ // The promoted session is now foreground — remove it from the slot registry.
6060
+ slots.delete(key);
6061
+ // Tell the frontend: reload the promoted session's context + refresh the panel.
6062
+ const summary = await getSessionSummary(key, workingDir).catch(() => null);
6063
+ const conversationHistory = await getConversationHistory(key, workingDir, 30).catch(() => []);
6064
+ await sendToFrontend({ type: 'session_switched', sessionId: key, success: true, summary, conversationHistory });
6065
+ const fId = key;
6066
+ const fEntry = [{ id: fId, isFocused: true, workingDir, hasSession: currentLLM.hasSession?.() ?? false }];
6067
+ const bEntries = [...slots.values()].map(s => ({ id: s.id, isFocused: s.isFocused, workingDir: s.workingDir, hasSession: s.llm.hasSession?.() ?? false }));
6068
+ await sendToFrontend({ type: 'slots_list', slots: [...fEntry, ...bEntries] });
6069
+ try {
6070
+ if (currentSession)
6071
+ await currentSession.say('Switched. This instance is live now — the previous one keeps running in the background.');
6072
+ }
6073
+ catch { }
6074
+ }
6075
+ }
6076
+ }
5957
6077
  else if (data.type === 'list_slots') {
5958
6078
  // Return all live slots: the focused session + every background slot.
5959
6079
  const focusedId = currentLLM?.sessionId || currentResumeSessionId || null;
@@ -9,7 +9,7 @@
9
9
  * Phase 2 (future): Gemini speaks first, Claude suppressed when Gemini has HIGH confidence
10
10
  */
11
11
  import { llm, type APIConnectOptions } from '@livekit/agents';
12
- import { type ClaudeLLMOptions } from './claude-llm.js';
12
+ import { ClaudeLLM, type ClaudeLLMOptions } from './claude-llm.js';
13
13
  import { EventEmitter } from 'events';
14
14
  export interface InterruptionContext {
15
15
  spokenText: string;
@@ -51,6 +51,9 @@ export declare class PipelineDirectLLM extends llm.LLM {
51
51
  get model(): string;
52
52
  get isResumingSession(): boolean;
53
53
  label(): string;
54
+ get inner(): ClaudeLLM;
55
+ swapInner(next: ClaudeLLM): ClaudeLLM;
56
+ setWorkingContext(workingDirectory: string, sessionBaseDir?: string): void;
54
57
  setResumeSessionId(id: string | null): void;
55
58
  setContinueSession(e: boolean): void;
56
59
  resetForSessionSwitch(): void;
@@ -94,6 +94,36 @@ export class PipelineDirectLLM extends llm.LLM {
94
94
  get model() { return this.#claudeLLM.model; }
95
95
  get isResumingSession() { return this.#claudeLLM.isResumingSession; }
96
96
  label() { return 'pipeline-direct'; }
97
+ // ── Instance swap (foreground ⇄ background handoff) ──────────────────────────
98
+ // The voice AgentSession binds THIS wrapper for the whole session; chat() routes
99
+ // to #claudeLLM. To move the voice to a different, already-running Claude
100
+ // subprocess (a background slot) WITHOUT tearing down the AgentSession or killing
101
+ // either subprocess, we hot-swap the inner ClaudeLLM. The caller is responsible
102
+ // for moving the voice event listeners from the old inner's emitter to the new
103
+ // one (wireVoice/unwireVoice in index.ts) — `.events` changes target after swap,
104
+ // but already-attached listeners stay on the old emitter until re-wired.
105
+ get inner() { return this.#claudeLLM; }
106
+ swapInner(next) {
107
+ const prev = this.#claudeLLM;
108
+ // Cancel any in-flight fast-brain turn bound to the OLD inner so its agentControl
109
+ // closures (which capture `this`) can't fire interrupt/sendPrompt into the newly
110
+ // promoted session. Clear per-turn flags so the new inner starts clean.
111
+ try {
112
+ this.#turnAbort?.abort();
113
+ }
114
+ catch { }
115
+ this.#turnAbort = null;
116
+ this.#postCompactionTailPending = false;
117
+ this.#claudeLLM = next;
118
+ console.log('🔁 [pipeline] swapped inner ClaudeLLM (voice handoff)');
119
+ return prev;
120
+ }
121
+ // After a swap, point the dir-derived work (fast brain, session-tail) at the promoted
122
+ // session's working dir. Without this, #opts stays bound to the pre-swap session and a
123
+ // slot spawned with a different cwd would search the wrong store.
124
+ setWorkingContext(workingDirectory, sessionBaseDir) {
125
+ this.#opts = { ...this.#opts, workingDirectory, sessionBaseDir: sessionBaseDir ?? workingDirectory };
126
+ }
97
127
  // Proxy all methods
98
128
  setResumeSessionId(id) { this.#claudeLLM.setResumeSessionId(id); }
99
129
  setContinueSession(e) { this.#claudeLLM.setContinueSession(e); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.251",
3
+ "version": "0.9.252-swap.0",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,22 +0,0 @@
1
- /**
2
- * Bridge LLM Module - Creates LLM instances for pipelined voice sessions
3
- *
4
- * In pipelined mode, we use a separate LLM (Gemini or GPT-4o) as the
5
- * "conversation manager" that handles voice I/O and routes to Claude Code.
6
- */
7
- import * as google from '@livekit/agents-plugin-google';
8
- import * as openai from '@livekit/agents-plugin-openai';
9
- export interface BridgeLLMConfig {
10
- provider: 'gemini-pro' | 'gemini-flash' | 'gpt-4o' | 'gpt-4o-mini';
11
- model?: string;
12
- }
13
- /**
14
- * Create Bridge LLM instance for pipelined voice sessions
15
- *
16
- * Options:
17
- * - gemini-pro: Gemini 2.5 Pro (smart, good reasoning)
18
- * - gemini-flash: Gemini 2.0 Flash (faster, cheaper)
19
- * - gpt-4o: GPT-4o (alternative if OpenAI preferred)
20
- * - gpt-4o-mini: GPT-4o Mini (faster, cheaper)
21
- */
22
- export declare function createBridgeLLM(config: BridgeLLMConfig): google.LLM | openai.LLM;
@@ -1,39 +0,0 @@
1
- /**
2
- * Bridge LLM Module - Creates LLM instances for pipelined voice sessions
3
- *
4
- * In pipelined mode, we use a separate LLM (Gemini or GPT-4o) as the
5
- * "conversation manager" that handles voice I/O and routes to Claude Code.
6
- */
7
- import * as google from '@livekit/agents-plugin-google';
8
- import * as openai from '@livekit/agents-plugin-openai';
9
- /**
10
- * Create Bridge LLM instance for pipelined voice sessions
11
- *
12
- * Options:
13
- * - gemini-pro: Gemini 2.5 Pro (smart, good reasoning)
14
- * - gemini-flash: Gemini 2.0 Flash (faster, cheaper)
15
- * - gpt-4o: GPT-4o (alternative if OpenAI preferred)
16
- * - gpt-4o-mini: GPT-4o Mini (faster, cheaper)
17
- */
18
- export function createBridgeLLM(config) {
19
- switch (config.provider) {
20
- case 'gemini-pro':
21
- return new google.LLM({
22
- model: config.model || 'gemini-2.5-pro',
23
- });
24
- case 'gemini-flash':
25
- return new google.LLM({
26
- model: config.model || 'gemini-2.0-flash',
27
- });
28
- case 'gpt-4o':
29
- return new openai.LLM({
30
- model: config.model || 'gpt-4o',
31
- });
32
- case 'gpt-4o-mini':
33
- return new openai.LLM({
34
- model: config.model || 'gpt-4o-mini',
35
- });
36
- default:
37
- throw new Error(`Unknown Bridge LLM provider: ${config.provider}`);
38
- }
39
- }
@@ -1,38 +0,0 @@
1
- import { EventEmitter } from 'events';
2
- type SandboxMode = 'workspace-write' | 'danger-full-access';
3
- type ApprovalPolicy = 'on-failure' | 'on-request' | 'never';
4
- interface CodexHandlerOptions {
5
- workingDirectory?: string;
6
- skipGitRepoCheck?: boolean;
7
- sandboxMode?: SandboxMode;
8
- approvalPolicy?: ApprovalPolicy;
9
- }
10
- /**
11
- * Codex Handler using the official OpenAI Codex SDK
12
- *
13
- * Features:
14
- * - Thread persistence (reuses same thread for context)
15
- * - Built-in coding tools (file operations, terminal commands)
16
- * - Hooks for observability
17
- *
18
- * Requirements:
19
- * - Codex CLI must be installed (npm install -g @openai/codex or use npx)
20
- * - Must be authenticated via: codex login (ChatGPT) or OPENAI_API_KEY env var
21
- *
22
- * Sandbox/Approval modes are configured via:
23
- * - ~/.codex/config.yaml for global settings
24
- * - CLI flags: --sandbox, --approval-policy
25
- *
26
- * Note: The SDK spawns the CLI binary, so authentication must be done beforehand.
27
- */
28
- export declare class CodexHandler extends EventEmitter {
29
- private codex;
30
- private thread;
31
- private options;
32
- constructor(options?: CodexHandlerOptions);
33
- run(prompt: string): Promise<string>;
34
- getThreadId(): string | null;
35
- clearThread(): void;
36
- }
37
- export declare function askCodex(prompt: string, cwd?: string): Promise<string>;
38
- export {};