osborn 0.9.81 → 0.9.83

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.
@@ -0,0 +1,32 @@
1
+ # Voice-E2E — browser testing with ears, a mouth, hands and a brain
2
+
3
+ ## SKILL IDENTITY
4
+ Name: voice-e2e
5
+ Version: 1 (packaged seed — the served copy at the URL below is source of truth)
6
+ Served from: https://www.voice-native.com/api/test-skill
7
+ Landing page: https://www.voice-native.com/test-skill
8
+
9
+ Agent-driven end-to-end testing for voice and web apps in a REAL browser:
10
+ speak into the page's mic (synthesized on the fly), hear and transcribe what
11
+ it plays back, click via a natural-language brain, improvise conversations,
12
+ and get video + audio replays per run. Works on any website.
13
+
14
+ ## UPDATE CHECK (notify-first, run before major test/research runs)
15
+ This packaged copy ships with every machine and may lag the served version.
16
+ Check, then NOTIFY the user before updating — never silently force:
17
+
18
+ ```bash
19
+ SERVED_V=$(curl -sf --max-time 10 https://www.voice-native.com/api/test-skill | grep -m1 '^Version:' | awk '{print $2}')
20
+ LOCAL_V=$(grep -m1 '^Version:' ~/.claude/skills/voice-e2e/SKILL.md 2>/dev/null | awk '{print $2}')
21
+ [ -n "$SERVED_V" ] && [ "$SERVED_V" != "$LOCAL_V" ] && echo "UPDATE AVAILABLE: voice-e2e v${LOCAL_V:-none} -> v$SERVED_V — tell the user what's new and ask before updating (curl -sS https://www.voice-native.com/api/test-skill -o ~/.claude/skills/voice-e2e/SKILL.md), then refresh the harness bundle per the served INSTALLATION."
22
+ ```
23
+
24
+ If the user approves (or has pre-approved auto-updates), update the skill
25
+ file AND re-run the served INSTALLATION step 2 (harness bundle refresh) so
26
+ the accompanying files stay in lockstep with the skill.
27
+
28
+ ## FIRST USE
29
+ Fetch the served copy and follow its INSTALLATION section — it materializes
30
+ the harness (~/voice-e2e-harness/), installs dependencies, and asks the user
31
+ for a GOOGLE_API_KEY (required, Gemini brain) plus optional OPENAI/DEEPGRAM
32
+ keys for the voice mouth and audible verification.
package/dist/index.js CHANGED
@@ -70,7 +70,7 @@ function loadSkillsList(agentDir) {
70
70
  // Extract description from first paragraph after heading
71
71
  const descMatch = content.match(/^#[^\n]+\n+([^\n#]+)/m);
72
72
  const description = descMatch ? descMatch[1].trim() : '';
73
- skills.push({ name, description });
73
+ skills.push({ name, description, folder: skillName });
74
74
  }
75
75
  }
76
76
  }
@@ -313,23 +313,39 @@ function startApiServer(workingDir, port) {
313
313
  });
314
314
  return;
315
315
  }
316
+ // GET /room-code — LEGACY (0.9.83). Returns the LAST-CREATED full room NAME
317
+ // (not a short code anymore) so old frontends that fetch this before joining
318
+ // keep working during rollout. New frontends should instead call
319
+ // POST /connect-room and use the returned { roomName }. Kept as a fallback.
316
320
  if (req.method === 'GET' && url.pathname === '/room-code') {
317
321
  res.writeHead(200, { 'Content-Type': 'application/json' });
318
322
  res.end(JSON.stringify({ roomCode: currentRoomCode }));
319
323
  return;
320
324
  }
321
325
  // POST /connect-room — the frontend connect flow calls this so the agent
322
- // joins LiveKit for an incoming user. Idempotent: if already connected it's
323
- // a no-op. Must complete (agent in room) BEFORE the user joins, because the
324
- // voice session is created from the ParticipantConnected event, which only
325
- // fires for participants who join AFTER the agent. The frontend polls
326
- // /health for livekit.status==='connected' before minting its token.
326
+ // joins LiveKit for an incoming user. 0.9.83: creates a FRESH, UNIQUE room
327
+ // per session and AWAITS the join so we can return the room NAME the
328
+ // frontend must mint its token against — eliminating the /room-code fetch
329
+ // race. Response: { ok, roomName }. The frontend should bind to roomName
330
+ // directly (mint its LiveKit token for it) rather than fetching /room-code.
331
+ // The agent is in the room by the time this responds, so the user's join
332
+ // fires ParticipantConnected (or is caught by the adopt-sweep either way).
327
333
  if (req.method === 'POST' && url.pathname === '/connect-room') {
328
- res.writeHead(200, { 'Content-Type': 'application/json' });
329
- res.end(JSON.stringify({ ok: true, status: livekitState.status }));
330
- if (connectRoomHook) {
331
- connectRoomHook().catch((e) => console.error('❌ /connect-room hook failed:', e));
334
+ if (!connectRoomHook) {
335
+ res.writeHead(503, { 'Content-Type': 'application/json' });
336
+ res.end(JSON.stringify({ ok: false, error: 'agent not ready' }));
337
+ return;
332
338
  }
339
+ connectRoomHook()
340
+ .then((roomName) => {
341
+ res.writeHead(200, { 'Content-Type': 'application/json' });
342
+ res.end(JSON.stringify({ ok: true, roomName, status: livekitState.status }));
343
+ })
344
+ .catch((e) => {
345
+ console.error('❌ /connect-room hook failed:', e);
346
+ res.writeHead(500, { 'Content-Type': 'application/json' });
347
+ res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e), status: livekitState.status }));
348
+ });
333
349
  return;
334
350
  }
335
351
  // POST /leave-room — the frontend's explicit "leave"/disconnect leaves the
@@ -1197,43 +1213,59 @@ async function main() {
1197
1213
  console.warn('⚠️ could not persist room code (rotation race protection disabled):', e instanceof Error ? e.message : e);
1198
1214
  }
1199
1215
  }
1200
- currentRoomCode = roomCode;
1201
- const roomName = `osborn-${roomCode}`;
1216
+ // `roomCode` is the STABLE, identity-derived PREFIX (per machine/user). Under
1217
+ // the temporary-rooms architecture (0.9.83) we no longer join ONE fixed room
1218
+ // for the machine's life — each user session gets a FRESH, unique room name
1219
+ // built by appending a base36 timestamp to this prefix:
1220
+ // osborn-${roomCode}-${Date.now().toString(36)}
1221
+ // The stable prefix is preserved purely for log forensics (grep by user).
1222
+ // currentRoomCode holds the LAST-CREATED full room NAME so the legacy
1223
+ // GET /room-code endpoint keeps working for old frontends during rollout
1224
+ // (see note in createRoomSession + the /room-code handler).
1225
+ const buildRoomName = () => `osborn-${roomCode}-${Date.now().toString(36)}`;
1202
1226
  if (cliArgs.roomCode) {
1203
- console.log(`🔗 Joining room: ${roomCode}`);
1227
+ console.log(`🔗 Room code prefix: ${roomCode}`);
1204
1228
  }
1205
1229
  else {
1206
- console.log(`\n✨ Created new room: ${roomCode}`);
1207
- console.log(`\n📋 Share this with the frontend or run:`);
1208
- console.log(` Open: https://osborn.app?room=${roomCode}`);
1209
- console.log(` Or enter code "${roomCode}" in the frontend\n`);
1230
+ console.log(`\n✨ Room code prefix: ${roomCode} — each session mints a fresh room osborn-${roomCode}-<ts>\n`);
1210
1231
  }
1211
1232
  // Start HTTP API server for frontend session browsing
1212
1233
  const apiPort = parseInt(process.env.OSBORN_API_PORT || '8741', 10);
