voicecc 1.1.35 → 1.2.0

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.
Files changed (46) hide show
  1. package/bin/voicecc.js +94 -1
  2. package/dashboard/dist/assets/index-DCeOdulF.js +28 -0
  3. package/dashboard/dist/index.html +1 -1
  4. package/dashboard/routes/agents.ts +28 -8
  5. package/dashboard/routes/browser-call.ts +3 -2
  6. package/dashboard/routes/chat.ts +75 -55
  7. package/dashboard/routes/providers.ts +5 -74
  8. package/dashboard/routes/twilio.ts +104 -5
  9. package/dashboard/routes/voice.ts +98 -0
  10. package/dashboard/server.ts +58 -2
  11. package/package.json +2 -3
  12. package/server/index.ts +96 -8
  13. package/server/services/device-pairing.ts +18 -2
  14. package/server/services/twilio-manager.ts +29 -10
  15. package/dashboard/dist/assets/index-C62C9Gp0.js +0 -28
  16. package/dashboard/dist/audio-processor.js +0 -126
  17. package/server/services/heartbeat.ts +0 -403
  18. package/server/voice/assets/chime.wav +0 -0
  19. package/server/voice/assets/startup.pcm +0 -0
  20. package/server/voice/audio-adapter.ts +0 -60
  21. package/server/voice/audio-inactivity.test.ts +0 -108
  22. package/server/voice/audio-inactivity.ts +0 -91
  23. package/server/voice/browser-audio-playback.test.ts +0 -149
  24. package/server/voice/browser-audio.ts +0 -147
  25. package/server/voice/browser-server.ts +0 -311
  26. package/server/voice/chat-server.ts +0 -236
  27. package/server/voice/chime.test.ts +0 -69
  28. package/server/voice/chime.ts +0 -36
  29. package/server/voice/claude-session.ts +0 -293
  30. package/server/voice/endpointing.ts +0 -163
  31. package/server/voice/mic-vpio +0 -0
  32. package/server/voice/narration.ts +0 -204
  33. package/server/voice/prompt-builder.ts +0 -108
  34. package/server/voice/session-lock.ts +0 -123
  35. package/server/voice/stt-elevenlabs.ts +0 -210
  36. package/server/voice/stt-provider.ts +0 -106
  37. package/server/voice/tts-elevenlabs-hiss.test.ts +0 -183
  38. package/server/voice/tts-elevenlabs.ts +0 -397
  39. package/server/voice/tts-provider.ts +0 -155
  40. package/server/voice/twilio-audio.ts +0 -338
  41. package/server/voice/twilio-server.ts +0 -540
  42. package/server/voice/types.ts +0 -282
  43. package/server/voice/vad.ts +0 -101
  44. package/server/voice/voice-loop-bugs.test.ts +0 -348
  45. package/server/voice/voice-server.ts +0 -129
  46. package/server/voice/voice-session.ts +0 -539
@@ -1,236 +0,0 @@
1
- /**
2
- * Core session management for text chat sessions.
3
- *
4
- * Manages ClaudeSession lifecycle for text chat: creation on first message,
5
- * reuse across messages, and cleanup on close or inactivity timeout.
6
- * Framework-agnostic -- the Hono route layer in dashboard/routes/chat.ts
7
- * calls these functions.
8
- *
9
- * - getOrCreateSession: lazily creates a session on first message
10
- * - streamMessage: sends user text to Claude, yields SSE events
11
- * - closeSession: cleans up a session
12
- * - Inactivity timeout auto-cleans abandoned sessions after 10 minutes
13
- */
14
-
15
- import { join } from "path";
16
-
17
- import { createClaudeSession } from "./claude-session.js";
18
- import { buildAgentPrompt, buildDefaultPrompt } from "./prompt-builder.js";
19
- import { acquireSessionLock } from "./session-lock.js";
20
- import { AGENTS_DIR } from "../services/agent-store.js";
21
-
22
- import type { ClaudeSession } from "./claude-session.js";
23
- import type { SessionLock } from "./session-lock.js";
24
- import type { ClaudeStreamEvent } from "./types.js";
25
-
26
- // ============================================================================
27
- // CONSTANTS
28
- // ============================================================================
29
-
30
- /** Default max concurrent sessions (shared with voice sessions) */
31
- const DEFAULT_MAX_SESSIONS = 3;
32
-
33
- /** Inactivity timeout before auto-cleaning a session (10 minutes) */
34
- const INACTIVITY_TIMEOUT_MS = 600_000;
35
-
36
- /** Interval for checking inactive sessions (60 seconds) */
37
- const CLEANUP_INTERVAL_MS = 60_000;
38
-
39
- // ============================================================================
40
- // TYPES
41
- // ============================================================================
42
-
43
- /** Tracks an active text chat session */
44
- interface ActiveChatSession {
45
- /** The device token used for this session */
46
- deviceToken: string;
47
- /** Claude session handle */
48
- claudeSession: ClaudeSession;
49
- /** Session lock handle */
50
- lock: SessionLock;
51
- /** Optional agent ID for agent-specific sessions */
52
- agentId?: string;
53
- /** Whether the session is currently streaming a response */
54
- streaming: boolean;
55
- /** Timestamp of last activity (used for inactivity timeout) */
56
- lastActivity: number;
57
- }
58
-
59
- /** SSE event sent to the client */
60
- export interface ChatSseEvent {
61
- /** Event type */
62
- type: "text_delta" | "tool_start" | "tool_end" | "result" | "error";
63
- /** Text content or error message */
64
- content: string;
65
- /** Tool name (only for tool_start events) */
66
- toolName?: string;
67
- }
68
-
69
- // ============================================================================
70
- // STATE
71
- // ============================================================================
72
-
73
- /** Active chat sessions keyed by device token */
74
- const activeSessions = new Map<string, ActiveChatSession>();
75
-
76
- // Start the inactivity cleanup timer
77
- setInterval(() => {
78
- const now = Date.now();
79
- for (const [key, session] of activeSessions) {
80
- if (now - session.lastActivity > INACTIVITY_TIMEOUT_MS) {
81
- console.log(`Chat session timed out due to inactivity, token: ${key}`);
82
- closeSession(key).catch((err) => {
83
- console.error(`Error cleaning up timed-out chat session: ${err}`);
84
- });
85
- }
86
- }
87
- }, CLEANUP_INTERVAL_MS);
88
-
89
- // ============================================================================
90
- // MAIN HANDLERS
91
- // ============================================================================
92
-
93
- /**
94
- * Get or create a chat session for the given token.
95
- *
96
- * On first call for a token, acquires a session lock, builds the system
97
- * prompt, and creates a ClaudeSession. Subsequent calls return the existing
98
- * session.
99
- *
100
- * @param sessionKey - Device token to key the session on
101
- * @param agentId - Optional agent ID for agent-specific prompts
102
- * @returns The active chat session
103
- */
104
- export async function getOrCreateSession(sessionKey: string, agentId?: string): Promise<ActiveChatSession> {
105
- const existing = activeSessions.get(sessionKey);
106
- if (existing) return existing;
107
-
108
- const maxSessions = parseInt(process.env.MAX_CONCURRENT_SESSIONS ?? "", 10) || DEFAULT_MAX_SESSIONS;
109
- const lock = acquireSessionLock(maxSessions);
110
-
111
- let systemPrompt: string;
112
- let cwd: string | undefined;
113
-
114
- if (agentId) {
115
- systemPrompt = await buildAgentPrompt(agentId, "text");
116
- cwd = join(AGENTS_DIR, agentId);
117
- console.log(`Chat session using agent "${agentId}" for token ${sessionKey}`);
118
- } else {
119
- systemPrompt = buildDefaultPrompt("text");
120
- }
121
-
122
- const claudeSession = await createClaudeSession({
123
- allowedTools: [],
124
- permissionMode: "bypassPermissions",
125
- systemPrompt: "",
126
- customSystemPrompt: systemPrompt,
127
- ...(cwd && { cwd }),
128
- });
129
-
130
- const session: ActiveChatSession = {
131
- deviceToken: sessionKey,
132
- claudeSession,
133
- lock,
134
- agentId,
135
- streaming: false,
136
- lastActivity: Date.now(),
137
- };
138
-
139
- activeSessions.set(sessionKey, session);
140
- console.log(`Chat session created, token: ${sessionKey}`);
141
-
142
- return session;
143
- }
144
-
145
- /**
146
- * Send a user message and yield SSE events from Claude's response.
147
- *
148
- * Sets the streaming flag to prevent concurrent messages. Yields each
149
- * ClaudeStreamEvent as a ChatSseEvent. Throws if the session is already
150
- * streaming.
151
- *
152
- * @param sessionKey - Device token identifying the session
153
- * @param text - User message text
154
- * @yields ChatSseEvent objects for each streaming event
155
- */
156
- export async function* streamMessage(sessionKey: string, text: string): AsyncGenerator<ChatSseEvent> {
157
- const session = activeSessions.get(sessionKey);
158
- if (!session) throw new Error("No active session");
159
-
160
- if (session.streaming) {
161
- throw new Error("ALREADY_STREAMING");
162
- }
163
-
164
- session.lastActivity = Date.now();
165
- session.streaming = true;
166
-
167
- try {
168
- const events: AsyncIterable<ClaudeStreamEvent> = session.claudeSession.sendMessage(text);
169
-
170
- for await (const event of events) {
171
- const sseEvent: ChatSseEvent = {
172
- type: event.type,
173
- content: event.content,
174
- ...(event.toolName && { toolName: event.toolName }),
175
- };
176
-
177
- yield sseEvent;
178
- }
179
- } catch (err) {
180
- const msg = err instanceof Error ? err.message : "Error during response streaming";
181
- console.error(`Stream error for chat token ${sessionKey}:`, err);
182
- yield { type: "error", content: msg };
183
- } finally {
184
- session.streaming = false;
185
- session.lastActivity = Date.now();
186
- }
187
- }
188
-
189
- /**
190
- * Close a chat session by closing ClaudeSession, releasing the lock,
191
- * and removing from the activeSessions map.
192
- *
193
- * @param sessionKey - Device token identifying the session
194
- */
195
- export async function closeSession(sessionKey: string): Promise<void> {
196
- const session = activeSessions.get(sessionKey);
197
- if (!session) return;
198
-
199
- activeSessions.delete(sessionKey);
200
-
201
- await session.claudeSession.close();
202
- session.lock.release();
203
-
204
- console.log(`Chat session cleaned up, token: ${sessionKey}`);
205
- }
206
-
207
- /**
208
- * Interrupt the current streaming response for a session.
209
- *
210
- * Calls interrupt() on the underlying ClaudeSession to stop generation,
211
- * and resets the streaming flag so the user can send a new message.
212
- *
213
- * @param sessionKey - Device token identifying the session
214
- * @returns true if a streaming session was interrupted, false if nothing to interrupt
215
- */
216
- export function interruptSession(sessionKey: string): boolean {
217
- const session = activeSessions.get(sessionKey);
218
- if (!session || !session.streaming) return false;
219
-
220
- session.claudeSession.interrupt();
221
- session.streaming = false;
222
- session.lastActivity = Date.now();
223
-
224
- console.log(`Chat session interrupted, token: ${sessionKey}`);
225
- return true;
226
- }
227
-
228
- /**
229
- * Check if a session exists for the given token.
230
- *
231
- * @param sessionKey - Device token to check
232
- * @returns true if a session exists
233
- */
234
- export function hasSession(sessionKey: string): boolean {
235
- return activeSessions.has(sessionKey);
236
- }
@@ -1,69 +0,0 @@
1
- /**
2
- * Tests that decodeChimeToPcm produces clean audio without artifacts.
3
- *
4
- * The chime PCM is sent directly to the browser as raw int16 samples.
5
- * If the buffer contains file-format headers, they get played as loud
6
- * garbage ("bop") before the actual chime.
7
- *
8
- * Run: npx tsx --test server/voice/chime.test.ts
9
- */
10
-
11
- import { test } from "node:test";
12
- import { strict as assert } from "node:assert";
13
-
14
- import { decodeChimeToPcm } from "./chime.js";
15
-
16
- // ============================================================================
17
- // CONSTANTS
18
- // ============================================================================
19
-
20
- /** Sample rate of the decoded chime */
21
- const CHIME_RATE = 24000;
22
-
23
- /**
24
- * Max acceptable amplitude for the first 10ms of the chime.
25
- * Glass.aiff fades in from silence, so early samples should be near-zero.
26
- * A value above this means non-audio data (e.g. file headers) is present.
27
- */
28
- const MAX_AMPLITUDE_FIRST_10MS = 500;
29
-
30
- // ============================================================================
31
- // TESTS
32
- // ============================================================================
33
-
34
- /**
35
- * The chime starts quietly -- the Glass.aiff sound fades in from silence.
36
- * If the first samples contain large values, the buffer has non-audio data
37
- * (file-format headers) that would be heard as a loud pop/bop.
38
- */
39
- test("chime PCM starts with near-silent samples (no file header artifacts)", () => {
40
- const buf = decodeChimeToPcm();
41
- const int16 = new Int16Array(buf.buffer, buf.byteOffset, buf.byteLength / 2);
42
-
43
- const samplesIn10ms = Math.floor(CHIME_RATE * 0.01);
44
-
45
- let maxAmplitude = 0;
46
- for (let i = 0; i < samplesIn10ms; i++) {
47
- maxAmplitude = Math.max(maxAmplitude, Math.abs(int16[i]));
48
- }
49
-
50
- assert.ok(
51
- maxAmplitude < MAX_AMPLITUDE_FIRST_10MS,
52
- `First 10ms of chime has amplitude ${maxAmplitude} (limit: ${MAX_AMPLITUDE_FIRST_10MS}). ` +
53
- `This likely means file-format header bytes are being included as audio data.`
54
- );
55
- });
56
-
57
- /**
58
- * The decoded chime should contain roughly 1-2 seconds of audio.
59
- * If the buffer is much larger, it likely includes a large file header.
60
- * If much smaller, the decoding failed.
61
- */
62
- test("chime PCM has a plausible duration for Glass.aiff", () => {
63
- const buf = decodeChimeToPcm();
64
- const sampleCount = buf.byteLength / 2; // int16 = 2 bytes per sample
65
- const durationSec = sampleCount / CHIME_RATE;
66
-
67
- assert.ok(durationSec > 0.5, `Chime too short: ${durationSec.toFixed(2)}s`);
68
- assert.ok(durationSec < 3.0, `Chime too long: ${durationSec.toFixed(2)}s -- may contain header data`);
69
- });
@@ -1,36 +0,0 @@
1
- /**
2
- * Shared utility for loading the ready chime as raw PCM.
3
- *
4
- * Reads a pre-converted raw 24kHz int16 mono PCM file bundled in init/.
5
- * Works on both macOS and Linux with no runtime dependencies.
6
- *
7
- * Responsibilities:
8
- * - Load the bundled chime-24k.raw file as a Buffer
9
- */
10
-
11
- import { readFileSync } from "fs";
12
- import { join, dirname } from "path";
13
- import { fileURLToPath } from "url";
14
-
15
- // ============================================================================
16
- // CONSTANTS
17
- // ============================================================================
18
-
19
- const __dirname = dirname(fileURLToPath(import.meta.url));
20
-
21
- /** Path to the bundled raw PCM chime (24kHz int16 mono) */
22
- const CHIME_PATH = join(__dirname, "..", "..", "init", "chime-24k.raw");
23
-
24
- // ============================================================================
25
- // MAIN ENTRYPOINT
26
- // ============================================================================
27
-
28
- /**
29
- * Load the bundled chime as raw 24kHz int16 mono PCM.
30
- *
31
- * @returns Buffer containing raw 24kHz int16 mono PCM
32
- * @throws Error if the chime file is missing
33
- */
34
- export function decodeChimeToPcm(): Buffer {
35
- return readFileSync(CHIME_PATH);
36
- }
@@ -1,293 +0,0 @@
1
- /**
2
- * Claude session via the @anthropic-ai/claude-agent-sdk.
3
- *
4
- * Keeps a single persistent Claude Code process alive across turns using
5
- * streaming I/O (AsyncIterable<SDKUserMessage> input). This eliminates the
6
- * ~2-3s process spawn overhead on each turn.
7
- *
8
- * Responsibilities:
9
- * - Start a persistent query() on createClaudeSession (process spawns once)
10
- * - Push user messages into the live session via an async queue
11
- * - Extract streaming text deltas from SDKPartialAssistantMessage events
12
- * - Map tool_use content blocks to tool_start / tool_end events
13
- * - Support interruption via query.interrupt()
14
- * - Provide clean session teardown
15
- */
16
-
17
- import { query as claudeQuery, type Query, type Options, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
18
- import { buildDefaultPrompt } from "./prompt-builder.js";
19
- import type { ClaudeSessionConfig, ClaudeStreamEvent } from "./types.js";
20
-
21
- /** Injectable query function signature for testing. Matches the SDK query() contract. */
22
- export type QueryFn = (params: { prompt: AsyncIterable<SDKUserMessage>; options: Options }) => Query;
23
-
24
- // ============================================================================
25
- // ASYNC QUEUE
26
- // ============================================================================
27
-
28
- /** Simple async iterable backed by a push queue. */
29
- class AsyncQueue<T> implements AsyncIterable<T> {
30
- private buf: T[] = [];
31
- private resolve: ((r: IteratorResult<T>) => void) | null = null;
32
- private done = false;
33
-
34
- push(item: T) {
35
- if (this.resolve) {
36
- const r = this.resolve;
37
- this.resolve = null;
38
- r({ value: item, done: false });
39
- } else {
40
- this.buf.push(item);
41
- }
42
- }
43
-
44
- close() {
45
- this.done = true;
46
- if (this.resolve) {
47
- const r = this.resolve;
48
- this.resolve = null;
49
- r({ value: undefined as any, done: true });
50
- }
51
- }
52
-
53
- /** Discard all buffered items. Used to clear stale events after interruption. */
54
- drain(): void {
55
- this.buf.length = 0;
56
- }
57
-
58
- /** Read one item (used by sendMessage to drain the event channel). */
59
- async next(): Promise<T | undefined> {
60
- if (this.buf.length > 0) return this.buf.shift()!;
61
- if (this.done) return undefined;
62
- const result = await new Promise<IteratorResult<T>>((r) => { this.resolve = r; });
63
- return result.done ? undefined : result.value;
64
- }
65
-
66
- [Symbol.asyncIterator](): AsyncIterator<T> {
67
- return {
68
- next: (): Promise<IteratorResult<T>> => {
69
- if (this.buf.length > 0) {
70
- return Promise.resolve({ value: this.buf.shift()!, done: false as const });
71
- }
72
- if (this.done) {
73
- return Promise.resolve({ value: undefined as any, done: true as const });
74
- }
75
- return new Promise<IteratorResult<T>>((r) => { this.resolve = r; });
76
- },
77
- };
78
- }
79
- }
80
-
81
- // ============================================================================
82
- // INTERFACES
83
- // ============================================================================
84
-
85
- /** Session object returned by createClaudeSession. */
86
- interface ClaudeSession {
87
- sendMessage(text: string): AsyncIterable<ClaudeStreamEvent>;
88
- interrupt(): void;
89
- close(): Promise<void>;
90
- }
91
-
92
- // ============================================================================
93
- // MAIN HANDLERS
94
- // ============================================================================
95
-
96
- async function createClaudeSession(
97
- config: ClaudeSessionConfig,
98
- queryOverride?: QueryFn,
99
- ): Promise<ClaudeSession> {
100
- const systemPrompt = config.systemPrompt || buildDefaultPrompt("voice");
101
- let sessionId = "";
102
- let closed = false;
103
- let lastTurnCompletedCleanly = true;
104
-
105
- // Persistent input stream — user messages are pushed here across turns
106
- const userMessages = new AsyncQueue<SDKUserMessage>();
107
-
108
- // Event channel — SDK events are routed here for sendMessage to consume
109
- const sdkEvents = new AsyncQueue<SDKMessage>();
110
-
111
- // systemPrompt as string replaces the entire system prompt.
112
- // { type: 'preset', preset: 'claude_code', append: '...' } appends to default.
113
- const options: Options = {
114
- includePartialMessages: true,
115
- maxThinkingTokens: 10000,
116
- ...(config.customSystemPrompt
117
- ? { systemPrompt: config.customSystemPrompt }
118
- : { systemPrompt: { type: "preset" as const, preset: "claude_code" as const, append: systemPrompt } }),
119
- permissionMode: config.permissionMode as Options["permissionMode"],
120
- allowDangerouslySkipPermissions: config.permissionMode === "bypassPermissions",
121
- settingSources: ["user", "project", "local"],
122
- ...(config.cwd && { cwd: config.cwd }),
123
- stderr: (data: string) => {
124
- const msg = data.trim();
125
- if (msg) console.error(`[claude-stderr] ${msg}`);
126
- },
127
- };
128
-
129
- // Start persistent query — process spawns once and stays alive.
130
- // NOTE: with AsyncIterable<SDKUserMessage> input, the SDK won't yield
131
- // events until the first user message is consumed, so we don't block
132
- // waiting for a system init event here. Session ID is captured when
133
- // the system event arrives during the first turn.
134
- const queryFn = queryOverride ?? claudeQuery;
135
- const q = queryFn({ prompt: userMessages, options });
136
-
137
- // Background: pump SDK events into our channel
138
- (async () => {
139
- try {
140
- for await (const msg of q) {
141
- if (msg.type === "system" && !sessionId) {
142
- sessionId = msg.session_id;
143
- console.log(`[claude] session ready (id=${sessionId})`);
144
- }
145
- sdkEvents.push(msg);
146
- }
147
- } catch (err) {
148
- console.error("[claude] SDK pump error:", err);
149
- } finally {
150
- sdkEvents.close();
151
- }
152
- })();
153
-
154
- console.log("[claude] persistent process started");
155
-
156
- return {
157
- async *sendMessage(text: string): AsyncIterable<ClaudeStreamEvent> {
158
- if (closed) {
159
- throw new Error("Session is closed.");
160
- }
161
-
162
- if (!text.trim()) {
163
- throw new Error("Cannot send empty message.");
164
- }
165
-
166
- // If the previous turn was interrupted, consume remaining events until its result
167
- if (!lastTurnCompletedCleanly) {
168
- while (true) {
169
- const msg = await sdkEvents.next();
170
- if (!msg || msg.type === "result") break;
171
- }
172
- }
173
- sdkEvents.drain();
174
- lastTurnCompletedCleanly = false;
175
-
176
- const t0 = Date.now();
177
- let hasStreamedContent = false;
178
- const toolUseBlocks = new Set<number>();
179
- const thinkingBlocks = new Set<number>();
180
-
181
- // Push user message into the live session
182
- userMessages.push({
183
- type: "user",
184
- message: { content: text, role: "user" },
185
- parent_tool_use_id: null,
186
- session_id: sessionId,
187
- });
188
-
189
- // Read events for this turn until result
190
- while (true) {
191
- const msg = await sdkEvents.next();
192
- if (!msg) break; // channel closed (process died)
193
-
194
- // Streaming events (token-level deltas)
195
- if (msg.type === "stream_event") {
196
- const event = msg.event;
197
-
198
- if (event.type === "content_block_start") {
199
- if (event.content_block.type === "tool_use") {
200
- hasStreamedContent = true;
201
- toolUseBlocks.add(event.index);
202
- yield { type: "tool_start", content: "", toolName: event.content_block.name };
203
- }
204
- if (event.content_block.type === "thinking") {
205
- hasStreamedContent = true;
206
- thinkingBlocks.add(event.index);
207
- console.log(`[claude] thinking started at +${Date.now() - t0}ms`);
208
- yield { type: "text_delta", content: "Thinking... " };
209
- }
210
- continue;
211
- }
212
-
213
- if (event.type === "content_block_delta") {
214
- if (event.delta.type === "text_delta") {
215
- if (!hasStreamedContent) {
216
- console.log(`[claude] first delta at +${Date.now() - t0}ms`);
217
- }
218
- hasStreamedContent = true;
219
- yield { type: "text_delta", content: event.delta.text };
220
- }
221
- continue;
222
- }
223
-
224
- if (event.type === "content_block_stop") {
225
- if (thinkingBlocks.has(event.index)) {
226
- thinkingBlocks.delete(event.index);
227
- console.log(`[claude] thinking ended at +${Date.now() - t0}ms`);
228
- }
229
- if (toolUseBlocks.has(event.index)) {
230
- toolUseBlocks.delete(event.index);
231
- yield { type: "tool_end", content: "" };
232
- }
233
- continue;
234
- }
235
-
236
- continue;
237
- }
238
-
239
- // Full assistant message — fallback if streaming didn't produce deltas
240
- if (msg.type === "assistant") {
241
- if (hasStreamedContent) {
242
- console.log(`[claude] full message at +${Date.now() - t0}ms (skipped, already streamed)`);
243
- } else {
244
- console.log(`[claude] full message at +${Date.now() - t0}ms (no streaming, using fallback)`);
245
- const blocks = msg.message.content;
246
- if (Array.isArray(blocks)) {
247
- for (const block of blocks) {
248
- if (block.type === "text") {
249
- yield { type: "text_delta", content: block.text };
250
- }
251
- if (block.type === "tool_use") {
252
- yield { type: "tool_start", content: "", toolName: block.name };
253
- }
254
- }
255
- }
256
- }
257
- toolUseBlocks.clear();
258
- continue;
259
- }
260
-
261
- // Skip system events and synthetic user messages (tool results)
262
- if (msg.type === "system" || msg.type === "user") {
263
- continue;
264
- }
265
-
266
- // Result — turn complete
267
- if (msg.type === "result") {
268
- lastTurnCompletedCleanly = true;
269
- console.log(`[claude] result at +${Date.now() - t0}ms (streamed=${hasStreamedContent})`);
270
- if (msg.is_error) {
271
- yield { type: "error", content: msg.subtype === "success" ? String((msg as any).result) : msg.subtype };
272
- }
273
- break;
274
- }
275
- }
276
-
277
- yield { type: "result", content: "" };
278
- },
279
-
280
- interrupt(): void {
281
- q.interrupt();
282
- },
283
-
284
- async close(): Promise<void> {
285
- closed = true;
286
- userMessages.close();
287
- await q.interrupt();
288
- },
289
- };
290
- }
291
-
292
- export { createClaudeSession };
293
- export type { ClaudeSession };