Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt standard monorepo conventions (apps/ for runnable apps, packages/ for reusable libraries). Update all config files, shared package imports, test fixtures, and documentation to reflect new paths. Key fixes: - Update workspace config to ["apps/*", "packages/*"] - Update tsconfig.json rootDir/include for apps/server/ - Add apps/web/** to vitest exclude list - Update drizzle.config.ts schema path - Fix ensure-schema.ts migration path detection (3 levels up in dev, 2 levels up in dist) - Fix tests/integration/cli-server.test.ts import paths - Update packages/shared imports to apps/server/ paths - Update all docs/ files with new paths
168 lines
4.5 KiB
TypeScript
168 lines
4.5 KiB
TypeScript
/**
|
|
* Claude Code Stream Parser
|
|
*
|
|
* Parses Claude Code CLI `--output-format stream-json` NDJSON output
|
|
* into standardized StreamEvents.
|
|
*
|
|
* Key line types handled:
|
|
* - system (subtype=init): session_id
|
|
* - stream_event (content_block_delta, text_delta): delta.text
|
|
* - stream_event (content_block_start, tool_use): content_block.name, .id
|
|
* - stream_event (message_delta): delta.stop_reason
|
|
* - result: result, session_id, total_cost_usd
|
|
* - any with is_error: true: error message
|
|
*/
|
|
|
|
import type { StreamEvent, StreamParser } from '../stream-types.js';
|
|
|
|
interface ClaudeSystemEvent {
|
|
type: 'system';
|
|
subtype?: string;
|
|
session_id?: string;
|
|
}
|
|
|
|
interface ClaudeStreamEvent {
|
|
type: 'stream_event';
|
|
event?: {
|
|
type: string;
|
|
index?: number;
|
|
delta?: {
|
|
type?: string;
|
|
text?: string;
|
|
stop_reason?: string;
|
|
};
|
|
content_block?: {
|
|
type?: string;
|
|
id?: string;
|
|
name?: string;
|
|
};
|
|
};
|
|
}
|
|
|
|
interface ClaudeAssistantEvent {
|
|
type: 'assistant';
|
|
message?: {
|
|
content?: Array<{
|
|
type: string;
|
|
text?: string;
|
|
id?: string;
|
|
name?: string;
|
|
}>;
|
|
};
|
|
}
|
|
|
|
interface ClaudeResultEvent {
|
|
type: 'result';
|
|
result?: string;
|
|
session_id?: string;
|
|
total_cost_usd?: number;
|
|
is_error?: boolean;
|
|
}
|
|
|
|
type ClaudeEvent = ClaudeSystemEvent | ClaudeStreamEvent | ClaudeAssistantEvent | ClaudeResultEvent | { type: string; is_error?: boolean; result?: string };
|
|
|
|
export class ClaudeStreamParser implements StreamParser {
|
|
readonly provider = 'claude';
|
|
|
|
parseLine(line: string): StreamEvent[] {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) return [];
|
|
|
|
let parsed: ClaudeEvent;
|
|
try {
|
|
parsed = JSON.parse(trimmed);
|
|
} catch {
|
|
// Not valid JSON, ignore
|
|
return [];
|
|
}
|
|
|
|
// Check for error on non-result events (e.g. stream errors)
|
|
// Result events with is_error are handled in the 'result' case below
|
|
if ('is_error' in parsed && parsed.is_error && 'result' in parsed && parsed.type !== 'result') {
|
|
return [{ type: 'error', message: String(parsed.result) }];
|
|
}
|
|
|
|
const events: StreamEvent[] = [];
|
|
|
|
switch (parsed.type) {
|
|
case 'system': {
|
|
const sysEvent = parsed as ClaudeSystemEvent;
|
|
if (sysEvent.subtype === 'init' && sysEvent.session_id) {
|
|
events.push({ type: 'init', sessionId: sysEvent.session_id });
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'stream_event': {
|
|
const streamEvent = parsed as ClaudeStreamEvent;
|
|
const inner = streamEvent.event;
|
|
if (!inner) break;
|
|
|
|
switch (inner.type) {
|
|
case 'content_block_delta': {
|
|
if (inner.delta?.type === 'text_delta' && inner.delta.text) {
|
|
events.push({ type: 'text_delta', text: inner.delta.text });
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'content_block_start': {
|
|
if (inner.content_block?.type === 'tool_use') {
|
|
const name = inner.content_block.name || 'unknown';
|
|
const id = inner.content_block.id || '';
|
|
events.push({ type: 'tool_use_start', name, id });
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'message_delta': {
|
|
if (inner.delta?.stop_reason) {
|
|
events.push({ type: 'turn_end', stopReason: inner.delta.stop_reason });
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'assistant': {
|
|
// Claude CLI stream-json now emits complete assistant messages
|
|
// instead of granular stream_event deltas
|
|
const assistantEvent = parsed as ClaudeAssistantEvent;
|
|
const content = assistantEvent.message?.content;
|
|
if (Array.isArray(content)) {
|
|
for (const block of content) {
|
|
if (block.type === 'text' && block.text) {
|
|
events.push({ type: 'text_delta', text: block.text });
|
|
} else if (block.type === 'tool_use' && block.name) {
|
|
events.push({ type: 'tool_use_start', name: block.name, id: block.id || '' });
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'result': {
|
|
const resultEvent = parsed as ClaudeResultEvent;
|
|
events.push({
|
|
type: 'result',
|
|
text: resultEvent.result || '',
|
|
sessionId: resultEvent.session_id,
|
|
costUsd: resultEvent.total_cost_usd,
|
|
isError: resultEvent.is_error === true,
|
|
});
|
|
break;
|
|
}
|
|
|
|
// Ignore: message_start, content_block_stop, message_stop, user
|
|
}
|
|
|
|
return events;
|
|
}
|
|
|
|
end(): StreamEvent[] {
|
|
// Claude emits a result event, so nothing needed at end
|
|
return [];
|
|
}
|
|
}
|