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.
- package/Dockerfile.sandbox +59 -0
- package/dist/claude-llm.js +40 -7
- package/dist/codex-llm.js +1 -1
- package/dist/config.js +65 -13
- package/dist/index.js +140 -17
- package/dist/pipeline-direct-llm.js +1 -1
- package/dist/prompts.js +25 -312
- package/package.json +5 -2
- package/scripts/dev-logged.ts +81 -0
- package/scripts/review.ts +425 -0
|
@@ -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"]
|
package/dist/claude-llm.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
508
|
-
*
|
|
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
|
-
|
|
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
|
|
516
|
-
*
|
|
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
|
|
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
|
|
528
|
-
|
|
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', () =>
|
|
536
|
-
rl.on('error', () =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1666
|
-
|
|
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
|
|
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
|
|
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)
|
|
2424
|
+
console.log(`📝 Text + ${files.length} file(s) (${fullContent.length} chars): "${fullContent}"`);
|
|
2302
2425
|
}
|
|
2303
2426
|
else {
|
|
2304
|
-
console.log(`📝 Text
|
|
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}
|
|
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()) {
|