osborn 0.8.5 → 0.8.7

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,59 @@
1
+ # Osborn Sandbox — Fly.io Machines (per-user)
2
+ # Installs osborn as npm package (not from source) for lightweight per-user machines.
3
+ # Build: docker build -f Dockerfile.sandbox -t registry.fly.io/osborn-sandbox/agent:latest .
4
+ # Push: fly auth docker && docker push registry.fly.io/osborn-sandbox/agent:latest
5
+
6
+ FROM node:22-slim
7
+
8
+ # Runtime deps for osborn + claude-code
9
+ RUN apt-get update -qq && \
10
+ apt-get install --no-install-recommends -y \
11
+ ca-certificates \
12
+ curl \
13
+ git \
14
+ python-is-python3 && \
15
+ rm -rf /var/lib/apt/lists/*
16
+
17
+ # Install osborn + claude-code globally
18
+ RUN npm install -g osborn@latest @anthropic-ai/claude-code
19
+
20
+ # Persistent workspace + claude config dirs
21
+ RUN mkdir -p /workspace /root/.claude
22
+
23
+ ENV OSBORN_CWD=/workspace
24
+ ENV OSBORN_API_PORT=8741
25
+ ENV NODE_ENV=production
26
+
27
+ WORKDIR /workspace
28
+
29
+ EXPOSE 8741
30
+
31
+ # Entrypoint: credential persistence + onboarding suppression + start
32
+ COPY <<'ENTRYPOINT' /entrypoint.sh
33
+ #!/bin/sh
34
+ set -e
35
+
36
+ # Claude credential persistence (volume at /workspace)
37
+ mkdir -p /workspace/.claude
38
+ rm -rf /root/.claude
39
+ ln -sf /workspace/.claude /root/.claude
40
+
41
+ # Suppress Claude Code interactive onboarding prompts
42
+ ONBOARDING_JSON='{"numStartups":10,"installMethod":"npm","autoUpdates":false,"hasCompletedOnboarding":true,"hasTrustDialogAccepted":true,"hasTrustDialogHooksAccepted":true,"hasCompletedProjectOnboarding":true,"hasAcknowledgedCostThreshold":true,"effortCalloutV2Dismissed":true,"theme":"dark","projects":{"/workspace":{"hasTrustDialogAccepted":true,"hasTrustDialogHooksAccepted":true,"hasCompletedProjectOnboarding":true}}}'
43
+ echo "$ONBOARDING_JSON" > /root/.claude.json
44
+ mkdir -p /workspace/.claude
45
+ echo "$ONBOARDING_JSON" > /workspace/.claude/.config.json
46
+ echo "$ONBOARDING_JSON" > /workspace/.claude/claude.json
47
+
48
+ # Restore OAuth token if persisted on volume
49
+ if [ -f /workspace/.claude/.oauth-token ]; then
50
+ export CLAUDE_CODE_OAUTH_TOKEN="$(cat /workspace/.claude/.oauth-token)"
51
+ echo "[sandbox] Restored CLAUDE_CODE_OAUTH_TOKEN from volume"
52
+ fi
53
+
54
+ exec osborn
55
+ ENTRYPOINT
56
+
57
+ RUN chmod +x /entrypoint.sh
58
+
59
+ CMD ["/entrypoint.sh"]
@@ -12,7 +12,11 @@ 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 } from 'node:path';
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');
16
20
  /**
17
21
  * Strip markdown formatting for TTS (text-to-speech)
18
22
  * Removes **bold**, ##headers, ```code```, etc. so TTS doesn't read them literally
@@ -617,7 +621,7 @@ export class ClaudeLLM extends llm.LLM {
617
621
  callbacks.eventEmitter.emit('assistant_text', { text: block.text });
618
622
  const ttsChunk = stripMarkdownForTTS(block.text);
619
623
  if (ttsChunk.trim()) {
620
- console.log(`🔊 TTS say (${ttsChunk.length} chars): "${ttsChunk.substring(0, 60)}..."`);
624
+ console.log(`🔊 TTS say (${ttsChunk.length} chars): "${ttsChunk}"`);
621
625
  callbacks.eventEmitter.emit('tts_say', { text: ttsChunk });
622
626
  }
623
627
  }
@@ -732,7 +736,7 @@ class ClaudeLLMStream extends llm.LLMStream {
732
736
  });
733
737
  return;
734
738
  }
735
- console.log(`🎤 User: "${userText.substring(0, 100)}${userText.length > 100 ? '...' : ''}"`);
739
+ console.log(`🎤 User (${userText.length} chars): "${userText}"`);
736
740
  // Build Claude Agent SDK options
737
741
  const resumeSessionId = this.#opts.resumeSessionId;
738
742
  const continueSession = this.#opts.continueSession;
@@ -852,6 +856,35 @@ class ClaudeLLMStream extends llm.LLMStream {
852
856
  this.#eventEmitter.emit('tool_result', { name: toolName, input: toolInput, response: toolResponse });
853
857
  return {};
854
858
  }]
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
+ }]
855
888
  }]
856
889
  },
857
890
  // Named sub-agents — Haiku overseer delegates to these specialists.
@@ -1076,12 +1109,12 @@ class ClaudeLLMStream extends llm.LLMStream {
1076
1109
  if (this.#opts.skipTTSQueue) {
1077
1110
  // Direct mode: emit event for session.say() — bypasses LiveKit's
1078
1111
  // BufferedTokenStream which causes stuck/delayed/out-of-order audio
1079
- console.log(`🔊 TTS say (${ttsChunk.length} chars): "${ttsChunk.substring(0, 60)}..."`);
1112
+ console.log(`🔊 TTS say (${ttsChunk.length} chars): "${ttsChunk}"`);
1080
1113
  this.#eventEmitter.emit('tts_say', { text: ttsChunk });
1081
1114
  }
1082
1115
  else {
1083
1116
  // Realtime mode: use LLM stream queue (framework handles TTS)
1084
- console.log(`🔊 TTS stream (${ttsChunk.length} chars): "${ttsChunk.substring(0, 60)}..."`);
1117
+ console.log(`🔊 TTS stream (${ttsChunk.length} chars): "${ttsChunk}"`);
1085
1118
  this.queue.put({
1086
1119
  id: requestId,
1087
1120
  delta: { role: 'assistant', content: ttsChunk },
@@ -1101,11 +1134,11 @@ class ClaudeLLMStream extends llm.LLMStream {
1101
1134
  const ttsText = stripMarkdownForTTS(rawResult);
1102
1135
  if (ttsText.trim()) {
1103
1136
  if (this.#opts.skipTTSQueue) {
1104
- console.log(`🔊 TTS say result (${ttsText.length} chars): "${ttsText.substring(0, 60)}..."`);
1137
+ console.log(`🔊 TTS say result (${ttsText.length} chars): "${ttsText}"`);
1105
1138
  this.#eventEmitter.emit('tts_say', { text: ttsText });
1106
1139
  }
1107
1140
  else {
1108
- console.log(`🔊 TTS result (${ttsText.length} chars): "${ttsText.substring(0, 60)}..."`);
1141
+ console.log(`🔊 TTS result (${ttsText.length} chars): "${ttsText}"`);
1109
1142
  this.queue.put({
1110
1143
  id: requestId,
1111
1144
  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.substring(0, 100)}${userText.length > 100 ? '...' : ''}"`);
100
+ console.log(`🎤 User (${userText.length} chars): "${userText}"`);
101
101
  // Create or reuse thread
102
102
  if (!this.#thread) {
103
103
  console.log('🆕 Starting new Codex thread');
package/dist/config.js CHANGED
@@ -37,8 +37,13 @@ export const MCP_CATALOG = [
37
37
  },
38
38
  ];
39
39
  // Default config template
40
+ // Note: workingDirectory is intentionally OMITTED here. Baking process.cwd() into the
41
+ // default config at module-load time freezes whatever directory osborn happened to be
42
+ // invoked from on first boot — which on cloud sandboxes can be the npm install dir
43
+ // (`/usr/local/nvm/.../osborn`) and gets persisted to ~/.osborn/config.yaml forever.
44
+ // Leaving it undefined lets the runtime self-heal in index.ts resolve it on every boot
45
+ // from OSBORN_CWD → process.cwd() at the actual time the agent starts.
40
46
  const DEFAULT_CONFIG = {
41
- workingDirectory: process.cwd(),
42
47
  defaultProvider: 'gemini',
43
48
  defaultCodingAgent: 'claude',
44
49
  // Voice mode: 'direct' (Claude Agent SDK) or 'realtime' (OpenAI/Gemini native)
@@ -504,36 +509,83 @@ export function sessionExists(sessionId, projectPath) {
504
509
  return existsSync(sessionFile);
505
510
  }
506
511
  /**
507
- * Reverse a project slug back to a path (best-effort replace leading dash, then dashes→slashes).
508
- * "-Users-foo-bar" → "/Users/foo/bar"
512
+ * Reverse a project slug back to a path — LAST-RESORT fallback only.
513
+ *
514
+ * Claude's slug encoding (`/` → `-`, `.` → `-`) is LOSSY: you can't tell from a
515
+ * slug whether a given `-` was originally `/`, `.`, or a literal `-` inside a
516
+ * directory name like `pensive-bohr`. So this function cannot reliably
517
+ * round-trip an arbitrary path.
518
+ *
519
+ * Strategy: produce the naive guess (with a small `--` → `/.` improvement for
520
+ * dot-directories like `.claude`), then VALIDATE it with `existsSync`. If the
521
+ * guess doesn't exist, return empty string — that way the caller knows the
522
+ * reverse failed and can fall back cleanly instead of passing a broken path
523
+ * to `child_process.spawn` and crashing with ENOENT.
524
+ *
525
+ * The primary source of cwd is `extractCwd()` which reads the actual cwd from
526
+ * the JSONL file. This function is only reached when that fails.
509
527
  */
