From 6d5a473560c5763d64bb009a159d5a74a6e0992b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B1=E6=89=AC?= Date: Thu, 7 May 2026 17:34:00 +0800 Subject: [PATCH] fix: Windows compatibility - CLI resolution, streaming mode, remove unsupported flags - Fix resolveQoderCLI() to search for .exe/.cmd on Windows and check npm global package embedded binary path - Add wrapStringAsStream() to force streaming input mode (--input-format stream-json) instead of --print, avoiding Windows ENAMETOOLONG - Remove unsupported CLI flags from buildCommand(): --verbose, --storage-dir, --resource-dir, --max-turns, --max-budget-usd, --fallback-model, --betas, --resume-session-at, --include-partial-messages, --max-thinking-tokens, --json-schema - Add --mcp-config temp file fallback when JSON exceeds 8000 chars (Windows command line length limit) --- src/qoder-language-model.ts | 42 +++++++++++++++++++++++------- src/vendor/qoder-agent-sdk.mjs | 47 +++++++++------------------------- 2 files changed, 45 insertions(+), 44 deletions(-) diff --git a/src/qoder-language-model.ts b/src/qoder-language-model.ts index 4d04a04..c1028f6 100644 --- a/src/qoder-language-model.ts +++ b/src/qoder-language-model.ts @@ -102,21 +102,44 @@ function installSdkLogFilter(): void { // 模块加载即生效 installSdkLogFilter() +// ── 将纯文本 prompt 包装为 streaming 模式 ────────────────────────────────────── +// 避免 --print 参数过长导致 Windows ENAMETOOLONG(命令行限制 ~32767 字符) +function wrapStringAsStream(prompt: string, sessionId?: string): AsyncIterable<{ type: string; session_id: string; message: { role: string; content: Array<{ type: string; text: string }> }; parent_tool_use_id: null }> { + async function* toStream() { + yield { + type: 'user', + session_id: sessionId ?? 'default', + message: { role: 'user', content: [{ type: 'text', text: prompt }] }, + parent_tool_use_id: null as const, + } + } + return toStream() +} + // ── qodercli 二进制路径解析 ─────────────────────────────────────────────────── function resolveQoderCLI(): string | undefined { + const isWin = process.platform === 'win32' + // Windows 上优先搜 .exe,再 .cmd;Unix 搜无后缀 + const candidates = isWin ? ['qodercli.exe', 'qodercli.cmd', 'qodercli'] : ['qodercli'] // 1. 全局 PATH 里的 qodercli(用户自行安装,优先) const pathDirs = (process.env.PATH ?? '').split(path.delimiter) - for (const dir of pathDirs) { - const p = path.join(dir, 'qodercli') - if (fs.existsSync(p)) return p + for (const name of candidates) { + for (const dir of pathDirs) { + const p = path.join(dir, name) + if (fs.existsSync(p)) return p + } } - - // 2. SDK 默认本地安装路径:~/.qoder/local/qodercli + // 2. npm 全局包内嵌的真实二进制(Windows 特有) + if (isWin) { + const npmPrefix = path.join(os.homedir(), 'AppData', 'Roaming', 'npm') + const embedded = path.join(npmPrefix, 'node_modules', '@qoder-ai', 'qodercli', 'bin', 'qodercli.exe') + if (fs.existsSync(embedded)) return embedded + } + // 3. SDK 默认本地安装路径:~/.qoder/local/qodercli const localCli = path.join(os.homedir(), '.qoder', 'local', 'qodercli') if (fs.existsSync(localCli)) return localCli - - // 3. 回退:~/.qoder/bin/qodercli/qodercli-(取最新版本) + // 4. 回退:~/.qoder/bin/qodercli/qodercli-(取最新版本) const binDir = path.join(os.homedir(), '.qoder', 'bin', 'qodercli') if (fs.existsSync(binDir)) { try { @@ -603,8 +626,9 @@ export class QoderLanguageModel implements LanguageModelV2 { let suppressFurtherAssistantContent = false try { - // query() 是单次查询的最优路径(QoderAgentSDKClient 是双向交互会话,每次 connect() 冷启动更慢) - const qoderQuery = query({ prompt, options: { ...qoderOptions, abortController } }) + // 强制 streaming 模式:纯文本 prompt 通过 stdin 传递,避免 --print 参数过长导致 Windows ENAMETOOLONG + const streamPrompt = typeof prompt === 'string' ? wrapStringAsStream(prompt, qoderOptions.sessionId) : prompt + const qoderQuery = query({ prompt: streamPrompt, options: { ...qoderOptions, abortController } }) qoderQueryRef = qoderQuery let sdkMsgCount = 0 for await (const msg of qoderQuery) { diff --git a/src/vendor/qoder-agent-sdk.mjs b/src/vendor/qoder-agent-sdk.mjs index 73431f6..25eec2f 100644 --- a/src/vendor/qoder-agent-sdk.mjs +++ b/src/vendor/qoder-agent-sdk.mjs @@ -560,16 +560,9 @@ Or provide the path via options: * Build CLI command with arguments */ buildCommand() { - const cmd = [this.cliPath, "--output-format", "stream-json", "--verbose"]; + const cmd = [this.cliPath, "--output-format", "stream-json"]; const config = getConfig(); - const storageDir = this.options.storageDir ?? config.storageDir; - if (storageDir) { - cmd.push("--storage-dir", storageDir); - } - const resourceDir = this.options.resourceDir ?? config.resourceDir; - if (resourceDir) { - cmd.push("--resource-dir", resourceDir); - } + // --storage-dir, --resource-dir, --verbose not supported by qodercli; removed for compatibility if (this.options.tools !== void 0) { if (Array.isArray(this.options.tools)) { if (this.options.tools.length === 0) { @@ -584,24 +577,12 @@ Or provide the path via options: if (this.options.allowedTools && this.options.allowedTools.length > 0) { cmd.push("--allowed-tools", this.options.allowedTools.join(",")); } - if (this.options.maxTurns !== void 0) { - cmd.push("--max-turns", String(this.options.maxTurns)); - } - if (this.options.maxBudgetUsd !== void 0) { - cmd.push("--max-budget-usd", String(this.options.maxBudgetUsd)); - } if (this.options.disallowedTools && this.options.disallowedTools.length > 0) { cmd.push("--disallowed-tools", this.options.disallowedTools.join(",")); } if (this.options.model) { cmd.push("--model", this.options.model); } - if (this.options.fallbackModel) { - cmd.push("--fallback-model", this.options.fallbackModel); - } - if (this.options.betas && this.options.betas.length > 0) { - cmd.push("--betas", this.options.betas.join(",")); - } if (this.options.permissionMode && this.options.permissionMode === "bypassPermissions") { cmd.push("--yolo"); } @@ -611,9 +592,6 @@ Or provide the path via options: if (this.options.resume) { cmd.push("--resume", this.options.resume); } - if (this.options.resumeSessionAt) { - cmd.push("--resume-session-at", this.options.resumeSessionAt); - } if (this.options.sessionId && !this.options.resume) { cmd.push("--session-id", this.options.sessionId); } @@ -640,11 +618,16 @@ Or provide the path via options: } } if (Object.keys(serversForCli).length > 0) { - cmd.push("--mcp-config", JSON.stringify({ mcpServers: serversForCli })); - } - } - if (this.options.includePartialMessages) { - cmd.push("--include-partial-messages"); + const mcpJson = JSON.stringify({ mcpServers: serversForCli }); + // Windows 命令行限制 ~32767 字符,超长时写入临时文件 + if (mcpJson.length > 8000) { + const tmpFile = path2.join(os.tmpdir(), `qoder-mcp-${Date.now()}.json`); + fs.writeFileSync(tmpFile, mcpJson, 'utf-8'); + this.tempFiles.push(tmpFile); + cmd.push("--mcp-config", tmpFile); + } else { + cmd.push("--mcp-config", mcpJson); + } } if (this.options.forkSession) { cmd.push("--fork-session"); @@ -682,12 +665,6 @@ Or provide the path via options: } } } - if (this.options.maxThinkingTokens !== void 0) { - cmd.push("--max-thinking-tokens", String(this.options.maxThinkingTokens)); - } - if (this.options.outputFormat && this.options.outputFormat.type === "json_schema") { - cmd.push("--json-schema", JSON.stringify(this.options.outputFormat.schema)); - } if (this.usePreparedNonStreaming) { for (const attachment of this.preparedAttachments) { cmd.push("--attachment", attachment);