osborn 0.9.230 → 0.9.232

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.
@@ -896,9 +896,12 @@ export const VERIFIER_CHAIN = {
896
896
  'at most 2 fix rounds, after which surface the outcome to the user rather than looping.',
897
897
  },
898
898
  researcher: {
899
- afterReminder: 'The researcher sub-agent just finished. Before you finalize, use the Task tool to dispatch the ' +
900
- '`reasoner` gate to judge whether the research is complete and well-sourced for the original ' +
901
- 'question. If it returns NEEDS-MORE, send the researcher back — at most 2 rounds, then surface.',
899
+ afterReminder: 'The researcher sub-agent just finished. Before you finalize, you MUST now use the Task tool to ' +
900
+ 'dispatch the `reasoner` GATE to judge whether the research is complete and well-sourced for the ' +
901
+ 'original question — it returns GATE: PASS or GATE: NEEDS-MORE. ' +
902
+ 'If it returns NEEDS-MORE, dispatch the `researcher` again with the gaps it named and then re-gate — ' +
903
+ 'at most 2 rounds, after which surface the outcome to the user rather than looping. ' +
904
+ "This is the research analogue of writer→reviewer: don't rely on the findings until an independent agent has vetted them.",
902
905
  },
903
906
  };
904
907
  const RESEARCH_TOOLS = [
package/dist/index.js CHANGED
@@ -2014,6 +2014,10 @@ async function main() {
2014
2014
  let currentLLM = null;
2015
2015
  const slots = new Map();
2016
2016
  let focusedSlotId = null; // reserved for future focused-slot wiring; not used yet
2017
+ // P1 host-agent anchor: the dashboard-started session that owns the room's env + keeps it alive.
2018
+ let hostActive = false;
2019
+ let hostSessionId = null;
2020
+ let hostWorkingDir = null;
2017
2021
  // Agent-side "alone in room" leave timer (see Room-presence lifecycle note up
2018
2022
  // top). Armed in ParticipantDisconnected once a user has left; if no one
2019
2023
  // rejoins within the grace window the agent leaves LiveKit on its own.
@@ -2028,7 +2032,31 @@ async function main() {
2028
2032
  // ParticipantDisconnected (covers a finished session). Cancelled the moment a
2029
2033
  // user joins. Net invariant: the agent never holds an empty room beyond
2030
2034
  // ALONE_GRACE_MS, in any scenario — the root cause of the 25h burn.
2035
+ // P1: room stays alive while the host anchor is active or any background slot
2036
+ // is running. Teardown timers below early-return on this so a dashboard-started
2037
+ // session + its background dispatches aren't killed by the empty-room grace.
2038
+ const agentsActive = () => hostActive || slots.size > 0;
2039
+ // Release the host anchor + hard-kill background slots at a genuine session-end
2040
+ // boundary so the room can idle-exit (agentsActive → false). Idempotent. MUST be
2041
+ // called before arming idle-exit on EVERY path where the session ends, or the
2042
+ // empty-room billing burn returns — the keep-alive guard is only as safe as its
2043
+ // release sites (endMeeting / intentional-leave / destroyRoomSession / disconnect).
2044
+ const releaseAgents = () => {
2045
+ hostActive = false;
2046
+ hostSessionId = null;
2047
+ hostWorkingDir = null;
2048
+ for (const [, s] of slots) {
2049
+ try {
2050
+ s.llm.abortQuery?.();
2051
+ s.llm.abortAgent?.();
2052
+ }
2053
+ catch { }
2054
+ }
2055
+ slots.clear();
2056
+ };
2031
2057
  const armAloneTimer = () => {
2058
+ if (agentsActive())
2059
+ return;
2032
2060
  if (aloneTimer)
2033
2061
  clearTimeout(aloneTimer);
2034
2062
  aloneTimer = null;
@@ -2054,6 +2082,8 @@ async function main() {
2054
2082
  let fastLeaveTimer = null;
2055
2083
  const POST_LEAVE_GRACE_MS = 20 * 1000;
2056
2084
  const armFastLeaveTimer = () => {
2085
+ if (agentsActive())
2086
+ return;
2057
2087
  if (fastLeaveTimer)
2058
2088
  clearTimeout(fastLeaveTimer);
2059
2089
  fastLeaveTimer = null;
@@ -2099,7 +2129,7 @@ async function main() {
2099
2129
  idleExitTimer = null;
2100
2130
  const stillIdle = livekitState.status === 'idle' || livekitState.status === 'failed';
2101
2131
  const empty = !activeRoom || activeRoom.remoteParticipants.size === 0;
2102
- if (stillIdle && empty) {
2132
+ if (stillIdle && empty && !agentsActive()) {
2103
2133
  console.log(`⏻ [IDLE-EXIT] still idle after grace — exiting 0 so Fly stops the machine (billing stops; next Resume boots it)`);
2104
2134
  // Give the log tee a beat to flush, then stop the machine.
2105
2135
  setTimeout(() => process.exit(0), 2000);
@@ -2229,6 +2259,25 @@ async function main() {
2229
2259
  console.error(`❌ Background slot ${slotId.substring(0, 8)}: resume failed — requested ${requestedSessionId?.substring(0, 8)} got ${actualSessionId?.substring(0, 8)}`);
2230
2260
  sendToFrontend({ type: 'background_session_error', slotId, error: 'Session resume failed' }).catch(() => { });
2231
2261
  });
2262
+ // P1: a real dispatch finished — notify the frontend. The [BACKGROUND_INIT]
2263
+ // ack also fires assistant_result, so gate on dispatchInFlight to skip it.
2264
+ bgEmitter.on('assistant_result', ({ text }) => {
2265
+ const slot = slots.get(slotId);
2266
+ if (!slot)
2267
+ return;
2268
+ // The [BACKGROUND_INIT] kickoff also fires assistant_result and, on the cold-spawn
2269
+ // dispatch path, can arrive AFTER dispatchInFlight was set. Consume that first result
2270
+ // as the kickoff ack so it's never mis-reported as (and never clobbers) the dispatch's
2271
+ // real answer.
2272
+ if (!slot.kickoffDone) {
2273
+ slot.kickoffDone = true;
2274
+ return;
2275
+ }
2276
+ if (!slot.dispatchInFlight)
2277
+ return; // only notify for a real dispatched instruction
2278
+ slot.dispatchInFlight = false;
2279
+ sendToFrontend({ type: 'background_update', sessionId: slotId, stage: 'done', summary: (text || '').trim().slice(0, 500) }).catch(() => { });
2280
+ });
2232
2281
  // Wire up the LLM's internal EventEmitter so hook-emitted events also flow.
2233
2282
  bgLLM.events.on('session_id', (data) => bgEmitter.emit('session_id', data));
2234
2283
  bgLLM.events.on('tool_blocked', (data) => bgEmitter.emit('tool_blocked', data));
@@ -2251,6 +2300,12 @@ async function main() {
2251
2300
  settingSources: ['project', 'user'],
2252
2301
  env: bgEnv,
2253
2302
  };
2303
+ // P1: persist the cold-start emitter + sdkOptions on the slot so a later
2304
+ // dispatch_background can reuse them. CRITICAL: pushMessage's fast path
2305
+ // ignores the callbacks/emitter arg once the subprocess exists, so a
2306
+ // dispatch MUST reuse THIS emitter — otherwise its output goes nowhere.
2307
+ slot.emitter = bgEmitter;
2308
+ slot.sdkOptions = bgSdkOptions;
2254
2309
  // Cold-start: deliver an init message so the subprocess actually spawns
2255
2310
  // and begins consuming the session JSONL.
2256
2311
  const KICKOFF_TEXT = '[BACKGROUND_INIT] You have been resumed in headless background mode. No voice session is active. Acknowledge receipt with a single brief line.';
@@ -2451,6 +2506,7 @@ async function main() {
2451
2506
  currentLLM = null;
2452
2507
  clearPipelineFastBrainSession();
2453
2508
  clearPipelineFastBrainSession();
2509
+ releaseAgents(); // session over → clear host + kill slots so idle-exit can fire
2454
2510
  armIdleExitTimer(`meeting ended (${reason}), no user`);
2455
2511
  }
2456
2512
  };
@@ -3254,6 +3310,15 @@ async function main() {
3254
3310
  // PipelineDirectLLM (llmOverride path) wraps its own ClaudeLLM and may not expose setTurbo.
3255
3311
  directLLM?.setTurbo?.(turboMode);
3256
3312
  currentLLM = directLLM;
3313
+ // P1 host-agent anchor: this dashboard-started session owns the room's env
3314
+ // and keeps it alive (agentsActive → true) so background dispatches survive.
3315
+ // Keep-alive keys off the hostActive boolean, NOT a session_id event;
3316
+ // hostSessionId is best-effort (the focused id is otherwise resolved as
3317
+ // currentLLM?.sessionId || currentResumeSessionId).
3318
+ hostActive = true;
3319
+ hostWorkingDir = workingDir;
3320
+ if (resumeSessionId)
3321
+ hostSessionId = resumeSessionId;
3257
3322
  // Reset the session always-allow list for each new direct session
3258
3323
  sessionAlwaysAllowPaths = new Set();
3259
3324
  // For resumed sessions, eagerly create workspace (we know the real ID)
@@ -3884,6 +3949,7 @@ async function main() {
3884
3949
  console.log('🕊️ Left LiveKit room intentionally — idle, awaiting /connect-room (no auto-rejoin)');
3885
3950
  // 0.9.73: idle machines must not bill forever — stop the Fly machine
3886
3951
  // after the grace window unless a /connect-room revives us first.
3952
+ releaseAgents(); // session over → clear host + kill slots so idle-exit can fire
3887
3953
  armIdleExitTimer('intentional leave → idle');
3888
3954
  return;
3889
3955
  }
@@ -4843,6 +4909,12 @@ async function main() {
4843
4909
  currentLLM = null;
4844
4910
  clearPipelineFastBrainSession();
4845
4911
  clearPipelineFastBrainSession();
4912
+ // P1 (CRITICAL FOR COST): the human left a non-meeting session. Clear the
4913
+ // host anchor AND hard-kill every background slot so their subprocesses
4914
+ // stop burning tokens. agentsActive() keeps the room alive on the host +
4915
+ // slots, so this is the ONLY safe place to release them — leaving a slot
4916
+ // alive here would defeat the empty-room teardown timers entirely.
4917
+ releaseAgents();
4846
4918
  // Auto-leave path for a NON-meeting session. 0.9.83: a real session just
4847
4919
  // ended → use the FAST leave (~20s), not the 3-min alone grace. Fires even
4848
4920
  // on an abrupt tab close; cancelled if a user rejoins within the grace.
@@ -5747,6 +5819,39 @@ async function main() {
5747
5819
  });
5748
5820
  }
5749
5821
  }
5822
+ else if (data.type === 'dispatch_background') {
5823
+ // P1: push an instruction into a background slot (spawning it cold-start
5824
+ // first if needed), then notify the frontend when the turn completes.
5825
+ const targetSessionId = data.targetSessionId;
5826
+ const instruction = data.instruction;
5827
+ if (!targetSessionId || !instruction) {
5828
+ await sendToFrontend({ type: 'background_session_error', slotId: targetSessionId ?? null, error: 'dispatch_background requires targetSessionId + instruction' });
5829
+ }
5830
+ else {
5831
+ const focusedId = currentLLM?.sessionId || currentResumeSessionId || null;
5832
+ if (targetSessionId === focusedId) {
5833
+ await sendToFrontend({ type: 'background_session_error', slotId: targetSessionId, error: 'Target is the focused session — use the normal path' });
5834
+ }
5835
+ else {
5836
+ const doDispatch = () => {
5837
+ const slot = slots.get(targetSessionId);
5838
+ if (!slot || !slot.emitter || !slot.sdkOptions) {
5839
+ void sendToFrontend({ type: 'background_session_error', slotId: targetSessionId, error: 'Slot not ready' });
5840
+ return;
5841
+ }
5842
+ slot.dispatchInFlight = true;
5843
+ // Reuse the cold-start emitter — pushMessage's fast path ignores a
5844
+ // fresh emitter once the subprocess exists (see spawnBackgroundSession).
5845
+ slot.llm.pushMessage(instruction, slot.sdkOptions, { onSessionId: () => { }, onCheckpoint: () => { }, eventEmitter: slot.emitter });
5846
+ void sendToFrontend({ type: 'background_dispatched', sessionId: targetSessionId, instruction });
5847
+ };
5848
+ if (slots.has(targetSessionId))
5849
+ doDispatch();
5850
+ else
5851
+ spawnBackgroundSession(targetSessionId).then(doDispatch).catch((err) => { void sendToFrontend({ type: 'background_session_error', slotId: targetSessionId, error: err instanceof Error ? err.message : String(err) }); });
5852
+ }
5853
+ }
5854
+ }
5750
5855
  else if (data.type === 'list_slots') {
5751
5856
  // Return all live slots: the focused session + every background slot.
5752
5857
  const focusedId = currentLLM?.sessionId || currentResumeSessionId || null;
@@ -5925,6 +6030,7 @@ async function main() {
5925
6030
  if (!activeRoom) {
5926
6031
  // Already idle — still ensure billing backstop is armed.
5927
6032
  livekitState.status = 'idle';
6033
+ releaseAgents(); // ensure no lingering host/slots keep the machine alive
5928
6034
  armIdleExitTimer(`destroyRoomSession(${reason}) — already idle`);
5929
6035
  return;
5930
6036
  }
@@ -5957,6 +6063,7 @@ async function main() {
5957
6063
  livekitState.status = 'idle';
5958
6064
  livekitState.error = null;
5959
6065
  livekitState.errorCode = null;
6066
+ releaseAgents(); // session torn down → clear host + kill slots so idle-exit can fire
5960
6067
  armIdleExitTimer(`destroyRoomSession(${reason})`);
5961
6068
  destroyingRoomSession = false;
5962
6069
  };
@@ -113,9 +113,9 @@ THE SUB-AGENTS:
113
113
  · NEVER use the SDK's built-in 'general-purpose' agent — it is not configured for this project and will hit write blocks. Always pick researcher, reasoner, writer, tester, or reviewer explicitly.
114
114
 
115
115
  DIVISION OF LABOR — follow this chain by default for any substantive or code task:
116
- researcher (gather facts) → planner (step plan for multi-step work) → THEN dispatch writer AND tester IN THE SAME STEP, in parallel: the writer executes the plan while the tester writes tests from the spec, blind to the implementation (TDD) → after the writer returns, RUN the tester's tests against the new code AND dispatch the reviewer on the diff → on test-FAIL or REJECT, send the writer back to fix and re-verify (at most 2 rounds) → you synthesize and speak.
117
- For a quick factual query: researcher only → you speak.
118
- NEVER skip the tester+reviewer verification after a code change — and dispatch the tester IN PARALLEL with the writer (not after), so tests are written independently of the code.
116
+ researcher (gather facts) → after the researcher returns on a substantive question, dispatch the `reasoner` GATE to judge whether the research is complete and well-sourced (GATE: PASS / NEEDS-MORE); on NEEDS-MORE send the researcher back with the named gaps and re-gate (at most 2 rounds) → planner (step plan for multi-step work) → THEN dispatch writer AND tester IN THE SAME STEP, in parallel: the writer executes the plan while the tester writes tests from the spec, blind to the implementation (TDD) → after the writer returns, RUN the tester's tests against the new code AND dispatch the reviewer on the diff → on test-FAIL or REJECT, send the writer back to fix and re-verify (at most 2 rounds) → you synthesize and speak.
117
+ For a quick factual lookup: researcher only → you speak (a trivial lookup needs no gate).
118
+ NEVER skip verification — it is symmetric across both flows: the reasoner GATE after substantive research, and the tester+reviewer after a code change. Both are the same pattern (generate → an INDEPENDENT agent verifies before you rely on it). Dispatch the tester IN PARALLEL with the writer (not after) so tests are written independently of the code.
119
119
  You may OVERRIDE this chain at any time — stop a running agent, inject, or reorder — because you can see each task's live state. The chain is the default, not a cage.
120
120
  Surfacing findings and communicating to the user is YOUR job, not a sub-agent's.
121
121
  </turn-shape>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.230",
3
+ "version": "0.9.232",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {