ClaudeCliProtocol.js 12.4 KB
/**
 * ClaudeCliProtocol — Claude Code CLI stream-json 交互协议
 *
 * 使用 `claude --input-format stream-json --output-format stream-json --verbose
 *   --include-partial-messages --dangerously-skip-permissions` 与 Claude CLI 通信。
 *
 * 持久化进程:启动一次,跨多次 sendPrompt 调用复用。
 *
 * 与 AcpProtocol 的区别:
 *   - 不使用 JSON-RPC(Claude CLI 自有协议)
 *   - 输入:{"type":"user","message":{"role":"user","content":[{"type":"text","text":"..."}]}}
 *   - 输出:NDJSON 流
 *     - {type:"stream_event", event:{type:"content_block_delta", delta:{type:"text_delta",...}}}
 *     - {type:"assistant", message:{...}}  (带 --include-partial-messages)
 *     - {type:"result", result:"...", stop_reason:"end_turn"}
 *   - initialize() / newSession() 为 no-op
 *
 * 事件兼容 AcpProtocol 接口:
 *   - emit('session_update', { update: { sessionUpdate, content: { text } } })
 *   - emit('acp_event', { type, method, params, timestamp })
 *   - emit('error', Error)
 */

const { EventEmitter } = require('events');
const { spawn } = require('child_process');

class ClaudeCliProtocol extends EventEmitter {
    constructor(agentInfo = {}, options = {}) {
        super();
        this.agentInfo = agentInfo;
        this.options = options;
        this.isConnected = false;
        this.sessionId = null;
        this._child = null;
        this._workingDir = null;
        this._buffer = '';
        this._promptResolve = null;
        this._promptReject = null;
    }

    /**
     * 连接:启动 Claude CLI 持久进程
     * transport 参数被忽略:本协议自行管理子进程
     */
    async connect(transport, workingDir) {
        this._workingDir = workingDir;

        const args = [
            '--input-format', 'stream-json',
            '--output-format', 'stream-json',
            '--verbose',
            '--include-partial-messages',
            '--dangerously-skip-permissions',
        ];

        // 支持禁用特定原生工具 (用于编排型 Agent,强制纯文本输出)
        if (this.options.disallowedTools && this.options.disallowedTools.length > 0) {
            args.push('--disallowedTools', ...this.options.disallowedTools);
        }
        // 支持仅开放特定工具 (空字符串 = 禁用全部)
        if (this.options.allowedTools !== undefined) {
            if (this.options.allowedTools === '') {
                args.push('--tools', '');
            } else if (Array.isArray(this.options.allowedTools)) {
                args.push('--allowedTools', ...this.options.allowedTools);
            }
        }

        const processEnv = { ...process.env };
        delete processEnv.NODE_OPTIONS;
        delete processEnv.ELECTRON_RUN_AS_NODE;
        delete processEnv.CLAUDECODE;

        console.log(`[ClaudeCliProtocol] 启动: claude ${args.join(' ')}`);

        this._child = spawn('claude', args, {
            stdio: ['pipe', 'pipe', 'pipe'],
            env: processEnv,
            cwd: workingDir,
        });

        // stdout → NDJSON 解析
        this._child.stdout.on('data', (data) => {
            this._buffer += data.toString();
            const lines = this._buffer.split('\n');
            this._buffer = lines.pop() || '';

            for (const line of lines) {
                if (!line.trim()) continue;
                try {
                    const event = JSON.parse(line);
                    this._handleStreamEvent(event);
                } catch (e) {
                    // 非 JSON 行,忽略
                }
            }
        });

        // stderr 转发
        this._child.stderr.on('data', (data) => {
            const msg = data.toString().trim();
            if (msg) {
                console.log('[ClaudeCliProtocol] stderr:', msg.substring(0, 300));
                this.emit('stderr', msg);
            }
        });

        // 进程退出
        this._child.on('exit', (code, signal) => {
            console.log(`[ClaudeCliProtocol] 进程退出: code=${code} signal=${signal}`);
            this.isConnected = false;
            this._child = null;

            if (this._promptReject) {
                this._promptReject(new Error(`Claude CLI 进程退出: code=${code} signal=${signal}`));
                this._promptResolve = null;
                this._promptReject = null;
            }

            this.emit('disconnected', { code, signal });
        });

        this._child.on('error', (err) => {
            console.error('[ClaudeCliProtocol] 进程错误:', err.message);
            this.emit('error', err);
        });

        // 等待进程启动
        await new Promise((resolve, reject) => {
            this._child.on('error', reject);
            setTimeout(resolve, 500);
        });

        this.isConnected = true;
        console.log('[ClaudeCliProtocol] 进程已启动');
    }

    /**
     * Claude CLI 不需要 JSON-RPC initialize — no-op
     */
    async initialize() {
        console.log('[ClaudeCliProtocol] skip initialize (Claude CLI 自有协议)');
        return { protocolVersion: 1 };
    }

    /**
     * Claude CLI 不需要 session/new — no-op
     */
    async newSession(options = {}) {
        this._workingDir = options.cwd || this._workingDir || '.';
        this.sessionId = `claude-cli-${Date.now()}`;
        console.log('[ClaudeCliProtocol] newSession (virtual):', this.sessionId);
        return { sessionId: this.sessionId };
    }

    /**
     * 发送 prompt 到 Claude CLI
     *
     * 写入 stream-json 格式消息到 stdin:
     *   {"type":"user","message":{"role":"user","content":[{"type":"text","text":"..."}]}}
     *
     * 等待 type=result 消息后 resolve。
     *
     * @param {string} text - prompt 文本
     * @returns {Promise<void>}
     */
    sendPrompt(text) {
        return new Promise((resolve, reject) => {
            if (!this._child || !this.isConnected) {
                return reject(new Error('[ClaudeCliProtocol] 进程未启动'));
            }

            this._promptResolve = resolve;
            this._promptReject = reject;

            // Claude CLI stream-json 输入格式
            const message = {
                type: 'user',
                message: {
                    role: 'user',
                    content: [{ type: 'text', text }],
                },
            };

            const jsonLine = JSON.stringify(message) + '\n';
            console.log(`[ClaudeCliProtocol] 发送 prompt (${text.length} chars)`);

            try {
                this._child.stdin.write(jsonLine);
            } catch (e) {
                this._promptResolve = null;
                this._promptReject = null;
                reject(new Error(`[ClaudeCliProtocol] stdin 写入失败: ${e.message}`));
            }
        });
    }

    /**
     * 处理 Claude CLI stream-json 输出事件
     *
     * 事件类型:
     *   - {type:"system"} — 系统初始化
     *   - {type:"stream_event", event:{type:"content_block_delta", delta:{...}}} — 流式增量
     *   - {type:"assistant", message:{content:[{type:"text",text:"..."}]}} — 完整/部分消息
     *   - {type:"result", result:"...", stop_reason:"end_turn"} — 最终结果
     *
     * @private
     */
    _handleStreamEvent(event) {
        switch (event.type) {
            case 'system':
                this.sessionId = event.session_id || this.sessionId;
                this.emit('acp_event', {
                    type: 'notification',
                    method: 'system/init',
                    params: event,
                    timestamp: Date.now(),
                });
                break;

            case 'stream_event':
                this._handleInnerStreamEvent(event.event, event.session_id);
                break;

            case 'assistant':
                // 带 --include-partial-messages 时,每次 API 响应更新都发送完整 assistant 消息
                // 仅提取增量文本(由 stream_event 处理),这里用于工具检测
                if (event.message?.content) {
                    for (const block of event.message.content) {
                        if (block.type === 'text' && block.text) {
                            // assistant 消息的完整文本(包含已流式发送的内容)
                            // 不再 emit session_update 避免重复,stream_event 已处理增量
                        }
                    }
                }
                break;

            case 'result':
                this.sessionId = event.session_id || this.sessionId;
                this.emit('acp_event', {
                    type: 'notification',
                    method: 'session/result',
                    params: {
                        result: event.result,
                        duration_ms: event.duration_ms,
                        cost_usd: event.total_cost_usd,
                        session_id: event.session_id,
                        stop_reason: event.stop_reason,
                    },
                    timestamp: Date.now(),
                });

                if (this._promptResolve) {
                    this._promptResolve();
                    this._promptResolve = null;
                    this._promptReject = null;
                }
                break;

            default:
                this.emit('acp_event', {
                    type: 'notification',
                    method: `claude/${event.type}`,
                    params: event,
                    timestamp: Date.now(),
                });
        }
    }

    /**
     * 处理 stream_event 内层事件(Anthropic Streaming API 格式)
     *
     * event.type:
     *   - message_start — 消息开始
     *   - content_block_start — 内容块开始(text / thinking / tool_use)
     *   - content_block_delta — 增量内容
     *     - delta.type: "text_delta" → emit session_update
     *     - delta.type: "thinking_delta" → emit session_update (thought)
     *   - content_block_stop — 内容块结束
     *   - message_delta — 消息级更新(stop_reason 等)
     *   - message_stop — 消息结束
     *
     * @private
     */
    _handleInnerStreamEvent(innerEvent, sessionId) {
        if (!innerEvent) return;

        switch (innerEvent.type) {
            case 'content_block_delta':
                if (innerEvent.delta?.type === 'text_delta' && innerEvent.delta.text) {
                    this.emit('session_update', {
                        update: {
                            sessionUpdate: 'agent_message_chunk',
                            content: { text: innerEvent.delta.text },
                        },
                    });
                } else if (innerEvent.delta?.type === 'thinking_delta' && innerEvent.delta.thinking) {
                    this.emit('session_update', {
                        update: {
                            sessionUpdate: 'agent_thought_chunk',
                            content: { text: innerEvent.delta.thinking },
                        },
                    });
                }
                break;

            case 'content_block_start':
                // 可用于检测 tool_use 块开始
                if (innerEvent.content_block?.type === 'tool_use') {
                    this.emit('acp_event', {
                        type: 'notification',
                        method: 'claude/tool_use_start',
                        params: {
                            tool_use_id: innerEvent.content_block.id,
                            name: innerEvent.content_block.name,
                        },
                        timestamp: Date.now(),
                    });
                }
                break;

            case 'message_start':
            case 'content_block_stop':
            case 'message_delta':
            case 'message_stop':
                // 透传为 acp_event
                this.emit('acp_event', {
                    type: 'notification',
                    method: `claude/stream/${innerEvent.type}`,
                    params: innerEvent,
                    timestamp: Date.now(),
                });
                break;
        }
    }

    /**
     * 断开连接 — 终止 Claude CLI 进程
     */
    disconnect() {
        if (this._child) {
            try { this._child.stdin.end(); } catch { }
            try { this._child.kill('SIGTERM'); } catch { }
            this._child = null;
        }
        this.isConnected = false;
        this.sessionId = null;
    }
}

module.exports = { ClaudeCliProtocol };