510
528
  function slugToPath(slug) {
511
- return slug.replace(/^-/, '/').replace(/-/g, '/');
529
+ // Naive reverse: leading `-` → `/`, `--` → `/.`, remaining `-` → `/`.
530
+ // The `--` → `/.` pass handles dot-prefixed directories like `.claude`.
531
+ const guess = slug
532
+ .replace(/^-/, '/')
533
+ .replace(/--/g, '/.')
534
+ .replace(/-/g, '/');
535
+ // Validate — lossy encoding means we cannot trust the guess.
536
+ return existsSync(guess) ? guess : '';
512
537
  }
513
538
  const UUID_JSONL_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/i;
514
539
  /**
515
- * Extract cwd from first user message in a JSONL file.
516
- * Reuses the existing readline-based parsing pattern.
540
+ * Extract cwd from the first JSONL entry that carries a `cwd` field.
541
+ *
542
+ * Previously: read only the first 8KB and only accepted `type === 'user'`. That
543
+ * broke for sessions whose first JSONL entry was larger than 8KB — e.g. a
544
+ * `queue-operation` containing pasted email/page text. readline never emits the
545
+ * `line` event for an incomplete final chunk, so the scan finds nothing,
546
+ * `listAllClaudeSessions` falls through to the lossy `slugToPath` reverse, and
547
+ * the mangled path ends up as a `cwd` passed to `child_process.spawn`, producing
548
+ * the misleading "Claude Code executable not found" error (see MEMORY bug #11).
549
+ *
550
+ * Now: stream line-by-line with no byte cap, short-circuit on the first entry
551
+ * with a `cwd` field regardless of `type` (every `user` / `attachment` /
552
+ * `assistant` / `system` entry in a Claude JSONL session carries `cwd`, so the
553
+ * scan finishes in the first few KB of any normal session).
517
554
  */
518
555
  async function extractCwd(filePath) {
519
556
  return new Promise((resolve) => {
520
- const fileStream = createReadStream(filePath, { end: 8192 }); // first 8KB
557
+ const fileStream = createReadStream(filePath);
521
558
  const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
559
+ let resolved = false;
560
+ const done = (value) => {
561
+ if (resolved)
562
+ return;
563
+ resolved = true;
564
+ try {
565
+ rl.close();
566
+ }
567
+ catch { }
568
+ try {
569
+ fileStream.destroy();
570
+ }
571
+ catch { }
572
+ resolve(value);
573
+ };
522
574
  rl.on('line', (line) => {
575
+ if (resolved)
576
+ return;
523
577
  if (!line.trim())
524
578
  return;
525
579
  try {
526
580
  const obj = JSON.parse(line);
527
- if (obj.type === 'user' && obj.cwd) {
528
- rl.close();
529
- fileStream.destroy();
530
- resolve(obj.cwd);
581
+ if (typeof obj?.cwd === 'string' && obj.cwd.length > 0) {
582
+ done(obj.cwd);
531
583
  }
532
584
  }
533
585
  catch { }
534
586
  });
535
- rl.on('close', () => resolve(''));
536
- rl.on('error', () => resolve(''));
587
+ rl.on('close', () => done(''));
588
+ rl.on('error', () => done(''));
537
589
  });
538
590
  }
539
591
  /**
package/dist/index.js CHANGED
@@ -227,6 +227,47 @@ 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
+ }
230
271
  res.writeHead(404, { 'Content-Type': 'application/json' });
231
272
  res.end(JSON.stringify({ error: 'Not found' }));
232
273
  });
@@ -322,13 +363,33 @@ async function main() {
322
363
  // Always the Osborn agent install directory (where this process started).
323
364
  // This ensures .osborn/sessions/ doesn't scatter across random directories.
324
365
  const sessionBaseDir = process.cwd(); // Always the Osborn install dir
325
- const defaultWorkingDir = process.env.OSBORN_CWD || config.workingDirectory || process.cwd();
366
+ // Self-healing fallback: blindly trusting OSBORN_CWD without checking that the directory
367
+ // exists has bitten us in cloud sandboxes where the env var was set to a path that didn't
368
+ // exist (e.g. `/root/workspace` on a daytona/* user). The Claude SDK then fails its spawn
369
+ // call with ENOENT and reports the misleading "Claude Code executable not found" error.
370
+ // Walk the candidate list in priority order and pick the first one that ACTUALLY exists.
371
+ // process.cwd() is the ultimate safety net — it always exists by definition.
372
+ const cwdCandidates = [
373
+ { source: 'OSBORN_CWD env var', value: process.env.OSBORN_CWD },
374
+ { source: 'config.workingDirectory', value: config.workingDirectory },
375
+ { source: 'process.cwd()', value: process.cwd() },
376
+ ];
377
+ let defaultWorkingDir = process.cwd();
378
+ let cwdSource = 'process.cwd() (last-resort fallback)';
379
+ for (const c of cwdCandidates) {
380
+ if (c.value && existsSync(c.value)) {
381
+ defaultWorkingDir = c.value;
382
+ cwdSource = c.source;
383
+ break;
384
+ }
385
+ if (c.value) {
386
+ console.log(` ⚠️ ${c.source} = ${c.value} (does not exist, skipping)`);
387
+ }
388
+ }
326
389
  let workingDir = defaultWorkingDir;
327
390
  console.log(`📂 Working directory (cwd): ${workingDir}`);
328
391
  console.log(`📂 Session base directory: ${sessionBaseDir}`);
329
- if (process.env.OSBORN_CWD) {
330
- console.log(` (cwd from OSBORN_CWD env var)`);
331
- }
392
+ console.log(` (cwd from ${cwdSource})`);
332
393
  console.log(`🔬 Mode: RESEARCH`);
333
394
  // Determine voice mode
334
395
  const voiceMode = getVoiceMode(config);
@@ -386,6 +447,43 @@ async function main() {
386
447
  let currentSession = null;
387
448
  let currentAgent = null; // For updateChatCtx() context injection
388
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
+ }
389
487
  let localParticipant = null;
390
488
  let agentState = 'initializing';
391
489
  // Session-level always-allow list: paths the user has approved for this session without prompting
@@ -459,7 +557,7 @@ async function main() {
459
557
  // fullText is what was being spoken when interrupted (passed from tts_say handler).
460
558
  // No word-level cutoff for say() — only generateReply pipeline has that — but Claude
461
559
  // knows its own output from JSONL, so the full block is enough context.
462
- console.log(`🔇 Speech interrupted. Was speaking: "${fullText.substring(0, 80)}..."`);
560
+ console.log(`🔇 Speech interrupted. Was speaking (${fullText.length} chars): "${fullText}"`);
463
561
  // Read last 10 assistant messages from JSONL (Claude's full untruncated output).
464
562
  // SessionMessage.text is pre-joined from all text content blocks.
465
563
  let recentMessages = '';
@@ -835,7 +933,7 @@ async function main() {
835
933
  });
836
934
  // Wire up Claude text output - RAW text goes to frontend for chat bubbles
837
935
  directLLM.events.on('assistant_text', (data) => {
838
- console.log(`💬 Claude text: ${data.text?.substring(0, 60)}...`);
936
+ console.log(`💬 Claude text (${data.text?.length || 0} chars): ${data.text || ''}`);
839
937
  sendToFrontend({
840
938
  type: 'claude_output',
841
939
  text: data.text,
@@ -845,7 +943,7 @@ async function main() {
845
943
  });
846
944
  // Wire up Claude final result - RAW result goes to frontend
847
945
  directLLM.events.on('assistant_result', (data) => {
848
- console.log(`📋 Claude result: ${data.text?.substring(0, 60)}...`);
946
+ console.log(`📋 Claude result (${data.text?.length || 0} chars): ${data.text || ''}`);
849
947
  sendToFrontend({
850
948
  type: 'claude_output',
851
949
  text: data.text,
@@ -963,7 +1061,7 @@ async function main() {
963
1061
  directLLM.events.on('tts_say', (data) => {
964
1062
  // Guard: session must be alive — TTS errors can kill the session while background query runs
965
1063
  if (!currentSession) {
966
- console.warn(`⚠️ tts_say fired but currentSession is null — text dropped: "${data.text?.substring(0, 60)}"`);
1064
+ console.warn(`⚠️ tts_say fired but currentSession is null — text dropped (${data.text?.length || 0} chars): "${data.text || ''}"`);
967
1065
  return;
968
1066
  }
969
1067
  if (!data.text?.trim()) {
@@ -971,7 +1069,7 @@ async function main() {
971
1069
  return;
972
1070
  }
973
1071
  const sayId = Date.now(); // simple ID to correlate start/end logs
974
- console.log(`🗣️ [${sayId}] session.say START (${data.text.length} chars): "${data.text.substring(0, 60)}..."`);
1072
+ console.log(`🗣️ [${sayId}] session.say START (${data.text.length} chars): "${data.text}"`);
975
1073
  try {
976
1074
  const handle = currentSession.say(data.text);
977
1075
  if (handle && typeof handle.addDoneCallback === 'function') {
@@ -1099,7 +1197,7 @@ async function main() {
1099
1197
  }
1100
1198
  });
1101
1199
  realtimeClaudeHandler.events.on('assistant_result', (data) => {
1102
- console.log(`📋 Claude result: ${data.text?.substring(0, 60)}...`);
1200
+ console.log(`📋 Claude result (${data.text?.length || 0} chars): ${data.text || ''}`);
1103
1201
  sendToFrontend({
1104
1202
  type: 'claude_output',
1105
1203
  text: data.text,
@@ -1591,6 +1689,9 @@ async function main() {
1591
1689
  lastCompletedResearch = null;
1592
1690
  currentSession = null;
1593
1691
  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');
1594
1695
  currentLLM = null;
1595
1696
  clearFastBrainSession();
1596
1697
  clearPipelineFastBrainSession();
@@ -1633,6 +1734,9 @@ async function main() {
1633
1734
  catch { }
1634
1735
  currentSession = null;
1635
1736
  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');
1636
1740
  currentLLM = null;
1637
1741
  }
1638
1742
  // Extract voice architecture, provider, and sessionId from participant metadata (sent by frontend)
@@ -1660,10 +1764,26 @@ async function main() {
1660
1764
  preSelectedSessionId = metadata.sessionId;
1661
1765
  console.log(`📂 Pre-selected session from frontend: ${preSelectedSessionId}`);
1662
1766
  }
1663
- // Read working directory override from frontend
1767
+ // Read working directory override from frontend.
1768
+ //
1769
+ // Must validate with existsSync before accepting: a broken reverse-slug in
1770
+ // the frontend's session list (see `slugToPath` in config.ts — the encoding
1771
+ // is lossy), a deleted project, or a bad legacy client can all produce a
1772
+ // non-existent path here. Passing a non-existent cwd to
1773
+ // `child_process.spawn` in the Claude SDK errors with ENOENT, which the
1774
+ // SDK then reports as the misleading "Claude Code executable not found at
1775
+ // .../cli.js" error (see MEMORY bug fix #11). Fall back to defaultWorkingDir
1776
+ // (which is itself existsSync-verified at startup).
1664
1777
  if (metadata.workingDirectory && typeof metadata.workingDirectory === 'string' && metadata.workingDirectory.length > 0) {
1665
- workingDir = metadata.workingDirectory;
1666
- console.log(`📂 Working directory from frontend: ${workingDir}`);
1778
+ if (existsSync(metadata.workingDirectory)) {
1779
+ workingDir = metadata.workingDirectory;
1780
+ console.log(`📂 Working directory from frontend: ${workingDir}`);
1781
+ }
1782
+ else {
1783
+ console.log(`⚠️ Frontend sent workingDirectory that does not exist: ${metadata.workingDirectory}`);
1784
+ console.log(` Falling back to default: ${defaultWorkingDir}`);
1785
+ workingDir = defaultWorkingDir;
1786
+ }
1667
1787
  }
1668
1788
  else {
1669
1789
  // Reset to default for new connections (in case previous session changed it)
@@ -1785,7 +1905,7 @@ async function main() {
1785
1905
  // (Gemini v1.0.51: userInput in generateReply creates a user conversation item)
1786
1906
  if (normalized.startsWith('[SCRIPT]') || normalized.startsWith('[PROACTIVE]') || normalized.startsWith('[NOTIFICATION]'))
1787
1907
  return;
1788
- console.log(`📝 User (${source}): "${transcript.substring(0, 60)}..."`);
1908
+ console.log(`📝 User (${source}, ${transcript.length} chars): "${transcript}"`);
1789
1909
  sendToFrontend({ type: 'user_transcript', text: transcript });
1790
1910
  lastSentUserTranscript = normalized;
1791
1911
  }
@@ -1795,7 +1915,7 @@ async function main() {
1795
1915
  const normalized = text.trim().replace(/\s+/g, ' ');
1796
1916
  if (normalized === lastSentAgentTranscript)
1797
1917
  return;
1798
- console.log(`💬 Agent (${source}): "${text.substring(0, 60)}..."`);
1918
+ console.log(`💬 Agent (${source}, ${text.length} chars): "${text}"`);
1799
1919
  sendToFrontend({ type: 'assistant_response', text });
1800
1920
  lastSentAgentTranscript = normalized;
1801
1921
  }
@@ -2253,6 +2373,9 @@ async function main() {
2253
2373
  })();
2254
2374
  }
2255
2375
  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');
2256
2379
  currentLLM = null;
2257
2380
  clearFastBrainSession();
2258
2381
  clearPipelineFastBrainSession();
@@ -2298,10 +2421,10 @@ async function main() {
2298
2421
  fullContent += `\n\n[Image attached: ${f.name}]`;
2299
2422
  }
2300
2423
  }
2301
- console.log(`📝 Text + ${files.length} file(s): "${fullContent.substring(0, 100)}"`);
2424
+ console.log(`📝 Text + ${files.length} file(s) (${fullContent.length} chars): "${fullContent}"`);
2302
2425
  }
2303
2426
  else {
2304
- console.log(`📝 Text: "${fullContent.substring(0, 100)}"`);
2427
+ console.log(`📝 Text (${fullContent.length} chars): "${fullContent}"`);
2305
2428
  }
2306
2429
  // Skip interrupt for Gemini — disrupts state machine (hangs in speaking state)
2307
2430
  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.substring(0, 60)}"`);
85
+ console.log(`📥 [pipeline] chat() call #${callN} (${userText.length} chars): "${userText}"`);
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()) {