osborn 0.8.6 → 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 +116 -13
- package/dist/pipeline-direct-llm.js +1 -1
- package/dist/prompts.js +25 -312
- package/package.json +3 -1
- 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
|
});
|
|
@@ -406,6 +447,43 @@ async function main() {
|
|
|
406
447
|
let currentSession = null;
|
|
407
448
|
let currentAgent = null; // For updateChatCtx() context injection
|
|
408
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
|
+
}
|
|
409
487
|
let localParticipant = null;
|
|
410
488
|
let agentState = 'initializing';
|
|
411
489
|
// Session-level always-allow list: paths the user has approved for this session without prompting
|
|
@@ -479,7 +557,7 @@ async function main() {
|
|
|
479
557
|
// fullText is what was being spoken when interrupted (passed from tts_say handler).
|
|
480
558
|
// No word-level cutoff for say() — only generateReply pipeline has that — but Claude
|
|
481
559
|
// knows its own output from JSONL, so the full block is enough context.
|
|
482
|
-
console.log(`🔇 Speech interrupted. Was speaking
|
|
560
|
+
console.log(`🔇 Speech interrupted. Was speaking (${fullText.length} chars): "${fullText}"`);
|
|
483
561
|
// Read last 10 assistant messages from JSONL (Claude's full untruncated output).
|
|
484
562
|
// SessionMessage.text is pre-joined from all text content blocks.
|
|
485
563
|
let recentMessages = '';
|
|
@@ -855,7 +933,7 @@ async function main() {
|
|
|
855
933
|
});
|
|
856
934
|
// Wire up Claude text output - RAW text goes to frontend for chat bubbles
|
|
857
935
|
directLLM.events.on('assistant_text', (data) => {
|
|
858
|
-
console.log(`💬 Claude text
|
|
936
|
+
console.log(`💬 Claude text (${data.text?.length || 0} chars): ${data.text || ''}`);
|
|
859
937
|
sendToFrontend({
|
|
860
938
|
type: 'claude_output',
|
|
861
939
|
text: data.text,
|
|
@@ -865,7 +943,7 @@ async function main() {
|
|
|
865
943
|
});
|
|
866
944
|
// Wire up Claude final result - RAW result goes to frontend
|
|
867
945
|
directLLM.events.on('assistant_result', (data) => {
|
|
868
|
-
console.log(`📋 Claude result
|
|
946
|
+
console.log(`📋 Claude result (${data.text?.length || 0} chars): ${data.text || ''}`);
|
|
869
947
|
sendToFrontend({
|
|
870
948
|
type: 'claude_output',
|
|
871
949
|
text: data.text,
|
|
@@ -983,7 +1061,7 @@ async function main() {
|
|
|
983
1061
|
directLLM.events.on('tts_say', (data) => {
|
|
984
1062
|
// Guard: session must be alive — TTS errors can kill the session while background query runs
|
|
985
1063
|
if (!currentSession) {
|
|
986
|
-
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 || ''}"`);
|
|
987
1065
|
return;
|
|
988
1066
|
}
|
|
989
1067
|
if (!data.text?.trim()) {
|
|
@@ -991,7 +1069,7 @@ async function main() {
|
|
|
991
1069
|
return;
|
|
992
1070
|
}
|
|
993
1071
|
const sayId = Date.now(); // simple ID to correlate start/end logs
|
|
994
|
-
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}"`);
|
|
995
1073
|
try {
|
|
996
1074
|
const handle = currentSession.say(data.text);
|
|
997
1075
|
if (handle && typeof handle.addDoneCallback === 'function') {
|
|
@@ -1119,7 +1197,7 @@ async function main() {
|
|
|
1119
1197
|
}
|
|
1120
1198
|
});
|
|
1121
1199
|
realtimeClaudeHandler.events.on('assistant_result', (data) => {
|
|
1122
|
-
console.log(`📋 Claude result
|
|
1200
|
+
console.log(`📋 Claude result (${data.text?.length || 0} chars): ${data.text || ''}`);
|
|
1123
1201
|
sendToFrontend({
|
|
1124
1202
|
type: 'claude_output',
|
|
1125
1203
|
text: data.text,
|
|
@@ -1611,6 +1689,9 @@ async function main() {
|
|
|
1611
1689
|
lastCompletedResearch = null;
|
|
1612
1690
|
currentSession = null;
|
|
1613
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');
|
|
1614
1695
|
currentLLM = null;
|
|
1615
1696
|
clearFastBrainSession();
|
|
1616
1697
|
clearPipelineFastBrainSession();
|
|
@@ -1653,6 +1734,9 @@ async function main() {
|
|
|
1653
1734
|
catch { }
|
|
1654
1735
|
currentSession = null;
|
|
1655
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');
|
|
1656
1740
|
currentLLM = null;
|
|
1657
1741
|
}
|
|
1658
1742
|
// Extract voice architecture, provider, and sessionId from participant metadata (sent by frontend)
|
|
@@ -1680,10 +1764,26 @@ async function main() {
|
|
|
1680
1764
|
preSelectedSessionId = metadata.sessionId;
|
|
1681
1765
|
console.log(`📂 Pre-selected session from frontend: ${preSelectedSessionId}`);
|
|
1682
1766
|
}
|
|
1683
|
-
// 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).
|
|
1684
1777
|
if (metadata.workingDirectory && typeof metadata.workingDirectory === 'string' && metadata.workingDirectory.length > 0) {
|
|
1685
|
-
|
|
1686
|
-
|
|
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
|
+
}
|
|
1687
1787
|
}
|
|
1688
1788
|
else {
|
|
1689
1789
|
// Reset to default for new connections (in case previous session changed it)
|
|
@@ -1805,7 +1905,7 @@ async function main() {
|
|
|
1805
1905
|
// (Gemini v1.0.51: userInput in generateReply creates a user conversation item)
|
|
1806
1906
|
if (normalized.startsWith('[SCRIPT]') || normalized.startsWith('[PROACTIVE]') || normalized.startsWith('[NOTIFICATION]'))
|
|
1807
1907
|
return;
|
|
1808
|
-
console.log(`📝 User (${source}): "${transcript
|
|
1908
|
+
console.log(`📝 User (${source}, ${transcript.length} chars): "${transcript}"`);
|
|
1809
1909
|
sendToFrontend({ type: 'user_transcript', text: transcript });
|
|
1810
1910
|
lastSentUserTranscript = normalized;
|
|
1811
1911
|
}
|
|
@@ -1815,7 +1915,7 @@ async function main() {
|
|
|
1815
1915
|
const normalized = text.trim().replace(/\s+/g, ' ');
|
|
1816
1916
|
if (normalized === lastSentAgentTranscript)
|
|
1817
1917
|
return;
|
|
1818
|
-
console.log(`💬 Agent (${source}): "${text
|
|
1918
|
+
console.log(`💬 Agent (${source}, ${text.length} chars): "${text}"`);
|
|
1819
1919
|
sendToFrontend({ type: 'assistant_response', text });
|
|
1820
1920
|
lastSentAgentTranscript = normalized;
|
|
1821
1921
|
}
|
|
@@ -2273,6 +2373,9 @@ async function main() {
|
|
|
2273
2373
|
})();
|
|
2274
2374
|
}
|
|
2275
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');
|
|
2276
2379
|
currentLLM = null;
|
|
2277
2380
|
clearFastBrainSession();
|
|
2278
2381
|
clearPipelineFastBrainSession();
|
|
@@ -2318,10 +2421,10 @@ async function main() {
|
|
|
2318
2421
|
fullContent += `\n\n[Image attached: ${f.name}]`;
|
|
2319
2422
|
}
|
|
2320
2423
|
}
|
|
2321
|
-
console.log(`📝 Text + ${files.length} file(s)
|
|
2424
|
+
console.log(`📝 Text + ${files.length} file(s) (${fullContent.length} chars): "${fullContent}"`);
|
|
2322
2425
|
}
|
|
2323
2426
|
else {
|
|
2324
|
-
console.log(`📝 Text
|
|
2427
|
+
console.log(`📝 Text (${fullContent.length} chars): "${fullContent}"`);
|
|
2325
2428
|
}
|
|
2326
2429
|
// Skip interrupt for Gemini — disrupts state machine (hangs in speaking state)
|
|
2327
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()) {
|