osborn 0.8.7 → 0.8.9

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
@@ -447,43 +447,6 @@ async function main() {
447
447
  let currentSession = null;
448
448
  let currentAgent = null; // For updateChatCtx() context injection
449
449
  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
450
  let localParticipant = null;
488
451
  let agentState = 'initializing';
489
452
  // Session-level always-allow list: paths the user has approved for this session without prompting
@@ -557,7 +520,7 @@ async function main() {
557
520
  // fullText is what was being spoken when interrupted (passed from tts_say handler).
558
521
  // No word-level cutoff for say() — only generateReply pipeline has that — but Claude
559
522
  // knows its own output from JSONL, so the full block is enough context.
560
- console.log(`🔇 Speech interrupted. Was speaking (${fullText.length} chars): "${fullText}"`);
523
+ console.log(`🔇 Speech interrupted. Was speaking: "${fullText.substring(0, 80)}..."`);
561
524
  // Read last 10 assistant messages from JSONL (Claude's full untruncated output).
562
525
  // SessionMessage.text is pre-joined from all text content blocks.
563
526
  let recentMessages = '';
@@ -933,7 +896,7 @@ async function main() {
933
896
  });
934
897
  // Wire up Claude text output - RAW text goes to frontend for chat bubbles
935
898
  directLLM.events.on('assistant_text', (data) => {
936
- console.log(`💬 Claude text (${data.text?.length || 0} chars): ${data.text || ''}`);
899
+ console.log(`💬 Claude text: ${data.text?.substring(0, 60)}...`);
937
900
  sendToFrontend({
938
901
  type: 'claude_output',
939
902
  text: data.text,
@@ -943,7 +906,7 @@ async function main() {
943
906
  });
944
907
  // Wire up Claude final result - RAW result goes to frontend
945
908
  directLLM.events.on('assistant_result', (data) => {
946
- console.log(`📋 Claude result (${data.text?.length || 0} chars): ${data.text || ''}`);
909
+ console.log(`📋 Claude result: ${data.text?.substring(0, 60)}...`);
947
910
  sendToFrontend({
948
911
  type: 'claude_output',
949
912
  text: data.text,
@@ -1061,7 +1024,7 @@ async function main() {
1061
1024
  directLLM.events.on('tts_say', (data) => {
1062
1025
  // Guard: session must be alive — TTS errors can kill the session while background query runs
1063
1026
  if (!currentSession) {
1064
- console.warn(`⚠️ tts_say fired but currentSession is null — text dropped (${data.text?.length || 0} chars): "${data.text || ''}"`);
1027
+ console.warn(`⚠️ tts_say fired but currentSession is null — text dropped: "${data.text?.substring(0, 60)}"`);
1065
1028
  return;
1066
1029
  }
1067
1030
  if (!data.text?.trim()) {
@@ -1069,7 +1032,7 @@ async function main() {
1069
1032
  return;
1070
1033
  }
1071
1034
  const sayId = Date.now(); // simple ID to correlate start/end logs
1072
- console.log(`🗣️ [${sayId}] session.say START (${data.text.length} chars): "${data.text}"`);
1035
+ console.log(`🗣️ [${sayId}] session.say START (${data.text.length} chars): "${data.text.substring(0, 60)}..."`);
1073
1036
  try {
1074
1037
  const handle = currentSession.say(data.text);
1075
1038
  if (handle && typeof handle.addDoneCallback === 'function') {
@@ -1197,7 +1160,7 @@ async function main() {
1197
1160
  }
1198
1161
  });
1199
1162
  realtimeClaudeHandler.events.on('assistant_result', (data) => {
1200
- console.log(`📋 Claude result (${data.text?.length || 0} chars): ${data.text || ''}`);
1163
+ console.log(`📋 Claude result: ${data.text?.substring(0, 60)}...`);
1201
1164
  sendToFrontend({
1202
1165
  type: 'claude_output',
1203
1166
  text: data.text,
@@ -1689,9 +1652,6 @@ async function main() {
1689
1652
  lastCompletedResearch = null;
1690
1653
  currentSession = null;
1691
1654
  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
1655
  currentLLM = null;
1696
1656
  clearFastBrainSession();
1697
1657
  clearPipelineFastBrainSession();
@@ -1734,9 +1694,6 @@ async function main() {
1734
1694
  catch { }
1735
1695
  currentSession = null;
1736
1696
  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
1697
  currentLLM = null;
1741
1698
  }
1742
1699
  // Extract voice architecture, provider, and sessionId from participant metadata (sent by frontend)
@@ -1905,7 +1862,7 @@ async function main() {
1905
1862
  // (Gemini v1.0.51: userInput in generateReply creates a user conversation item)
1906
1863
  if (normalized.startsWith('[SCRIPT]') || normalized.startsWith('[PROACTIVE]') || normalized.startsWith('[NOTIFICATION]'))
1907
1864
  return;
1908
- console.log(`📝 User (${source}, ${transcript.length} chars): "${transcript}"`);
1865
+ console.log(`📝 User (${source}): "${transcript.substring(0, 60)}..."`);
1909
1866
  sendToFrontend({ type: 'user_transcript', text: transcript });
1910
1867
  lastSentUserTranscript = normalized;
1911
1868
  }
@@ -1915,7 +1872,7 @@ async function main() {
1915
1872
  const normalized = text.trim().replace(/\s+/g, ' ');
1916
1873
  if (normalized === lastSentAgentTranscript)
1917
1874
  return;
1918
- console.log(`💬 Agent (${source}, ${text.length} chars): "${text}"`);
1875
+ console.log(`💬 Agent (${source}): "${text.substring(0, 60)}..."`);
1919
1876
  sendToFrontend({ type: 'assistant_response', text });
1920
1877
  lastSentAgentTranscript = normalized;
1921
1878
  }
@@ -2373,9 +2330,6 @@ async function main() {
2373
2330
  })();
2374
2331
  }
2375
2332
  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
2333
  currentLLM = null;
2380
2334
  clearFastBrainSession();
2381
2335
  clearPipelineFastBrainSession();
@@ -2421,10 +2375,10 @@ async function main() {
2421
2375
  fullContent += `\n\n[Image attached: ${f.name}]`;
2422
2376
  }
2423
2377
  }
2424
- console.log(`📝 Text + ${files.length} file(s) (${fullContent.length} chars): "${fullContent}"`);
2378
+ console.log(`📝 Text + ${files.length} file(s): "${fullContent.substring(0, 100)}"`);
2425
2379
  }
2426
2380
  else {
2427
- console.log(`📝 Text (${fullContent.length} chars): "${fullContent}"`);
2381
+ console.log(`📝 Text: "${fullContent.substring(0, 100)}"`);
2428
2382
  }
2429
2383
  // Skip interrupt for Gemini — disrupts state machine (hangs in speaking state)
2430
2384
  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()) {