osborn 0.9.51 → 0.9.52
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 +136 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -165,6 +165,30 @@ const livekitState = {
|
|
|
165
165
|
lastAttemptAt: null,
|
|
166
166
|
attemptCount: 0,
|
|
167
167
|
};
|
|
168
|
+
// ── Room-presence lifecycle (2026-06-09) ──────────────────────────────────────
|
|
169
|
+
// The agent used to eager-connect to LiveKit on boot and hold the room for the
|
|
170
|
+
// machine's entire life. With 1 participant (the agent itself), LiveKit never
|
|
171
|
+
// considers the room empty, so it never closes — a single forgotten session
|
|
172
|
+
// burned 25h of connection-minutes (room osborn-jzs94j) before we caught it.
|
|
173
|
+
//
|
|
174
|
+
// Fix: the agent now LEAVES the LiveKit room when no user is present, and only
|
|
175
|
+
// rejoins when a user actually connects. Two triggers, both feeding room.disconnect():
|
|
176
|
+
// 1. Agent-side "alone" timer — armed in ParticipantDisconnected once a real
|
|
177
|
+
// session has ended; if no user rejoins within ALONE_GRACE_MS, the agent
|
|
178
|
+
// leaves on its own. This is tab-close-proof (does not depend on the
|
|
179
|
+
// frontend's JS still running — the exact gap that let the 25h room linger).
|
|
180
|
+
// 2. POST /leave-room — the frontend's explicit "leave" button leaves instantly.
|
|
181
|
+
// Rejoin happens via POST /connect-room (frontend connect flow) which re-runs the
|
|
182
|
+
// connect-with-retry loop.
|
|
183
|
+
//
|
|
184
|
+
// `intentionalLeave` distinguishes a voluntary leave from an involuntary LiveKit
|
|
185
|
+
// eviction. The ghost-agent fix in RoomEvent.Disconnected auto-rejoins on drop;
|
|
186
|
+
// that must NOT fire after a voluntary leave (it would recreate the burn we just
|
|
187
|
+
// stopped). The hooks below are populated by main() (which owns `room` and the
|
|
188
|
+
// connect-with-retry loop) so the module-level HTTP server can drive them.
|
|
189
|
+
let intentionalLeave = false;
|
|
190
|
+
let connectRoomHook = null;
|
|
191
|
+
let leaveRoomHook = null;
|
|
168
192
|
function startApiServer(workingDir, port) {
|
|
169
193
|
const server = createServer(async (req, res) => {
|
|
170
194
|
// CORS headers for cloud frontend
|
|
@@ -282,6 +306,32 @@ function startApiServer(workingDir, port) {
|
|
|
282
306
|
res.end(JSON.stringify({ roomCode: currentRoomCode }));
|
|
283
307
|
return;
|
|
284
308
|
}
|
|
309
|
+
// POST /connect-room — the frontend connect flow calls this so the agent
|
|
310
|
+
// joins LiveKit for an incoming user. Idempotent: if already connected it's
|
|
311
|
+
// a no-op. Must complete (agent in room) BEFORE the user joins, because the
|
|
312
|
+
// voice session is created from the ParticipantConnected event, which only
|
|
313
|
+
// fires for participants who join AFTER the agent. The frontend polls
|
|
314
|
+
// /health for livekit.status==='connected' before minting its token.
|
|
315
|
+
if (req.method === 'POST' && url.pathname === '/connect-room') {
|
|
316
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
317
|
+
res.end(JSON.stringify({ ok: true, status: livekitState.status }));
|
|
318
|
+
if (connectRoomHook) {
|
|
319
|
+
connectRoomHook().catch((e) => console.error('❌ /connect-room hook failed:', e));
|
|
320
|
+
}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
// POST /leave-room — the frontend's explicit "leave"/disconnect leaves the
|
|
324
|
+
// LiveKit room immediately so connection-minute burn stops the instant the
|
|
325
|
+
// user is done (no waiting for the agent-side alone timer). Sets
|
|
326
|
+
// intentionalLeave so the Disconnected handler does NOT auto-rejoin.
|
|
327
|
+
if (req.method === 'POST' && url.pathname === '/leave-room') {
|
|
328
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
329
|
+
res.end(JSON.stringify({ ok: true }));
|
|
330
|
+
if (leaveRoomHook) {
|
|
331
|
+
leaveRoomHook('frontend_leave').catch((e) => console.error('❌ /leave-room hook failed:', e));
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
285
335
|
// POST /restart — graceful process restart (process manager will restart)
|
|
286
336
|
if (req.method === 'POST' && url.pathname === '/restart') {
|
|
287
337
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -1069,6 +1119,35 @@ async function main() {
|
|
|
1069
1119
|
let currentSession = null;
|
|
1070
1120
|
let currentAgent = null; // For updateChatCtx() context injection
|
|
1071
1121
|
let currentLLM = null;
|
|
1122
|
+
// Agent-side "alone in room" leave timer (see Room-presence lifecycle note up
|
|
1123
|
+
// top). Armed in ParticipantDisconnected once a user has left; if no one
|
|
1124
|
+
// rejoins within the grace window the agent leaves LiveKit on its own.
|
|
1125
|
+
// Cancelled in ParticipantConnected. 3 min: long enough to ride out a brief
|
|
1126
|
+
// reconnect (page refresh, network blip), short enough that a forgotten
|
|
1127
|
+
// session costs ~3 min of connection-minutes instead of hours.
|
|
1128
|
+
let aloneTimer = null;
|
|
1129
|
+
const ALONE_GRACE_MS = 3 * 60 * 1000;
|
|
1130
|
+
// Arm (or re-arm) the alone timer: if no remote participant is present, leave
|
|
1131
|
+
// the LiveKit room after the grace window. Called on Connected (covers a
|
|
1132
|
+
// machine woken but then abandoned before the user joined) and on
|
|
1133
|
+
// ParticipantDisconnected (covers a finished session). Cancelled the moment a
|
|
1134
|
+
// user joins. Net invariant: the agent never holds an empty room beyond
|
|
1135
|
+
// ALONE_GRACE_MS, in any scenario — the root cause of the 25h burn.
|
|
1136
|
+
const armAloneTimer = () => {
|
|
1137
|
+
if (aloneTimer)
|
|
1138
|
+
clearTimeout(aloneTimer);
|
|
1139
|
+
aloneTimer = null;
|
|
1140
|
+
if (room.remoteParticipants.size > 0)
|
|
1141
|
+
return;
|
|
1142
|
+
aloneTimer = setTimeout(() => {
|
|
1143
|
+
aloneTimer = null;
|
|
1144
|
+
if (room.remoteParticipants.size === 0 && livekitState.status === 'connected') {
|
|
1145
|
+
console.log(`🕊️ Alone in room ${ALONE_GRACE_MS / 1000}s — leaving LiveKit to stop connection-minute burn`);
|
|
1146
|
+
intentionalLeave = true;
|
|
1147
|
+
room.disconnect().catch((e) => console.error('alone-leave room.disconnect failed:', e));
|
|
1148
|
+
}
|
|
1149
|
+
}, ALONE_GRACE_MS);
|
|
1150
|
+
};
|
|
1072
1151
|
/**
|
|
1073
1152
|
* Hard-kill the in-flight Claude SDK query AND the persistent subprocess.
|
|
1074
1153
|
*
|
|
@@ -2505,6 +2584,10 @@ async function main() {
|
|
|
2505
2584
|
room.on(RoomEvent.Connected, () => {
|
|
2506
2585
|
console.log('✅ Connected to room:', roomName);
|
|
2507
2586
|
localParticipant = room.localParticipant;
|
|
2587
|
+
// Arm the alone timer: if we connected but no user joins within the grace
|
|
2588
|
+
// window (e.g. machine woken then abandoned mid-handshake), leave the room
|
|
2589
|
+
// rather than hold it indefinitely. Cancelled in ParticipantConnected.
|
|
2590
|
+
armAloneTimer();
|
|
2508
2591
|
});
|
|
2509
2592
|
// NOTE: previously this section also had a RoomEvent.ActiveSpeakersChanged
|
|
2510
2593
|
// handler that interrupted TTS on any sustained audio activity (~50ms after
|
|
@@ -2559,6 +2642,20 @@ async function main() {
|
|
|
2559
2642
|
//
|
|
2560
2643
|
// Note: we mark status='retrying' immediately so /health reflects the real
|
|
2561
2644
|
// state — closing the lie window between Disconnected and the next attempt.
|
|
2645
|
+
// ── Voluntary-leave guard (2026-06-09) ──
|
|
2646
|
+
// If we left the room ON PURPOSE (user clicked leave → /leave-room, or the
|
|
2647
|
+
// agent-side alone timer fired), do NOT auto-rejoin — rejoining would
|
|
2648
|
+
// recreate the connection-minute burn we just stopped. Mark the connection
|
|
2649
|
+
// 'idle' (machine stays warm, /health still 200) and wait for the next
|
|
2650
|
+
// /connect-room. Reset the flag so a later involuntary drop still rejoins.
|
|
2651
|
+
if (intentionalLeave) {
|
|
2652
|
+
intentionalLeave = false;
|
|
2653
|
+
livekitState.status = 'idle';
|
|
2654
|
+
livekitState.error = null;
|
|
2655
|
+
livekitState.errorCode = null;
|
|
2656
|
+
console.log('🕊️ Left LiveKit room intentionally — idle, awaiting /connect-room (no auto-rejoin)');
|
|
2657
|
+
return;
|
|
2658
|
+
}
|
|
2562
2659
|
livekitState.status = 'retrying';
|
|
2563
2660
|
livekitState.error = 'LiveKit room disconnected; attempting to rejoin';
|
|
2564
2661
|
livekitState.errorCode = 'disconnected';
|
|
@@ -2569,6 +2666,11 @@ async function main() {
|
|
|
2569
2666
|
});
|
|
2570
2667
|
room.on(RoomEvent.ParticipantConnected, async (participant) => {
|
|
2571
2668
|
console.log(`\n👤 User joined: ${participant.identity}`);
|
|
2669
|
+
// A user is present — cancel any pending agent-side "alone" leave.
|
|
2670
|
+
if (aloneTimer) {
|
|
2671
|
+
clearTimeout(aloneTimer);
|
|
2672
|
+
aloneTimer = null;
|
|
2673
|
+
}
|
|
2572
2674
|
// Wait for previous session's byte stream handler to fully deregister.
|
|
2573
2675
|
// Quick reconnects (< ~6s) crash with "byte stream handler already set" without this.
|
|
2574
2676
|
if (pendingSessionClose) {
|
|
@@ -3291,6 +3393,10 @@ async function main() {
|
|
|
3291
3393
|
activeMeetingBotId = null;
|
|
3292
3394
|
}
|
|
3293
3395
|
}
|
|
3396
|
+
// Arm the agent-side "alone" leave timer (tab-close-proof — runs on the
|
|
3397
|
+
// agent, not the frontend, so it fires even if the user closed the tab
|
|
3398
|
+
// without clicking leave).
|
|
3399
|
+
armAloneTimer();
|
|
3294
3400
|
console.log('⏳ Waiting for new user...\n');
|
|
3295
3401
|
});
|
|
3296
3402
|
room.on(RoomEvent.DataReceived, async (payload, participant, kind, topic) => {
|
|
@@ -4092,6 +4198,36 @@ async function main() {
|
|
|
4092
4198
|
}
|
|
4093
4199
|
}
|
|
4094
4200
|
};
|
|
4201
|
+
// Wire the module-level HTTP control hooks now that `room` and the
|
|
4202
|
+
// connect-with-retry loop exist (see Room-presence lifecycle note up top).
|
|
4203
|
+
// The /connect-room and /leave-room endpoints in startApiServer call these.
|
|
4204
|
+
connectRoomHook = async () => {
|
|
4205
|
+
intentionalLeave = false;
|
|
4206
|
+
if (aloneTimer) {
|
|
4207
|
+
clearTimeout(aloneTimer);
|
|
4208
|
+
aloneTimer = null;
|
|
4209
|
+
}
|
|
4210
|
+
if (livekitState.status === 'connected')
|
|
4211
|
+
return; // already in the room — no-op
|
|
4212
|
+
console.log('🔌 /connect-room — joining LiveKit for incoming user');
|
|
4213
|
+
await connectWithRetry();
|
|
4214
|
+
};
|
|
4215
|
+
leaveRoomHook = async (reason) => {
|
|
4216
|
+
if (aloneTimer) {
|
|
4217
|
+
clearTimeout(aloneTimer);
|
|
4218
|
+
aloneTimer = null;
|
|
4219
|
+
}
|
|
4220
|
+
if (livekitState.status !== 'connected')
|
|
4221
|
+
return; // already out — no-op
|
|
4222
|
+
intentionalLeave = true;
|
|
4223
|
+
console.log(`🚪 Leaving LiveKit room (${reason}) — stops connection-minute burn`);
|
|
4224
|
+
try {
|
|
4225
|
+
await room.disconnect();
|
|
4226
|
+
}
|
|
4227
|
+
catch (e) {
|
|
4228
|
+
console.error('leave-room room.disconnect failed:', e);
|
|
4229
|
+
}
|
|
4230
|
+
};
|
|
4095
4231
|
// Fire and forget; the retry loop keeps the process alive on its own (so
|
|
4096
4232
|
// we don't need the explicit `new Promise(() => {})` keepalive anymore).
|
|
4097
4233
|
// Errors that escape the retry loop should never happen, but if they do,
|