1213
1234
  startApiServer(workingDir, apiPort);
1214
1235
  // ============================================================
1215
- // Create Access Token for Agent
1236
+ // Agent access token — minted FRESH per room session (0.9.83)
1216
1237
  // ============================================================
1217
- console.log('🔑 Creating access token...');
1218
- const token = new AccessToken(apiKey, apiSecret, {
1219
- identity: 'osborn-agent',
1220
- name: 'Osborn AI',
1221
- metadata: JSON.stringify({ type: 'agent', version: '0.3.0' }),
1222
- });
1223
- token.addGrant({
1224
- roomJoin: true,
1225
- room: roomName,
1226
- canPublish: true,
1227
- canSubscribe: true,
1228
- canPublishData: true,
1229
- });
1230
- const jwt = await token.toJwt();
1238
+ // A LiveKit JWT grant is scoped to a specific room name. Since every user
1239
+ // session now joins a UNIQUE temporary room (buildRoomName), the token must
1240
+ // be minted per room, not once at boot. mintAgentToken(roomName) returns a
1241
+ // fresh JWT grant for the given room.
1242
+ const mintAgentToken = async (targetRoom) => {
1243
+ const token = new AccessToken(apiKey, apiSecret, {
1244
+ identity: 'osborn-agent',
1245
+ name: 'Osborn AI',
1246
+ metadata: JSON.stringify({ type: 'agent', version: '0.3.0' }),
1247
+ });
1248
+ token.addGrant({
1249
+ roomJoin: true,
1250
+ room: targetRoom,
1251
+ canPublish: true,
1252
+ canSubscribe: true,
1253
+ canPublishData: true,
1254
+ });
1255
+ return token.toJwt();
1256
+ };
1231
1257
  // ============================================================
1232
- // Connect to Room
1258
+ // Room session (0.9.83): FRESH Room instance per user session
1233
1259
  // ============================================================
1234
- console.log('📡 Connecting to LiveKit...');
1235
- const room = new Room();
1236
- room.setMaxListeners(50); // Prevent MaxListenersExceeded warnings on reconnect
1260
+ // rtc-node's Room is single-use: Room.disconnect() ends with
1261
+ // removeAllListeners() (room.js:584), wiping EVERY room.on() handler, and its
1262
+ // signal-less waitFor + hasCleanedUp reset make a reused Room deaf after any
1263
+ // leave/rejoin cycle. So we NEVER reuse a Room after disconnect — instead we
1264
+ // create a brand-new Room (with full handler wiring) per session via
1265
+ // createRoomSession(), and discard it via destroyRoomSession(). activeRoom is
1266
+ // null while the agent is idle (between sessions).
1267
+ let activeRoom = null;
1268
+ let activeRoomName = null; // the temporary room name currently joined
1237
1269
  // Track state
1238
1270
  let pendingSessionClose = null; // Tracks async session close for reconnect safety
1239
1271
  let currentSession = null;
@@ -1259,17 +1291,46 @@ async function main() {
1259
1291
  if (aloneTimer)
1260
1292
  clearTimeout(aloneTimer);
1261
1293
  aloneTimer = null;
1262
- if (room.remoteParticipants.size > 0)
1294
+ if (!activeRoom || activeRoom.remoteParticipants.size > 0)
1263
1295
  return;
1264
1296
  aloneTimer = setTimeout(() => {
1265
1297
  aloneTimer = null;
1266
- if (room.remoteParticipants.size === 0 && livekitState.status === 'connected') {
1267
- console.log(`🕊️ Alone in room ${ALONE_GRACE_MS / 1000}s — leaving LiveKit to stop connection-minute burn`);
1298
+ if (activeRoom && activeRoom.remoteParticipants.size === 0 && livekitState.status === 'connected') {
1299
+ console.log(`🕊️ Alone in room ${ALONE_GRACE_MS / 1000}s — destroying room session to stop connection-minute burn`);
1268
1300
  intentionalLeave = true;
1269
- room.disconnect().catch((e) => console.error('alone-leave room.disconnect failed:', e));
1301
+ // Fresh-Room-per-session (0.9.83): tear down + discard the instance
1302
+ // rather than room.disconnect() on a reused Room.
1303
+ destroyRoomSession('alone-timer').catch((e) => console.error('alone-leave destroyRoomSession failed:', e));
1270
1304
  }
1271
1305
  }, ALONE_GRACE_MS);
1272
1306
  };
1307
+ // 0.9.83: Fast tab-close leave. When a real session ENDS (user left / closed
1308
+ // the tab), LiveKit has already dropped that participant server-side, so we
1309
+ // don't need the full 3-min alone grace — free the room in ~20s. A short
1310
+ // grace still rides out a quick refresh/rejoin. This shrinks the "ghost
1311
+ // participant" window that compounds room churn; the 3-min alone-timer
1312
+ // remains for the woken-but-never-joined case (armed on connect).
1313
+ let fastLeaveTimer = null;
1314
+ const POST_LEAVE_GRACE_MS = 20 * 1000;
1315
+ const armFastLeaveTimer = () => {
1316
+ if (fastLeaveTimer)
1317
+ clearTimeout(fastLeaveTimer);
1318
+ fastLeaveTimer = null;
1319
+ if (!activeRoom || activeRoom.remoteParticipants.size > 0)
1320
+ return;
1321
+ fastLeaveTimer = setTimeout(() => {
1322
+ fastLeaveTimer = null;
1323
+ if (activeRoom && activeRoom.remoteParticipants.size === 0 && livekitState.status === 'connected') {
1324
+ console.log(`🚪 Room empty ${POST_LEAVE_GRACE_MS / 1000}s after session end (tab close / leave) — destroying room session`);
1325
+ intentionalLeave = true;
1326
+ destroyRoomSession('fast-leave').catch((e) => console.error('fast-leave destroyRoomSession failed:', e));
1327
+ }
1328
+ }, POST_LEAVE_GRACE_MS);
1329
+ };
1330
+ const cancelFastLeaveTimer = () => { if (fastLeaveTimer) {
1331
+ clearTimeout(fastLeaveTimer);
1332
+ fastLeaveTimer = null;
1333
+ } };
1273
1334
  // ── 0.9.73: Idle machine self-stop (the $123 Fly bill fix) ──
1274
1335
  // After an intentional alone-leave the agent used to sit "idle, awaiting
1275
1336
  // /connect-room" forever — process alive, Fly machine `started`, billing
@@ -1296,14 +1357,14 @@ async function main() {
1296
1357
  idleExitTimer = setTimeout(() => {
1297
1358
  idleExitTimer = null;
1298
1359
  const stillIdle = livekitState.status === 'idle' || livekitState.status === 'failed';
1299
- const empty = room.remoteParticipants.size === 0;
1360
+ const empty = !activeRoom || activeRoom.remoteParticipants.size === 0;
1300
1361
  if (stillIdle && empty) {
1301
1362
  console.log(`⏻ [IDLE-EXIT] still idle after grace — exiting 0 so Fly stops the machine (billing stops; next Resume boots it)`);
1302
1363
  // Give the log tee a beat to flush, then stop the machine.
1303
1364
  setTimeout(() => process.exit(0), 2000);
1304
1365
  }
1305
1366
  else {
1306
- console.log(`⏻ [IDLE-EXIT] aborted — status=${livekitState.status} participants=${room.remoteParticipants.size}`);
1367
+ console.log(`⏻ [IDLE-EXIT] aborted — status=${livekitState.status} participants=${activeRoom?.remoteParticipants.size ?? 0}`);
1307
1368
  }
1308
1369
  }, IDLE_EXIT_GRACE_MS);
1309
1370
  };
@@ -1314,85 +1375,20 @@ async function main() {
1314
1375
  console.log('⏻ [IDLE-EXIT] cancelled — activity resumed');
1315
1376
  }
1316
1377
  };
1317
- // ── 0.9.73: Zombie-presence watchdog (poll-based) ──
1318
- // The Jun 18 zombie proved the event-driven alone-timer has a blind spot:
1319
- // when the LiveKit WS goes half-dead, RoomEvent callbacks stop firing entirely
1320
- // (agent log went silent for 4 days while the dashboard logged "Participant
1321
- // resumed" on every network blip). A poll can't be starved by a dead event
1322
- // stream. Every 60s: if we believe we're connected but the room has no remote
1323
- // participants, count it; after ALONE_GRACE_MS worth of consecutive empty
1324
- // polls, force the same intentional-leave path the timer uses. If the
1325
- // disconnect itself hangs (zombie WS), the idle-exit timer armed alongside
1326
- // still stops the machine.
1327
- const WATCHDOG_POLL_MS = 60 * 1000;
1328
- let emptyPolls = 0;
1329
- setInterval(() => {
1330
- try {
1331
- if (livekitState.status !== 'connected') {
1332
- emptyPolls = 0;
1333
- return;
1334
- }
1335
- if (room.remoteParticipants.size > 0) {
1336
- emptyPolls = 0;
1337
- return;
1338
- }
1339
- emptyPolls++;
1340
- const emptyForMs = emptyPolls * WATCHDOG_POLL_MS;
1341
- if (emptyForMs >= ALONE_GRACE_MS) {
1342
- console.log(`🧟 [ZOMBIE-WATCHDOG] room empty for ${emptyPolls} consecutive polls (${emptyForMs / 1000}s) — forcing leave`);
1343
- emptyPolls = 0;
1344
- intentionalLeave = true;
1345
- // Update state FIRST, don't wait for the Disconnected event — a zombie
1346
- // WS is precisely the case where that event never arrives. Observed
1347
- // 2026-07-27: after a forced leave the status stayed 'connected', so
1348
- // /health lied, /connect-room no-op'd, and the next user joined a room
1349
- // the agent wasn't in (UI stuck at "Connecting..."). Setting idle here
1350
- // makes the next /connect-room actually rejoin. If the Disconnected
1351
- // event does fire later, its handler sets the same state — idempotent.
1352
- livekitState.status = 'idle';
1353
- const disconnectWatch = setTimeout(() => console.error('🧟 [ZOMBIE-WATCHDOG] room.disconnect() still pending after 10s — WS likely zombied; idle-exit timer is the backstop'), 10_000);
1354
- room.disconnect()
1355
- .then(() => { clearTimeout(disconnectWatch); console.log('🧟 [ZOMBIE-WATCHDOG] forced leave completed'); })
1356
- .catch((e) => { clearTimeout(disconnectWatch); console.error('watchdog room.disconnect failed:', e); });
1357
- // Belt-and-suspenders: if the WS is zombied the Disconnected event may
1358
- // never fire (so the idle-exit arm in that handler won't run). Arm here too.
1359
- armIdleExitTimer('zombie-watchdog forced leave');
1360
- }
1361
- }
1362
- catch (err) {
1363
- console.error('🧟 [ZOMBIE-WATCHDOG] poll error:', err instanceof Error ? err.message : err);
1364
- }
1365
- }, WATCHDOG_POLL_MS);
1366
- // ── 0.9.80: Event-loss adopter (poll-based) ──
1367
- // After a leave/rejoin cycle the same Room instance can stop delivering
1368
- // ParticipantConnected entirely — observed 2026-07-28: the leave-room guard
1369
- // saw "1 participant(s) still in room" while the agent sat at "Waiting for
1370
- // user to connect" (state knew, events never fired; user stuck on
1371
- // "Connecting..." forever). Same blind-spot family as the zombie watchdog:
1372
- // never trust the event stream alone. Every 5s: participant present but no
1373
- // voice session → re-emit ParticipantConnected (60s per-identity cooldown
1374
- // so a slow in-flight session creation isn't double-fired).
1375
- const adoptAttempts = new Map();
1376
- setInterval(() => {
1377
- try {
1378
- if (livekitState.status !== 'connected')
1379
- return;
1380
- if (currentSession)
1381
- return;
1382
- const p = room.remoteParticipants.values().next().value;
1383
- if (!p)
1384
- return;
1385
- const last = adoptAttempts.get(p.identity) ?? 0;
1386
- if (Date.now() - last < 60_000)
1387
- return;
1388
- adoptAttempts.set(p.identity, Date.now());
1389
- console.log(`📥 [ADOPT-POLL] ${p.identity} present with no session — invoking ParticipantConnected handler directly`);
1390
- participantConnectedHandler?.(p).catch((e) => console.error('📥 [ADOPT-POLL] handler error:', e instanceof Error ? e.message : e));
1391
- }
1392
- catch (err) {
1393
- console.error('📥 [ADOPT-POLL] error:', err instanceof Error ? err.message : err);
1394
- }
1395
- }, 5_000);
1378
+ // ── 0.9.83: Zombie-presence watchdog + event-loss adopter DELETED ──
1379
+ // Both poll-based layers existed solely to compensate for Room-reuse damage:
1380
+ // • zombie-watchdog: RoomEvent callbacks stopped firing after a leave/rejoin
1381
+ // cycle because Room.disconnect() had already run removeAllListeners()
1382
+ // (room.js:584), so even real disconnects went unobserved.
1383
+ // • adopt-poll: ParticipantConnected stopped being delivered on a reused
1384
+ // Room for the same reason.
1385
+ // Under fresh-Room-per-session (createRoomSession / destroyRoomSession) the
1386
+ // event stream is trustworthy again — every session gets a Room with freshly
1387
+ // wired handlers, and we NEVER reuse a Room after disconnect. The legitimate
1388
+ // race the adopt-poll also covered (a participant already in the room at join
1389
+ // time, who fires no ParticipantConnected event) is now handled once, at the
1390
+ // right moment, by the adopt-sweep inside createRoomSession(). The idle-exit
1391
+ // timer (above) remains as the billing backstop.
1396
1392
  /**
1397
1393
  * Hard-kill the in-flight Claude SDK query AND the persistent subprocess.
1398
1394
  *
@@ -2903,16 +2899,27 @@ async function main() {
2903
2899
  return { session, agent };
2904
2900
  }
2905
2901
  // ============================================================
2906
- // Room Event Handlers
2902
+ // Room Event Handlers (0.9.83: registered per-session via wireRoomHandlers)
2907
2903
  // ============================================================
2908
- room.on(RoomEvent.Connected, () => {
2904
+ //
2905
+ // Every handler BODY lives here in main()'s scope (closing over currentSession,
2906
+ // sendToFrontend, etc.). The actual `room.on(...)` REGISTRATION happens inside
2907
+ // wireRoomHandlers(room) — called once per fresh Room in createRoomSession().
2908
+ // We never re-use a Room after disconnect (rtc-node's Room.disconnect() runs
2909
+ // removeAllListeners() at room.js:584, wiping every listener), so handlers are
2910
+ // wired fresh onto each new instance.
2911
+ // Post-connect setup. RoomEvent.Connected is NEVER emitted by rtc-node (grep
2912
+ // of dist finds zero emit("connected") — only connectionStateChanged), so this
2913
+ // body — which used to live in a dead RoomEvent.Connected handler — is now
2914
+ // called directly from createRoomSession() after connect() resolves.
2915
+ const postConnectSetup = (room, connectedRoomName) => {
2909
2916
  // 0.9.68: log Room SID + name PROMINENTLY so we can cross-reference
2910
2917
  // this specific session in LiveKit Cloud dashboard → Sessions tab.
2911
2918
  // @livekit/rtc-node Room exposes SID via async getSid() (it's resolved
2912
2919
  // after WebRTC handshake), so we fetch it asynchronously and log when ready.
2913
- console.log(`✅ Connected to room: ${roomName} | t=${new Date().toISOString()}`);
2920
+ console.log(`✅ Connected to room: ${connectedRoomName} | t=${new Date().toISOString()}`);
2914
2921
  room.getSid().then((sid) => {
2915
- console.log(`🔗 [LIVEKIT-DASHBOARD] room sid=${sid} name=${roomName} — search at https://cloud.livekit.io/projects → Sessions → "${sid}"`);
2922
+ console.log(`🔗 [LIVEKIT-DASHBOARD] room sid=${sid} name=${connectedRoomName} — search at https://cloud.livekit.io/projects → Sessions → "${sid}"`);
2916
2923
  }).catch((err) => {
2917
2924
  console.log(`⚠️ [LIVEKIT-DASHBOARD] failed to fetch room SID: ${err instanceof Error ? err.message : String(err)}`);
2918
2925
  });
@@ -2950,7 +2957,7 @@ async function main() {
2950
2957
  // window (e.g. machine woken then abandoned mid-handshake), leave the room
2951
2958
  // rather than hold it indefinitely. Cancelled in ParticipantConnected.
2952
2959
  armAloneTimer();
2953
- });
2960
+ };
2954
2961
  // NOTE: previously this section also had a RoomEvent.ActiveSpeakersChanged
2955
2962
  // handler that interrupted TTS on any sustained audio activity (~50ms after
2956
2963
  // mic onset). That fired too eagerly — coughs, paper rustles, the agent's
@@ -2961,48 +2968,14 @@ async function main() {
2961
2968
  // Flux STT's speech-vs-noise classification: slower (~100-300ms) but
2962
2969
  // confidence-aware. The latency tradeoff is worth eliminating the false
2963
2970
  // interrupts at the root.
2964
- // 0.9.71: Room-level audio observability — observe-only logs so we can
2965
- // cross-reference user mic mute/quality changes against TTS cutoffs without
2966
- // re-introducing the over-eager ActiveSpeakers interrupt.
2967
- room.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
2968
- try {
2969
- const ids = (speakers || []).map((s) => s?.identity).filter(Boolean);
2970
- console.log(`🎙️ [ROOM-SPEAKERS] count=${ids.length} ids=${JSON.stringify(ids)} t=${new Date().toISOString()}`);
2971
- }
2972
- catch { }
2973
- });
2974
- room.on(RoomEvent.ConnectionQualityChanged, (quality, participant) => {
2975
- try {
2976
- console.log(`📶 [ROOM-QUALITY] participant=${participant?.identity} quality=${quality} t=${new Date().toISOString()}`);
2977
- }
2978
- catch { }
2979
- });
2980
- room.on(RoomEvent.TrackMuted, (publication, participant) => {
2981
- try {
2982
- console.log(`🔇 [ROOM-TRACK-MUTED] participant=${participant?.identity} kind=${publication?.kind} source=${publication?.source} sid=${publication?.sid} t=${new Date().toISOString()}`);
2983
- }
2984
- catch { }
2985
- });
2986
- room.on(RoomEvent.TrackUnmuted, (publication, participant) => {
2987
- try {
2988
- console.log(`🔊 [ROOM-TRACK-UNMUTED] participant=${participant?.identity} kind=${publication?.kind} source=${publication?.source} sid=${publication?.sid} t=${new Date().toISOString()}`);
2989
- }
2990
- catch { }
2991
- });
2992
- room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
2993
- try {
2994
- console.log(`📥 [ROOM-TRACK-SUBSCRIBED] participant=${participant?.identity} kind=${track?.kind} source=${publication?.source} sid=${publication?.sid} t=${new Date().toISOString()}`);
2995
- }
2996
- catch { }
2997
- });
2998
- room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
2999
- try {
3000
- console.log(`📤 [ROOM-TRACK-UNSUBSCRIBED] participant=${participant?.identity} kind=${track?.kind} source=${publication?.source} sid=${publication?.sid} t=${new Date().toISOString()}`);
3001
- }
3002
- catch { }
3003
- });
3004
- room.on(RoomEvent.Disconnected, () => {
2971
+ // Disconnected handler body (0.9.83: reworked for fresh-Room-per-session).
2972
+ // The Room instance that just fired this is ALREADY dead (rtc-node ran
2973
+ // cleanupOnDisconnect + removeAllListeners). We NEVER reconnect it. If the
2974
+ // disconnect was involuntary mid-session, we create a BRAND-NEW Room joining
2975
+ // the SAME LiveKit room name so any in-flight frontend token stays valid.
2976
+ const handleRoomDisconnected = () => {
3005
2977
  console.log('👋 Disconnected from room');
2978
+ const disconnectedRoomName = activeRoomName;
3006
2979
  // Clean up active research and voice queue
3007
2980
  voiceQueue.length = 0;
3008
2981
  isProcessingQueue = false;
@@ -3027,54 +3000,98 @@ async function main() {
3027
3000
  currentLLM = null;
3028
3001
  clearFastBrainSession();
3029
3002
  clearPipelineFastBrainSession();
3030
- // ── Ghost-agent fix (2026-06-01) ──
3031
- // When LiveKit Cloud evicts our WebSocket (idle, network blip, or quota window),
3032
- // the previous code stopped here — agent process kept running but no longer in
3033
- // any room. /health continued returning "livekit.status:connected" because the
3034
- // status was never written back. Frontend's checkOsbornHealth only validates
3035
- // HTTP 200, so the ghost state was invisible. Users got stuck in "Connecting..."
3036
- // forever because their LiveKit-token-minted room had no agent in it.
3037
- //
3038
- // Fix: re-arm the retry loop. connectWithRetry() will try to reconnect with
3039
- // the same room name (so the room code stays stable for any in-flight frontend
3040
- // token requests), backing off 5s → 60s. If the disconnect was permanent
3041
- // (e.g. JWT expired — they're 24h), the retry will fail and surface
3042
- // livekit.status=failed, which the (also-fixed) frontend health check will
3043
- // see and trigger restartService.
3044
- //
3045
- // Note: we mark status='retrying' immediately so /health reflects the real
3046
- // state — closing the lie window between Disconnected and the next attempt.
3047
- // ── Voluntary-leave guard (2026-06-09) ──
3003
+ // ── Voluntary-leave guard ──
3048
3004
  // If we left the room ON PURPOSE (user clicked leave → /leave-room, or the
3049
3005
  // agent-side alone timer fired), do NOT auto-rejoin — rejoining would
3050
- // recreate the connection-minute burn we just stopped. Mark the connection
3051
- // 'idle' (machine stays warm, /health still 200) and wait for the next
3052
- // /connect-room. Reset the flag so a later involuntary drop still rejoins.
3006
+ // recreate the connection-minute burn we just stopped. The
3007
+ // destroyRoomSession() path already set intentionalLeave and will null out
3008
+ // activeRoom + arm idle-exit; here we just do bookkeeping and bail. Reset
3009
+ // the flag so a later involuntary drop still rejoins.
3053
3010
  if (intentionalLeave) {
3054
3011
  intentionalLeave = false;
3055
3012
  livekitState.status = 'idle';
3056
3013
  livekitState.error = null;
3057
3014
  livekitState.errorCode = null;
3015
+ // The Room instance is dead. Drop our reference so nothing reuses it.
3016
+ activeRoom = null;
3017
+ activeRoomName = null;
3058
3018
  console.log('🕊️ Left LiveKit room intentionally — idle, awaiting /connect-room (no auto-rejoin)');
3059
3019
  // 0.9.73: idle machines must not bill forever — stop the Fly machine
3060
3020
  // after the grace window unless a /connect-room revives us first.
3061
3021
  armIdleExitTimer('intentional leave → idle');
3062
3022
  return;
3063
3023
  }
3024
+ // ── Involuntary drop (LiveKit evicted our WS: idle, network blip, quota) ──
3025
+ // The old code called room.connect() on the SAME (now-listener-less) Room —
3026
+ // which "succeeded" but produced a deaf agent every time (no events reached
3027
+ // the wiped listeners). Fix: discard the dead instance and create a FRESH
3028
+ // Room rejoining the SAME LiveKit room name so any in-flight frontend token
3029
+ // stays valid. status='retrying' closes the lie window until reconnect.
3030
+ activeRoom = null;
3031
+ activeRoomName = null;
3064
3032
  livekitState.status = 'retrying';
3065
3033
  livekitState.error = 'LiveKit room disconnected; attempting to rejoin';
3066
3034
  livekitState.errorCode = 'disconnected';
3067
- console.log('🔄 Rejoining LiveKit room after disconnect...');
3068
- connectWithRetry().catch(err => {
3069
- console.error('❌ Reconnect attempt threw (should not happen — connectWithRetry loops):', err);
3035
+ console.log(`🔄 Rejoining LiveKit room after involuntary disconnect (fresh Room, same name: ${disconnectedRoomName})...`);
3036
+ createRoomSession(disconnectedRoomName || buildRoomName()).catch(err => {
3037
+ console.error('❌ Reconnect createRoomSession threw:', err);
3038
+ livekitState.status = 'failed';
3039
+ livekitState.error = err instanceof Error ? err.message : String(err);
3070
3040
  });
3071
- });
3072
- // 0.9.81: handler extracted to a named reference so the adopt-poll and
3073
- // /connect-room adopt can CALL it directly. Synthetic `room.emit(...)` does
3074
- // NOT reach listeners on rtc-node's Room (observed 2026-07-28: ADOPT-POLL
3075
- // re-emitted for three successive participants, zero sessions created) —
3076
- // the event dispatch is internal, so direct invocation is the only
3077
- // reliable path for state-driven adoption.
3041
+ };
3042
+ // wireRoomHandlers(room): register EVERY room.on(...) listener onto a fresh
3043
+ // Room instance. Called once per session from createRoomSession(). The bodies
3044
+ // close over main()-scope state; `room` is the fresh instance passed in.
3045
+ const wireRoomHandlers = (room) => {
3046
+ // 0.9.71: Room-level audio observability — observe-only logs so we can
3047
+ // cross-reference user mic mute/quality changes against TTS cutoffs without
3048
+ // re-introducing the over-eager ActiveSpeakers interrupt.
3049
+ room.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
3050
+ try {
3051
+ const ids = (speakers || []).map((s) => s?.identity).filter(Boolean);
3052
+ console.log(`🎙️ [ROOM-SPEAKERS] count=${ids.length} ids=${JSON.stringify(ids)} t=${new Date().toISOString()}`);
3053
+ }
3054
+ catch { }
3055
+ });
3056
+ room.on(RoomEvent.ConnectionQualityChanged, (quality, participant) => {
3057
+ try {
3058
+ console.log(`📶 [ROOM-QUALITY] participant=${participant?.identity} quality=${quality} t=${new Date().toISOString()}`);
3059
+ }
3060
+ catch { }
3061
+ });
3062
+ room.on(RoomEvent.TrackMuted, (publication, participant) => {
3063
+ try {
3064
+ console.log(`🔇 [ROOM-TRACK-MUTED] participant=${participant?.identity} kind=${publication?.kind} source=${publication?.source} sid=${publication?.sid} t=${new Date().toISOString()}`);
3065
+ }
3066
+ catch { }
3067
+ });
3068
+ room.on(RoomEvent.TrackUnmuted, (publication, participant) => {
3069
+ try {
3070
+ console.log(`🔊 [ROOM-TRACK-UNMUTED] participant=${participant?.identity} kind=${publication?.kind} source=${publication?.source} sid=${publication?.sid} t=${new Date().toISOString()}`);
3071
+ }
3072
+ catch { }
3073
+ });
3074
+ room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
3075
+ try {
3076
+ console.log(`📥 [ROOM-TRACK-SUBSCRIBED] participant=${participant?.identity} kind=${track?.kind} source=${publication?.source} sid=${publication?.sid} t=${new Date().toISOString()}`);
3077
+ }
3078
+ catch { }
3079
+ });
3080
+ room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
3081
+ try {
3082
+ console.log(`📤 [ROOM-TRACK-UNSUBSCRIBED] participant=${participant?.identity} kind=${track?.kind} source=${publication?.source} sid=${publication?.sid} t=${new Date().toISOString()}`);
3083
+ }
3084
+ catch { }
3085
+ });
3086
+ room.on(RoomEvent.Disconnected, handleRoomDisconnected);
3087
+ room.on(RoomEvent.ParticipantConnected, (p) => { participantConnectedHandler?.(p); });
3088
+ room.on(RoomEvent.ParticipantDisconnected, handleParticipantDisconnected);
3089
+ room.on(RoomEvent.DataReceived, handleDataReceived);
3090
+ };
3091
+ // 0.9.83: handler kept as a named reference so createRoomSession's adopt-sweep
3092
+ // can CALL it directly for participants already in the room at join time
3093
+ // (they fire no ParticipantConnected event — rtc-node populates them from the
3094
+ // connect callback, not via events).
3078
3095
  participantConnectedHandler = async (participant) => {
3079
3096
  console.log(`\n👤 User joined: ${participant.identity}`);
3080
3097
  // A user is present — cancel any pending agent-side "alone" leave.
@@ -3082,6 +3099,8 @@ async function main() {
3082
3099
  clearTimeout(aloneTimer);
3083
3100
  aloneTimer = null;
3084
3101
  }
3102
+ // 0.9.83: and the fast tab-close leave (user rejoined within the grace).
3103
+ cancelFastLeaveTimer();
3085
3104
  // 0.9.73: and any pending idle machine self-stop.
3086
3105
  cancelIdleExitTimer();
3087
3106
  // Wait for previous session's byte stream handler to fully deregister.
@@ -3608,7 +3627,7 @@ async function main() {
3608
3627
  console.log(`⚠️ Recovery too frequent — scheduling retry in ${MIN_RECOVERY_INTERVAL}ms`);
3609
3628
  setTimeout(async () => {
3610
3629
  // Re-check: if session was already recovered or user left, skip
3611
- if (currentSession || !room.remoteParticipants.size)
3630
+ if (currentSession || !activeRoom || !activeRoom.remoteParticipants.size)
3612
3631
  return;
3613
3632
  console.log('🔄 Retrying direct mode recovery after guard interval...');
3614
3633
  // Trigger recovery by emitting a synthetic close
@@ -3688,7 +3707,7 @@ async function main() {
3688
3707
  currentAgent = newAgent;
3689
3708
  // Re-wire event listeners on the new session
3690
3709
  wireSessionEvents(newSession, newAgent);
3691
- await newSession.start({ agent: newAgent, room });
3710
+ await newSession.start({ agent: newAgent, room: activeRoom });
3692
3711
  // Sync state
3693
3712
  agentState = 'listening';
3694
3713
  sendToFrontend({ type: 'agent_state', state: 'listening' });
@@ -3767,7 +3786,7 @@ async function main() {
3767
3786
  currentAgent = newAgent;
3768
3787
  // Re-wire event listeners on the new session
3769
3788
  wireSessionEvents(newSession, newAgent);
3770
- await newSession.start({ agent: newAgent, room });
3789
+ await newSession.start({ agent: newAgent, room: activeRoom });
3771
3790
  // Sync state
3772
3791
  agentState = 'listening';
3773
3792
  sendToFrontend({ type: 'agent_state', state: 'listening' });
@@ -3807,7 +3826,7 @@ async function main() {
3807
3826
  // Start voice session
3808
3827
  console.log('🎬 Starting voice session...');
3809
3828
  try {
3810
- await session.start({ agent, room });
3829
+ await session.start({ agent, room: activeRoom });
3811
3830
  console.log('✅ Voice session started!');
3812
3831
  console.log('🎤 Ready - speak to begin!\n');
3813
3832
  // Workspace is created later in the session_id event handler (when SDK assigns real ID)
@@ -3938,8 +3957,9 @@ async function main() {
3938
3957
  console.error('❌ Failed to start session:', err);
3939
3958
  }
3940
3959
  };
3941
- room.on(RoomEvent.ParticipantConnected, (p) => { participantConnectedHandler?.(p); });
3942
- room.on(RoomEvent.ParticipantDisconnected, (participant) => {
3960
+ // ParticipantConnected is registered in wireRoomHandlers (delegates to
3961
+ // participantConnectedHandler above).
3962
+ const handleParticipantDisconnected = (participant) => {
3943
3963
  console.log(`👋 User left: ${participant.identity}`);
3944
3964
  // Full cleanup — stop all background work to avoid accumulating API usage
3945
3965
  voiceQueue.length = 0;
@@ -3992,13 +4012,14 @@ async function main() {
3992
4012
  activeMeetingBotId = null;
3993
4013
  }
3994
4014
  }
3995
- // Arm the agent-side "alone" leave timer (tab-close-proof — runs on the
3996
- // agent, not the frontend, so it fires even if the user closed the tab
3997
- // without clicking leave).
3998
- armAloneTimer();
4015
+ // 0.9.83: a real session just ended → use the FAST leave (~20s), not the
4016
+ // 3-min alone grace. Runs on the agent, so it fires even on an abrupt tab
4017
+ // close. Cancelled if a user rejoins within the grace (handled where the
4018
+ // participant-join cancels timers).
4019
+ armFastLeaveTimer();
3999
4020
  console.log('⏳ Waiting for new user...\n');
4000
- });
4001
- room.on(RoomEvent.DataReceived, async (payload, participant, kind, topic) => {
4021
+ };
4022
+ const handleDataReceived = async (payload, participant, kind, topic) => {
4002
4023
  if (topic !== 'user-input')
4003
4024
  return;
4004
4025
  try {
@@ -4558,6 +4579,33 @@ async function main() {
4558
4579
  }
4559
4580
  }
4560
4581
  }
4582
+ else if (data.type === 'skill_get') {
4583
+ const folder = (data.name || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
4584
+ const p = join(sessionBaseDir, '.claude', 'skills', folder, 'SKILL.md');
4585
+ if (folder && existsSync(p)) {
4586
+ await sendToFrontend({ type: 'skill_content', name: folder, content: readFileSync(p, 'utf-8') });
4587
+ }
4588
+ else {
4589
+ await sendToFrontend({ type: 'skill_content', name: folder, error: 'skill not found' });
4590
+ }
4591
+ }
4592
+ else if (data.type === 'skill_remove') {
4593
+ const folder = (data.name || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
4594
+ const dir = join(sessionBaseDir, '.claude', 'skills', folder);
4595
+ if (!folder || !existsSync(dir)) {
4596
+ await sendToFrontend({ type: 'skill_remove_result', success: false, error: 'skill not found' });
4597
+ }
4598
+ else {
4599
+ try {
4600
+ rmSync(dir, { recursive: true, force: true });
4601
+ console.log(`🗑️ Skill removed: ${folder}`);
4602
+ await sendToFrontend({ type: 'skill_remove_result', success: true, skills: loadSkillsList(sessionBaseDir) });
4603
+ }
4604
+ catch (err) {
4605
+ await sendToFrontend({ type: 'skill_remove_result', success: false, error: String(err) });
4606
+ }
4607
+ }
4608
+ }
4561
4609
  else if (data.type === 'join_meeting') {
4562
4610
  const meetingUrl = data.url;
4563
4611
  if (meetingUrl) {
@@ -4725,133 +4773,199 @@ async function main() {
4725
4773
  }
4726
4774
  }
4727
4775
  catch { }
4728
- });
4776
+ };
4729
4777
  // ============================================================
4730
- // Connect to Room
4778
+ // Room session lifecycle (0.9.83): fresh Room per user session
4731
4779
  // ============================================================
4732
- // Connect to LiveKit with retry-on-failure.
4733
- //
4734
- // Earlier behavior: a single attempt followed by `process.exit(1)` on error.
4735
- // Combined with Fly's restart policy this produced a tight restart loop that
4736
- // (a) hit the same 429 / auth error every ~30s, (b) burned the LiveKit
4737
- // quota's retry budget, and (c) hit Fly's max-restart-count (3) and killed
4738
- // the machine — at which point the frontend's /api/sandbox probe saw the
4739
- // sandbox as failed and bounced the user back to the dashboard with no
4740
- // useful error.
4741
- //
4742
- // New behavior: bounded-backoff retry, infinite attempts. The API server
4743
- // stays up the whole time serving /health (which surfaces the LiveKit error
4744
- // as a field, NOT as an HTTP failure — see the /health handler comment).
4745
- // When the underlying issue is resolved (quota reset, key fixed, LiveKit
4746
- // service back), the next retry succeeds and the agent picks up where it
4747
- // left off without anyone needing to manually restart.
4780
+ // createRoomSession(roomName): mint a FRESH Room, wire ALL handlers onto it,
4781
+ // mint a room-scoped JWT, connect with a bounded retry, run the post-connect
4782
+ // setup + adopt-sweep, and mark status='connected'. The caller AWAITS this so
4783
+ // /connect-room can respond only once the agent is actually in the room.
4748
4784
  //
4749
- // Backoff: 5s → 10s → 20s → 40s → 60s (capped). Resets to 5s after each
4750
- // successful connect (so a single transient hiccup doesn't disable fast
4751
- // recovery on the next disconnect).
4752
- const connectWithRetry = async () => {
4753
- const backoffSchedule = [5_000, 10_000, 20_000, 40_000, 60_000];
4754
- let backoffIdx = 0;
4755
- while (true) {
4756
- livekitState.status = livekitState.attemptCount === 0 ? 'connecting' : 'retrying';
4757
- livekitState.lastAttemptAt = Date.now();
4758
- livekitState.attemptCount += 1;
4759
- try {
4760
- await room.connect(livekitUrl, jwt, {
4761
- autoSubscribe: true,
4762
- dynacast: true,
4763
- });
4764
- localParticipant = room.localParticipant;
4765
- livekitState.status = 'connected';
4766
- livekitState.error = null;
4767
- livekitState.errorCode = null;
4768
- backoffIdx = 0;
4769
- console.log('✅ Connected to room:', roomName);
4770
- console.log('\n⏳ Waiting for user to connect...');
4771
- console.log(` Room: ${roomCode}\n`);
4772
- return;
4785
+ // Bounded retry (3 attempts, backoff 5s→10s→20s): the caller is awaiting, so
4786
+ // we can't loop forever like the old boot-time connectWithRetry did. If all
4787
+ // attempts fail we surface status='failed' and rethrow so the caller sees it.
4788
+ // A single transient blip is absorbed; a persistent failure (quota/auth) is
4789
+ // reported promptly rather than hanging the connect flow.
4790
+ let creatingRoomSession = false;
4791
+ const createRoomSession = async (targetRoomName) => {
4792
+ if (creatingRoomSession) {
4793
+ console.log('⏳ createRoomSession already in progress — skipping duplicate');
4794
+ return;
4795
+ }
4796
+ creatingRoomSession = true;
4797
+ try {
4798
+ // Discard any prior (dead/stale) instance without reuse.
4799
+ if (activeRoom) {
4800
+ console.warn('⚠️ createRoomSession called with an existing activeRoom — discarding it (no reuse)');
4801
+ activeRoom = null;
4773
4802
  }
4774
- catch (err) {
4775
- const msg = err instanceof Error ? err.message : String(err);
4776
- // Categorize the error so the frontend can show specific guidance.
4777
- // Substring matching on the message because LiveKit's rtc-node SDK
4778
- // wraps the underlying HTTP status in a generic ConnectError.
4779
- let errorCode;
4780
- if (/429|connection minutes limit/i.test(msg))
4781
- errorCode = 'quota_exceeded';
4782
- else if (/401|403|unauthorized|invalid/i.test(msg))
4783
- errorCode = 'auth';
4784
- else if (/ENOTFOUND|ECONNREFUSED|ETIMEDOUT|network/i.test(msg))
4785
- errorCode = 'network';
4786
- else
4787
- errorCode = 'unknown';
4788
- livekitState.status = 'failed';
4789
- livekitState.error = msg;
4790
- livekitState.errorCode = errorCode;
4791
- const waitMs = backoffSchedule[Math.min(backoffIdx, backoffSchedule.length - 1)];
4792
- backoffIdx += 1;
4793
- console.error(`❌ LiveKit connect failed (${errorCode}, attempt ${livekitState.attemptCount}): ${msg.substring(0, 200)}`);
4794
- console.error(` Retrying in ${waitMs / 1000}s — process staying alive; /health remains 200 with livekit.status='failed'`);
4795
- await new Promise(r => setTimeout(r, waitMs));
4796
- // loop — try again
4803
+ const room = new Room();
4804
+ room.setMaxListeners(50); // Prevent MaxListenersExceeded warnings
4805
+ wireRoomHandlers(room);
4806
+ const backoffSchedule = [5_000, 10_000, 20_000];
4807
+ let lastErr = null;
4808
+ for (let attempt = 0; attempt < 3; attempt++) {
4809
+ livekitState.status = livekitState.attemptCount === 0 ? 'connecting' : 'retrying';
4810
+ livekitState.lastAttemptAt = Date.now();
4811
+ livekitState.attemptCount += 1;
4812
+ try {
4813
+ const jwt = await mintAgentToken(targetRoomName);
4814
+ await room.connect(livekitUrl, jwt, {
4815
+ autoSubscribe: true,
4816
+ dynacast: true,
4817
+ });
4818
+ // Success — adopt this instance as the active room.
4819
+ activeRoom = room;
4820
+ activeRoomName = targetRoomName;
4821
+ currentRoomCode = targetRoomName; // legacy /room-code returns last-created NAME
4822
+ localParticipant = room.localParticipant;
4823
+ livekitState.status = 'connected';
4824
+ livekitState.error = null;
4825
+ livekitState.errorCode = null;
4826
+ console.log('✅ Connected to room:', targetRoomName);
4827
+ // Post-connect setup (re-homed from the dead RoomEvent.Connected handler).
4828
+ postConnectSetup(room, targetRoomName);
4829
+ // Adopt-sweep: ParticipantConnected only fires for participants who join
4830
+ // AFTER us. Anyone already in the room at connect time is populated by
4831
+ // rtc-node from the connect callback (no event) — invoke the handler
4832
+ // directly so their voice session is created. Runs exactly once per
4833
+ // connect (replaces the old adopt-poll + connectRoomHook adopt loop).
4834
+ for (const p of room.remoteParticipants.values()) {
4835
+ console.log(`👥 Participant already in room at join: ${p.identity} — adopting (invoking handler directly)`);
4836
+ participantConnectedHandler?.(p).catch((e) => console.error('👥 adopt handler error:', e instanceof Error ? e.message : e));
4837
+ }
4838
+ console.log('\n⏳ Waiting for user to connect...');
4839
+ console.log(` Room: ${targetRoomName}\n`);
4840
+ return;
4841
+ }
4842
+ catch (err) {
4843
+ lastErr = err;
4844
+ const msg = err instanceof Error ? err.message : String(err);
4845
+ // Categorize the error so the frontend can show specific guidance.
4846
+ let errorCode;
4847
+ if (/429|connection minutes limit/i.test(msg))
4848
+ errorCode = 'quota_exceeded';
4849
+ else if (/401|403|unauthorized|invalid/i.test(msg))
4850
+ errorCode = 'auth';
4851
+ else if (/ENOTFOUND|ECONNREFUSED|ETIMEDOUT|network/i.test(msg))
4852
+ errorCode = 'network';
4853
+ else
4854
+ errorCode = 'unknown';
4855
+ livekitState.status = 'failed';
4856
+ livekitState.error = msg;
4857
+ livekitState.errorCode = errorCode;
4858
+ console.error(`❌ LiveKit connect failed (${errorCode}, attempt ${attempt + 1}/3): ${msg.substring(0, 200)}`);
4859
+ if (attempt < 2) {
4860
+ const waitMs = backoffSchedule[attempt];
4861
+ console.error(` Retrying in ${waitMs / 1000}s...`);
4862
+ await new Promise(r => setTimeout(r, waitMs));
4863
+ }
4864
+ }
4797
4865
  }
4866
+ // All attempts failed. Drop the dead instance; leave status='failed' so
4867
+ // /health surfaces it and the frontend can retry /connect-room.
4868
+ activeRoom = null;
4869
+ activeRoomName = null;
4870
+ console.error('❌ createRoomSession: all 3 connect attempts failed — giving up (status=failed)');
4871
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
4872
+ }
4873
+ finally {
4874
+ creatingRoomSession = false;
4798
4875
  }
4799
4876
  };
4800
- // Wire the module-level HTTP control hooks now that `room` and the
4801
- // connect-with-retry loop exist (see Room-presence lifecycle note up top).
4802
- // The /connect-room and /leave-room endpoints in startApiServer call these.
4803
- connectRoomHook = async () => {
4804
- intentionalLeave = false;
4877
+ // destroyRoomSession(reason): tear down the active room session and DISCARD
4878
+ // the Room instance (never reused). A hung rtc-node disconnect() (signal-less
4879
+ // waitFor at room.js:579) is made harmless by racing it against a 10s timeout:
4880
+ // on timeout we just log and drop the instance — nothing reuses it, so a
4881
+ // zombie WS at the Rust layer can't wedge the next session. Idempotent.
4882
+ let destroyingRoomSession = false;
4883
+ const destroyRoomSession = async (reason) => {
4884
+ if (destroyingRoomSession)
4885
+ return;
4886
+ if (!activeRoom) {
4887
+ // Already idle — still ensure billing backstop is armed.
4888
+ livekitState.status = 'idle';
4889
+ armIdleExitTimer(`destroyRoomSession(${reason}) — already idle`);
4890
+ return;
4891
+ }
4892
+ destroyingRoomSession = true;
4893
+ const room = activeRoom;
4894
+ // Drop the reference immediately so nothing (handlers, sweeps) touches it.
4895
+ activeRoom = null;
4896
+ activeRoomName = null;
4805
4897
  if (aloneTimer) {
4806
4898
  clearTimeout(aloneTimer);
4807
4899
  aloneTimer = null;
4808
4900
  }
4809
- // 0.9.73: a user is on the way — cancel any pending idle machine self-stop.
4901
+ cancelFastLeaveTimer();
4902
+ intentionalLeave = true; // suppress the Disconnected handler's auto-rejoin
4903
+ console.log(`🚪 Destroying room session (${reason}) — disconnecting + discarding Room instance`);
4904
+ try {
4905
+ const timeout = new Promise((resolve) => setTimeout(() => resolve('timeout'), 10_000));
4906
+ const result = await Promise.race([room.disconnect().then(() => 'ok'), timeout]);
4907
+ if (result === 'timeout') {
4908
+ console.error('⚠️ room.disconnect() still pending after 10s (zombie WS) — abandoning the instance');
4909
+ try {
4910
+ room.ffiHandle?.dispose?.();
4911
+ }
4912
+ catch { }
4913
+ }
4914
+ }
4915
+ catch (e) {
4916
+ console.error('destroyRoomSession room.disconnect failed:', e);
4917
+ }
4918
+ livekitState.status = 'idle';
4919
+ livekitState.error = null;
4920
+ livekitState.errorCode = null;
4921
+ armIdleExitTimer(`destroyRoomSession(${reason})`);
4922
+ destroyingRoomSession = false;
4923
+ };
4924
+ // Wire the module-level HTTP control hooks now that the room-session lifecycle
4925
+ // functions exist. The /connect-room and /leave-room endpoints call these.
4926
+ //
4927
+ // NOTE (frontend migration): ChatSessionProvider should later call
4928
+ // POST /connect-room FIRST and mint its LiveKit token from the returned
4929
+ // { roomName } (which connectRoomHook resolves to). During rollout, the legacy
4930
+ // GET /room-code endpoint keeps returning the last-created room NAME so old
4931
+ // frontends still function.
4932
+ connectRoomHook = async () => {
4933
+ intentionalLeave = false;
4810
4934
  cancelIdleExitTimer();
4811
- if (livekitState.status === 'connected')
4812
- return; // already in the room — no-op
4813
- console.log('🔌 /connect-room — joining LiveKit for incoming user');
4814
- await connectWithRetry();
4815
- // Join-race fix: ParticipantConnected only fires for participants who join
4816
- // AFTER us. If the user's browser won the race (its token mint + WebRTC
4817
- // handshake finished before our rejoin), they're already in the room when
4818
- // we arrive — no event, no voice session, UI hangs at "Connecting...".
4819
- // Re-emit the event for every pre-existing participant so the session is
4820
- // created exactly as if they had joined after us.
4821
- for (const p of room.remoteParticipants.values()) {
4822
- console.log(`👥 Participant already in room at join: ${p.identity} — adopting (invoking handler directly)`);
4823
- participantConnectedHandler?.(p).catch((e) => console.error('👥 adopt handler error:', e instanceof Error ? e.message : e));
4935
+ // Idempotency: an active session with a user present is reused (return its
4936
+ // room name). An active-but-empty session (agent joined, user never showed,
4937
+ // or a stale race) is torn down and replaced with a fresh one.
4938
+ if (activeRoom && activeRoomName) {
4939
+ if (activeRoom.remoteParticipants.size > 0) {
4940
+ console.log(`🔌 /connect-room — already connected with a user present, reusing ${activeRoomName}`);
4941
+ return activeRoomName;
4942
+ }
4943
+ console.log('🔌 /connect-room — active room is empty; tearing down + creating fresh');
4944
+ await destroyRoomSession('connect-room replacing empty session');
4824
4945
  }
4946
+ const targetRoomName = buildRoomName();
4947
+ console.log(`🔌 /connect-room — creating fresh room session: ${targetRoomName}`);
4948
+ await createRoomSession(targetRoomName);
4949
+ return targetRoomName;
4825
4950
  };
4826
- // 0.9.73: explicit frontend leave should also start the idle-exit countdown —
4827
- // the user is done; the machine shouldn't idle-bill waiting for a maybe-return.
4828
- // (leaveRoomHook sets intentionalLeave → Disconnected handler arms it, but
4829
- // arm here too in case the disconnect event is swallowed by a zombie WS.)
4830
4951
  leaveRoomHook = async (reason) => {
4831
4952
  if (aloneTimer) {
4832
4953
  clearTimeout(aloneTimer);
4833
4954
  aloneTimer = null;
4834
4955
  }
4835
- if (livekitState.status !== 'connected')
4956
+ if (!activeRoom)
4836
4957
  return; // already out — no-op
4837
4958
  // Never abandon a user who is CURRENTLY in the room. Stale/racing
4838
4959
  // leave-room calls (a previous client's teardown landing after a new
4839
4960
  // client joined) were kicking the agent out mid-adopt, stranding the new
4840
4961
  // user on "Connecting..." (observed 2026-07-28). The alone-timer handles
4841
4962
  // the real departure when they actually leave.
4842
- if (room.remoteParticipants.size > 0) {
4843
- console.log(`🛑 /leave-room ignored (${reason}) — ${room.remoteParticipants.size} participant(s) still in room`);
4963
+ if (activeRoom.remoteParticipants.size > 0) {
4964
+ console.log(`🛑 /leave-room ignored (${reason}) — ${activeRoom.remoteParticipants.size} participant(s) still in room`);
4844
4965
  return;
4845
4966
  }
4846
- intentionalLeave = true;
4847
- console.log(`🚪 Leaving LiveKit room (${reason}) — stops connection-minute burn`);
4848
- armIdleExitTimer(`explicit leave (${reason})`);
4849
- try {
4850
- await room.disconnect();
4851
- }
4852
- catch (e) {
4853
- console.error('leave-room room.disconnect failed:', e);
4854
- }
4967
+ console.log(`🚪 /leave-room (${reason}) — destroying room session`);
4968
+ await destroyRoomSession(`explicit leave (${reason})`);
4855
4969
  };
4856
4970
  // bug-reporter skill hook — forwards a validated bug payload to the frontend
4857
4971
  // via the LiveKit data channel. Frontend (which holds the Supabase keys for
@@ -4887,18 +5001,19 @@ async function main() {
4887
5001
  },
4888
5002
  }).catch((e) => console.error('❌ bugReportHook sendToFrontend failed:', e));
4889
5003
  };
4890
- // Fire and forget; the retry loop keeps the process alive on its own (so
4891
- // we don't need the explicit `new Promise(() => {})` keepalive anymore).
4892
- // Errors that escape the retry loop should never happen, but if they do,
4893
- // log them rather than crash.
4894
- connectWithRetry().catch(err => {
4895
- console.error('❌ Unrecoverable error in LiveKit retry loop (should not happen):', err);
4896
- livekitState.status = 'failed';
4897
- livekitState.error = err instanceof Error ? err.message : String(err);
4898
- livekitState.errorCode = 'unrecoverable';
4899
- });
5004
+ // 0.9.83: boot IDLE — do NOT eager-connect to LiveKit at startup. Under the
5005
+ // fresh-Room-per-session model there is no persistent room to join at boot;
5006
+ // the agent only joins when a user arrives via POST /connect-room (which
5007
+ // awaits createRoomSession). This also removes the old boot-time
5008
+ // connectWithRetry() eager connect entirely. The idle-exit timer is armed so
5009
+ // a machine that boots but never receives a /connect-room still stops itself
5010
+ // (billing protection) instead of idling forever.
5011
+ livekitState.status = 'idle';
5012
+ console.log('🟢 Agent booted — idle, awaiting POST /connect-room to join LiveKit');
5013
+ armIdleExitTimer('boot idle (no session yet)');
4900
5014
  // Keep main() alive forever — without this the await chain ends and Node
4901
- // exits 0, which Fly treats as a clean shutdown.
5015
+ // exits 0, which Fly treats as a clean shutdown. The HTTP API server + timers
5016
+ // keep the process responsive.
4902
5017
  await new Promise(() => { });
4903
5018
  }
4904
5019
  // Run
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.81",
3
+ "version": "0.9.83",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {