osborn 0.8.7 → 0.8.8

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.
@@ -150,6 +150,13 @@ export async function checkClaudeAuthStatus() {
150
150
  * Strips ALL whitespace first (like vutran1710/claudebox) to handle
151
151
  * Ink UI wrapping the URL across multiple lines.
152
152
  * Also cleans trailing "Pastecodehereifprompted" that Ink appends.
153
+ *
154
+ * IMPORTANT: strips the `redirect_uri` query parameter (which points to a
155
+ * localhost callback server on the *sprite*, not the user's machine). With
156
+ * no redirect_uri, claude.com falls back to showing the auth code in-page,
157
+ * which the user pastes back into the modal. This is the only flow that
158
+ * works for cloud sandboxes — the localhost redirect breaks both on phones
159
+ * (no listener) AND on desktops (sprite's localhost is unreachable).
153
160
  */
154
161
  function extractOAuthUrl(text) {
155
162
  // Strip ANSI codes
@@ -168,6 +175,39 @@ function extractOAuthUrl(text) {
168
175
  if (idx > 0)
169
176
  url = url.substring(0, idx);
170
177
  }
178
+ // Strip the localhost redirect_uri so claude.com shows a pasteable code
179
+ // instead of trying to redirect. URL() can't be used here because it
180
+ // re-encodes the path, so we surgically delete the redirect_uri param.
181
+ url = stripRedirectUri(url);
182
+ return url;
183
+ }
184
+ /**
185
+ * Strip the `redirect_uri` query param from an OAuth URL.
186
+ *
187
+ * Background: `claude setup-token` spawns a one-shot localhost HTTP server on
188
+ * a random port and registers it as the redirect_uri. That works fine when the
189
+ * user is on the same machine as the CLI, but on a sprite the URL points to
190
+ * the *sprite's* localhost — unreachable from the user's browser regardless
191
+ * of whether they open the auth link on their PC or their phone. With no
192
+ * redirect_uri at all, claude.ai falls back to its in-page code display
193
+ * (the same flow that `claude setup-token`'s "Paste code here if prompted"
194
+ * Ink input is built to consume), and the user can paste the code back into
195
+ * our modal — which works whether they signed in on phone or desktop.
196
+ *
197
+ * Done with regex rather than `new URL()` because the URL constructor
198
+ * normalizes the path (which can break Claude's strict redirect check)
199
+ * and re-encodes spaces/special chars in other params.
200
+ */
201
+ function stripRedirectUri(url) {
202
+ const before = url;
203
+ // Three cases: leading param (?redirect_uri=...&), middle/trailing (&redirect_uri=...),
204
+ // and only param (?redirect_uri=...). Order matters so cleanup leaves the URL well-formed.
205
+ url = url.replace(/&redirect_uri=[^&]*/g, '');
206
+ url = url.replace(/\?redirect_uri=[^&]*&/g, '?');
207
+ url = url.replace(/\?redirect_uri=[^&]*$/g, '');
208
+ if (before !== url) {
209
+ console.log('🔑 Stripped localhost redirect_uri from OAuth URL — claude.ai will show a pasteable code instead of redirecting');
210
+ }
171
211
  return url;
172
212
  }
173
213
  // ─────────────────────────────────────────
@@ -12,11 +12,7 @@ import { EventEmitter } from 'events';
12
12
  import { saveSessionMetadata, getSessionWorkspace } from './config.js';
13
13
  import { getResearchSystemPrompt, getDirectModeResearchPrompt } from './prompts.js';
14
14
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
15
- import { join, dirname } from 'node:path';
16
- import { fileURLToPath } from 'node:url';
17
- // Directory of this module — used to locate co-located prompt files (e.g., turn-shape reminder).
18
- const __claudeLlmDir = dirname(fileURLToPath(import.meta.url));
19
- const TURN_SHAPE_REMINDER_PATH = join(__claudeLlmDir, 'prompts', 'turn-shape-reminder.md');
15
+ import { join } from 'node:path';
20
16
  /**
21
17
  * Strip markdown formatting for TTS (text-to-speech)
22
18
  * Removes **bold**, ##headers, ```code```, etc. so TTS doesn't read them literally
@@ -621,7 +617,7 @@ export class ClaudeLLM extends llm.LLM {
621
617
  callbacks.eventEmitter.emit('assistant_text', { text: block.text });
622
618
  const ttsChunk = stripMarkdownForTTS(block.text);
623
619
  if (ttsChunk.trim()) {
624
- console.log(`🔊 TTS say (${ttsChunk.length} chars): "${ttsChunk}"`);
620
+ console.log(`🔊 TTS say (${ttsChunk.length} chars): "${ttsChunk.substring(0, 60)}..."`);
625
621
  callbacks.eventEmitter.emit('tts_say', { text: ttsChunk });
626
622
  }
627
623
  }
@@ -736,7 +732,7 @@ class ClaudeLLMStream extends llm.LLMStream {
736
732
  });
737
733
  return;
738
734
  }
739
- console.log(`🎤 User (${userText.length} chars): "${userText}"`);
735
+ console.log(`🎤 User: "${userText.substring(0, 100)}${userText.length > 100 ? '...' : ''}"`);
740
736
  // Build Claude Agent SDK options
741
737
  const resumeSessionId = this.#opts.resumeSessionId;
742
738
  const continueSession = this.#opts.continueSession;
@@ -856,35 +852,6 @@ class ClaudeLLMStream extends llm.LLMStream {
856
852
  this.#eventEmitter.emit('tool_result', { name: toolName, input: toolInput, response: toolResponse });
857
853
  return {};
858
854
  }]
859
- }],
860
- // Per-turn behavioral re-anchor. Fires on EVERY user message that reaches Claude
861
- // (initial requests, follow-ups, mid-flight steering, resumed-session messages).
862
- // Reads the reminder text from disk every call, so it's hot-editable just like the
863
- // main prompt — edit agent/src/prompts/turn-shape-reminder.md, reconnect, next message
864
- // sees the new reminder. The SDK injects `additionalContext` alongside the user's actual
865
- // message so the model sees both the literal user input AND the reminder, weighing them
866
- // together. This is what fights JSONL-history-overrides-system-prompt drift on resumed
867
- // sessions: the conductor pattern gets re-asserted on every turn instead of being
868
- // anchored only at session-init time.
869
- UserPromptSubmit: [{
870
- matcher: '.*',
871
- hooks: [async (input) => {
872
- try {
873
- const reminder = readFileSync(TURN_SHAPE_REMINDER_PATH, 'utf-8');
874
- const promptPreview = String(input?.prompt || '').substring(0, 60).replace(/\n/g, ' ');
875
- console.log(`📌 UserPromptSubmit: injected turn-shape reminder (${reminder.length} chars) for prompt="${promptPreview}..."`);
876
- return {
877
- hookSpecificOutput: {
878
- hookEventName: 'UserPromptSubmit',
879
- additionalContext: reminder,
880
- },
881
- };
882
- }
883
- catch (err) {
884
- console.error('⚠️ UserPromptSubmit: failed to load turn-shape-reminder.md:', err instanceof Error ? err.message : err);
885
- return { hookSpecificOutput: { hookEventName: 'UserPromptSubmit' } };
886
- }
887
- }]
888
855
  }]
889
856
  },
890
857
  // Named sub-agents — Haiku overseer delegates to these specialists.
@@ -1109,12 +1076,12 @@ class ClaudeLLMStream extends llm.LLMStream {
1109
1076
  if (this.#opts.skipTTSQueue) {
1110
1077
  // Direct mode: emit event for session.say() — bypasses LiveKit's
1111
1078
  // BufferedTokenStream which causes stuck/delayed/out-of-order audio
1112
- console.log(`🔊 TTS say (${ttsChunk.length} chars): "${ttsChunk}"`);
1079
+ console.log(`🔊 TTS say (${ttsChunk.length} chars): "${ttsChunk.substring(0, 60)}..."`);
1113
1080
  this.#eventEmitter.emit('tts_say', { text: ttsChunk });
1114
1081
  }
1115
1082
  else {
1116
1083
  // Realtime mode: use LLM stream queue (framework handles TTS)
1117
- console.log(`🔊 TTS stream (${ttsChunk.length} chars): "${ttsChunk}"`);
1084
+ console.log(`🔊 TTS stream (${ttsChunk.length} chars): "${ttsChunk.substring(0, 60)}..."`);
1118
1085
  this.queue.put({
1119
1086
  id: requestId,
1120
1087
  delta: { role: 'assistant', content: ttsChunk },
@@ -1134,11 +1101,11 @@ class ClaudeLLMStream extends llm.LLMStream {
1134
1101
  const ttsText = stripMarkdownForTTS(rawResult);
1135
1102
  if (ttsText.trim()) {
1136
1103
  if (this.#opts.skipTTSQueue) {
1137
- console.log(`🔊 TTS say result (${ttsText.length} chars): "${ttsText}"`);
1104
+ console.log(`🔊 TTS say result (${ttsText.length} chars): "${ttsText.substring(0, 60)}..."`);
1138
1105
  this.#eventEmitter.emit('tts_say', { text: ttsText });
1139
1106
  }
1140
1107
  else {
1141
- console.log(`🔊 TTS result (${ttsText.length} chars): "${ttsText}"`);
1108
+ console.log(`🔊 TTS result (${ttsText.length} chars): "${ttsText.substring(0, 60)}..."`);
1142
1109
  this.queue.put({
1143
1110
  id: requestId,
1144
1111
  delta: { role: 'assistant', content: ttsText },
package/dist/codex-llm.js CHANGED
@@ -97,7 +97,7 @@ class CodexLLMStream extends llm.LLMStream {
97
97
  });
98
98
  return;
99
99
  }
100
- console.log(`🎤 User (${userText.length} chars): "${userText}"`);
100
+ console.log(`🎤 User: "${userText.substring(0, 100)}${userText.length > 100 ? '...' : ''}"`);
101
101
  // Create or reuse thread
102
102
  if (!this.#thread) {
103
103
  console.log('🆕 Starting new Codex thread');
package/dist/index.js CHANGED
@@ -227,47 +227,6 @@ function startApiServer(workingDir, port) {
227
227
  setTimeout(() => process.exit(0), 150);
228
228
  return;
229
229
  }
230
- // GET /events — Server-Sent Events heartbeat for cloud-sandbox keepalive.
231
- //
232
- // This endpoint is the single thing preventing Sprites' CRIU-based
233
- // hibernation from freezing osborn's Node.js event loop and dropping our
234
- // LiveKit WebSocket mid-session. Short HTTP pings don't work: Sprites'
235
- // warm state serves /health responses from a process snapshot without
236
- // actually resuming the event loop, so background timers (including
237
- // LiveKit heartbeats) stop firing after a few seconds. That causes the
238
- // LiveKit server to drop osborn's participant, delete the room, and
239
- // leave any future user joins stuck at "Connecting..." forever.
240
- //
241
- // An OPEN long-lived TCP connection keeps the sprite in 'running' state.
242
- // The frontend opens this endpoint on chat page mount and holds it open
243
- // for the entire voice session. While open, osborn's event loop ticks
244
- // continuously, LiveKit heartbeats fire, and the room stays alive.
245
- //
246
- // For local (non-cloud) dev, this endpoint is harmless — it just idles
247
- // on a client that may never connect. Zero cost when unused.
248
- if (req.method === 'GET' && url.pathname === '/events') {
249
- res.writeHead(200, {
250
- 'Content-Type': 'text/event-stream',
251
- 'Cache-Control': 'no-cache',
252
- 'Connection': 'keep-alive',
253
- // Disable proxy buffering (nginx-style) so each ping is flushed
254
- // through Sprites' reverse proxy immediately rather than batched.
255
- 'X-Accel-Buffering': 'no',
256
- });
257
- res.write(`: sprite-keepalive connected at ${new Date().toISOString()}\n\n`);
258
- const heartbeat = setInterval(() => {
259
- try {
260
- res.write(`: ping ${Date.now()}\n\n`);
261
- }
262
- catch { }
263
- }, 10_000);
264
- req.on('close', () => {
265
- clearInterval(heartbeat);
266
- console.log('[events] SSE client disconnected');
267
- });
268
- console.log('[events] SSE client connected');
269
- return;
270
- }
271
230
  res.writeHead(404, { 'Content-Type': 'application/json' });
272
231
  res.end(JSON.stringify({ error: 'Not found' }));
273
232
  });
@@ -447,43 +406,6 @@ async function main() {
447
406
  let currentSession = null;
448
407
  let currentAgent = null; // For updateChatCtx() context injection
449
408
  let currentLLM = null;
450
- /**
451
- * Hard-kill the in-flight Claude SDK query AND the persistent subprocess.
452
- *
453
- * Why this exists: the persistent ClaudeLLM session is deliberately kept alive
454
- * across user messages to avoid JSONL replay (see CLAUDE.md "Persistent Session
455
- * Architecture"). When the participant disconnects, simply nulling `currentLLM`
456
- * drops the JS reference but does NOT kill the underlying Claude Code subprocess
457
- * — the SDK keeps draining the MessageChannel, running tools, and pushing TTS
458
- * calls into a now-null voice session. Visible in logs as repeated:
459
- * "⚠️ tts_say fired but currentSession is null — text dropped"
460
- * followed by orphaned `🔧 Claude: Bash` calls and `📍 Checkpoint captured` lines
461
- * that nobody is listening to. Wasted compute, wasted tokens, possible side effects.
462
- *
463
- * The right cleanup is `abortQuery()` (on ClaudeLLM directly) or `abortAgent()`
464
- * (on PipelineDirectLLM, which wraps ClaudeLLM). They both call into
465
- * `closeSession()` → kills the subprocess. We duck-type to handle both class
466
- * shapes since `currentLLM` can hold either, depending on voice mode.
467
- */
468
- function killCurrentLLM(reason) {
469
- if (!currentLLM)
470
- return;
471
- try {
472
- const llm = currentLLM;
473
- if (typeof llm.abortQuery === 'function') {
474
- llm.abortQuery();
475
- }
476
- else if (typeof llm.abortAgent === 'function') {
477
- llm.abortAgent();
478
- }
479
- else {
480
- console.warn(`⚠️ killCurrentLLM(${reason}): no abort method on currentLLM`);
481
- }
482
- }
483
- catch (err) {
484
- console.error(`❌ killCurrentLLM(${reason}) failed:`, err instanceof Error ? err.message : err);
485
- }
486
- }
487
409
  let localParticipant = null;
488
410
  let agentState = 'initializing';
489
411
  // Session-level always-allow list: paths the user has approved for this session without prompting
@@ -557,7 +479,7 @@ async function main() {
557
479
  // fullText is what was being spoken when interrupted (passed from tts_say handler).
558
480
  // No word-level cutoff for say() — only generateReply pipeline has that — but Claude
559
481
  // knows its own output from JSONL, so the full block is enough context.
560
- console.log(`🔇 Speech interrupted. Was speaking (${fullText.length} chars): "${fullText}"`);
482
+ console.log(`🔇 Speech interrupted. Was speaking: "${fullText.substring(0, 80)}..."`);
561
483
  // Read last 10 assistant messages from JSONL (Claude's full untruncated output).
562
484
  // SessionMessage.text is pre-joined from all text content blocks.
563
485
  let recentMessages = '';
@@ -933,7 +855,7 @@ async function main() {
933
855
  });
934
856
  // Wire up Claude text output - RAW text goes to frontend for chat bubbles
935
857
  directLLM.events.on('assistant_text', (data) => {
936
- console.log(`💬 Claude text (${data.text?.length || 0} chars): ${data.text || ''}`);
858
+ console.log(`💬 Claude text: ${data.text?.substring(0, 60)}...`);
937
859
  sendToFrontend({
938
860
  type: 'claude_output',
939
861
  text: data.text,
@@ -943,7 +865,7 @@ async function main() {
943
865
  });
944
866
  // Wire up Claude final result - RAW result goes to frontend
945
867
  directLLM.events.on('assistant_result', (data) => {
946
- console.log(`📋 Claude result (${data.text?.length || 0} chars): ${data.text || ''}`);
868
+ console.log(`📋 Claude result: ${data.text?.substring(0, 60)}...`);
947
869
  sendToFrontend({
948
870
  type: 'claude_output',
949
871
  text: data.text,
@@ -1061,7 +983,7 @@ async function main() {
1061
983
  directLLM.events.on('tts_say', (data) => {
1062
984
  // Guard: session must be alive — TTS errors can kill the session while background query runs
1063
985
  if (!currentSession) {
1064
- console.warn(`⚠️ tts_say fired but currentSession is null — text dropped (${data.text?.length || 0} chars): "${data.text || ''}"`);
986
+ console.warn(`⚠️ tts_say fired but currentSession is null — text dropped: "${data.text?.substring(0, 60)}"`);
1065
987
  return;
1066
988
  }
1067
989
  if (!data.text?.trim()) {
@@ -1069,7 +991,7 @@ async function main() {
1069
991
  return;
1070
992
  }
1071
993
  const sayId = Date.now(); // simple ID to correlate start/end logs
1072
- console.log(`🗣️ [${sayId}] session.say START (${data.text.length} chars): "${data.text}"`);
994
+ console.log(`🗣️ [${sayId}] session.say START (${data.text.length} chars): "${data.text.substring(0, 60)}..."`);
1073
995
  try {
1074
996
  const handle = currentSession.say(data.text);
1075
997
  if (handle && typeof handle.addDoneCallback === 'function') {
@@ -1197,7 +1119,7 @@ async function main() {
1197
1119
  }
1198
1120
  });
1199
1121
  realtimeClaudeHandler.events.on('assistant_result', (data) => {
1200
- console.log(`📋 Claude result (${data.text?.length || 0} chars): ${data.text || ''}`);
1122
+ console.log(`📋 Claude result: ${data.text?.substring(0, 60)}...`);
1201
1123
  sendToFrontend({
1202
1124
  type: 'claude_output',
1203
1125
  text: data.text,
@@ -1689,9 +1611,6 @@ async function main() {
1689
1611
  lastCompletedResearch = null;
1690
1612
  currentSession = null;
1691
1613
  currentAgent = null;
1692
- // Same disconnect-leak fix as the other two cleanup sites — kill the Claude SDK
1693
- // subprocess BEFORE dropping the reference. See killCurrentLLM() for full context.
1694
- killCurrentLLM('disconnected_cleanup');
1695
1614
  currentLLM = null;
1696
1615
  clearFastBrainSession();
1697
1616
  clearPipelineFastBrainSession();
@@ -1734,9 +1653,6 @@ async function main() {
1734
1653
  catch { }
1735
1654
  currentSession = null;
1736
1655
  currentAgent = null;
1737
- // Same disconnect-leak fix — kill the previous user's Claude subprocess
1738
- // before binding currentLLM to the new user's session below.
1739
- killCurrentLLM('previous_session_cleanup');
1740
1656
  currentLLM = null;
1741
1657
  }
1742
1658
  // Extract voice architecture, provider, and sessionId from participant metadata (sent by frontend)
@@ -1905,7 +1821,7 @@ async function main() {
1905
1821
  // (Gemini v1.0.51: userInput in generateReply creates a user conversation item)
1906
1822
  if (normalized.startsWith('[SCRIPT]') || normalized.startsWith('[PROACTIVE]') || normalized.startsWith('[NOTIFICATION]'))
1907
1823
  return;
1908
- console.log(`📝 User (${source}, ${transcript.length} chars): "${transcript}"`);
1824
+ console.log(`📝 User (${source}): "${transcript.substring(0, 60)}..."`);
1909
1825
  sendToFrontend({ type: 'user_transcript', text: transcript });
1910
1826
  lastSentUserTranscript = normalized;
1911
1827
  }
@@ -1915,7 +1831,7 @@ async function main() {
1915
1831
  const normalized = text.trim().replace(/\s+/g, ' ');
1916
1832
  if (normalized === lastSentAgentTranscript)
1917
1833
  return;
1918
- console.log(`💬 Agent (${source}, ${text.length} chars): "${text}"`);
1834
+ console.log(`💬 Agent (${source}): "${text.substring(0, 60)}..."`);
1919
1835
  sendToFrontend({ type: 'assistant_response', text });
1920
1836
  lastSentAgentTranscript = normalized;
1921
1837
  }
@@ -2373,9 +2289,6 @@ async function main() {
2373
2289
  })();
2374
2290
  }
2375
2291
  currentAgent = null;
2376
- // Kill the Claude SDK subprocess BEFORE dropping the reference, otherwise the
2377
- // persistent session keeps running tools and pushing TTS into a dead session.
2378
- killCurrentLLM('participant_disconnected');
2379
2292
  currentLLM = null;
2380
2293
  clearFastBrainSession();
2381
2294
  clearPipelineFastBrainSession();
@@ -2421,10 +2334,10 @@ async function main() {
2421
2334
  fullContent += `\n\n[Image attached: ${f.name}]`;
2422
2335
  }
2423
2336
  }
2424
- console.log(`📝 Text + ${files.length} file(s) (${fullContent.length} chars): "${fullContent}"`);
2337
+ console.log(`📝 Text + ${files.length} file(s): "${fullContent.substring(0, 100)}"`);
2425
2338
  }
2426
2339
  else {
2427
- console.log(`📝 Text (${fullContent.length} chars): "${fullContent}"`);
2340
+ console.log(`📝 Text: "${fullContent.substring(0, 100)}"`);
2428
2341
  }
2429
2342
  // Skip interrupt for Gemini — disrupts state machine (hangs in speaking state)
2430
2343
  if (currentProvider !== 'gemini') {
@@ -82,7 +82,7 @@ export class PipelineDirectLLM extends llm.LLM {
82
82
  break;
83
83
  }
84
84
  }
85
- console.log(`📥 [pipeline] chat() call #${callN} (${userText.length} chars): "${userText}"`);
85
+ console.log(`📥 [pipeline] chat() call #${callN}: "${userText.substring(0, 60)}"`);
86
86
  // Check for pending interruption context — enrich user message if interrupted
87
87
  const interruptCtx = this.#opts.getAndConsumeInterruptionContext?.();
88
88
  if (interruptCtx && userText.trim()) {