chatccc 0.2.103 → 0.2.107

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.
@@ -1,372 +1,455 @@
1
- // =============================================================================
2
- // claude-adapter.ts — Claude Agent SDK 适配器
3
- // =============================================================================
4
- // 实现 ToolAdapter 接口,将 @anthropic-ai/claude-agent-sdk 调用封装在内。
5
- // =============================================================================
6
-
7
- import {
8
- unstable_v2_createSession,
9
- unstable_v2_resumeSession,
10
- getSessionInfo as sdkGetSessionInfo,
11
- } from "@anthropic-ai/claude-agent-sdk";
12
- import { readdirSync, existsSync } from "node:fs";
13
- import { join } from "node:path";
14
- import { homedir } from "node:os";
15
-
16
- import type {
17
- ToolAdapter,
18
- UnifiedBlock,
19
- UnifiedStreamMessage,
20
- CreateSessionResult,
21
- SessionInfo,
22
- } from "./adapter-interface.ts";
23
-
24
- // ---------------------------------------------------------------------------
25
- // 类型别名:SDK 内部消息的形状(避免导入 sdk.d.ts 的大型联合类型)
26
- // ---------------------------------------------------------------------------
27
-
28
- interface SdkContentBlock {
29
- type: string;
30
- text?: string;
31
- thinking?: string;
32
- name?: string;
33
- input?: unknown;
34
- tool_use_id?: string;
35
- content?: unknown;
36
- is_error?: boolean;
37
- query?: string;
38
- [key: string]: unknown;
39
- }
40
-
41
- interface SdkMessageLike {
42
- type?: string;
43
- subtype?: string;
44
- message?: { content?: SdkContentBlock[] };
45
- compact_metadata?: {
46
- trigger?: "manual" | "auto";
47
- pre_tokens?: number;
48
- post_tokens?: number;
49
- };
50
- session_id?: string;
51
- }
52
-
53
- // ---------------------------------------------------------------------------
54
- // ClaudeAdapterOptions
55
- // ---------------------------------------------------------------------------
56
-
57
- export interface ClaudeAdapterOptions {
58
- model: string;
59
- subagentModel?: string;
60
- effort: string;
61
- /** 判断字段是否为"不传给 SDK"的占位(项目约定:空字符串/全空白) */
62
- isEmpty: (value: string) => boolean;
63
- /**
64
- * Anthropic 兼容网关的 API key。
65
- * 非空(trim 后)时会被注入到 SDK 子进程的 ANTHROPIC_API_KEY 环境变量;
66
- * 留空 / 全空白 → 不覆盖,沿用主进程 process.env / 系统环境变量。
67
- * 永远不会写入主进程的 process.env,避免污染其他依赖 env 的代码。
68
- */
69
- apiKey?: string;
70
- /**
71
- * Anthropic 兼容网关的 base URL。
72
- * 非空(trim 后)时会被注入到 SDK 子进程的 ANTHROPIC_BASE_URL 环境变量;
73
- * 留空 / 全空白 → 不覆盖,沿用主进程 process.env / 系统环境变量。
74
- */
75
- baseUrl?: string;
76
- }
77
-
78
- // ---------------------------------------------------------------------------
79
- // buildSdkEnv — 为 SDK 子进程构造 env
80
- // ---------------------------------------------------------------------------
81
- // 行为契约(详见单测 "createClaudeAdapter — env 注入"):
82
- // - apiKey baseUrl 都为空(trim 后)→ 返回 undefined,让 SDK 走默认行为
83
- // (即 process.env),避免无意义的拷贝。
84
- // - 任一非空 返回 process.env 的浅拷贝,并按需覆盖 ANTHROPIC_API_KEY /
85
- // ANTHROPIC_BASE_URL;其余 env 字段保持不变(PATH、HOME 等子进程必需)。
86
- // - 主进程 process.env 永不被写入,主进程其他模块对 env 的读取不受影响。
87
- function buildSdkEnv(
88
- apiKey: string | undefined,
89
- baseUrl: string | undefined,
90
- subagentModel: string | undefined,
91
- ): Record<string, string | undefined> | undefined {
92
- const apiKeyTrim = (apiKey ?? "").trim();
93
- const baseUrlTrim = (baseUrl ?? "").trim();
94
- const subagentModelTrim = (subagentModel ?? "").trim();
95
- const hasApiOverride = Boolean(apiKeyTrim || baseUrlTrim);
96
- if (!hasApiOverride) return undefined;
97
-
98
- const env: Record<string, string | undefined> = { ...process.env };
99
- // ChatCCC's third-party Claude API config is authoritative when present.
100
- // Remove Claude Code/user settings env that can silently override gateway/auth/model choice.
101
- delete env.ANTHROPIC_AUTH_TOKEN;
102
- delete env.CLAUDE_CODE_OAUTH_TOKEN;
103
- delete env.ANTHROPIC_MODEL;
104
- delete env.ANTHROPIC_DEFAULT_OPUS_MODEL;
105
- delete env.ANTHROPIC_DEFAULT_SONNET_MODEL;
106
- delete env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
107
- delete env.CLAUDE_CODE_SUBAGENT_MODEL;
108
- delete env.CLAUDE_CODE_EFFORT_LEVEL;
109
-
110
- if (apiKeyTrim) env.ANTHROPIC_API_KEY = apiKeyTrim;
111
- if (baseUrlTrim) {
112
- env.ANTHROPIC_BASE_URL = baseUrlTrim;
113
- } else {
114
- delete env.ANTHROPIC_BASE_URL;
115
- }
116
- if (subagentModelTrim) env.CLAUDE_CODE_SUBAGENT_MODEL = subagentModelTrim;
117
- return env;
118
- }
119
-
120
- function resolveSettingSources(
121
- _apiKey: string | undefined,
122
- _baseUrl: string | undefined,
123
- ): Array<"user" | "project" | "local"> {
124
- // CLAUDE.md / CLAUDE.local.md 是 Agent 指令文件,与 API 来源无关,
125
- // 无论使用官方 Anthropic 还是第三方网关都应加载。
126
- // 包含 "user" 以使 ~/.claude/settings.json 中的配置(如 mcpServers)生效;
127
- // buildSdkEnv() 会删除可能冲突的 env 变量,确保网关配置不被覆盖。
128
- return ["user", "project", "local"];
129
- }
130
-
131
- // ---------------------------------------------------------------------------
132
- // collectSkillNames 扫描用户级 skill 目录,返回所有 skill
133
- // ---------------------------------------------------------------------------
134
-
135
- function collectSkillNames(): string[] {
136
- const userSkillsDir = join(homedir(), ".claude", "skills");
137
- if (!existsSync(userSkillsDir)) return [];
138
- try {
139
- return readdirSync(userSkillsDir, { withFileTypes: true })
140
- .filter((d) => d.isDirectory())
141
- .map((d) => d.name);
142
- } catch {
143
- return [];
144
- }
145
- }
146
-
147
- // ---------------------------------------------------------------------------
148
- // buildSessionOptions — 还原 claudeSdkSessionOptions 的精确行为
149
- // ---------------------------------------------------------------------------
150
-
151
- function buildSessionOptions(
152
- cwd: string,
153
- model: string,
154
- effort: string,
155
- isEmpty: (value: string) => boolean,
156
- apiKey: string | undefined,
157
- baseUrl: string | undefined,
158
- subagentModel: string | undefined,
159
- ): Record<string, unknown> {
160
- const o: Record<string, unknown> = {
161
- cwd,
162
- permissionMode: "bypassPermissions",
163
- allowDangerouslySkipPermissions: true,
164
- autoCompactEnabled: true,
165
- settingSources: resolveSettingSources(apiKey, baseUrl),
166
- skills: collectSkillNames(),
167
- };
168
- if (!isEmpty(model)) o.model = model;
169
- if (!isEmpty(effort)) o.effort = effort;
170
- const env = buildSdkEnv(apiKey, baseUrl, subagentModel);
171
- if (env) o.env = env;
172
- return o;
173
- }
174
-
175
- // ---------------------------------------------------------------------------
176
- // normalizeSdkMessage — 关键映射:SDK 消息 → UnifiedStreamMessage | null
177
- // ---------------------------------------------------------------------------
178
-
179
- export function normalizeSdkMessage(msg: SdkMessageLike): UnifiedStreamMessage | null {
180
- // 1) assistant / user 消息:遍历 content 块
181
- if (
182
- (msg.type === "assistant" || msg.type === "user") &&
183
- msg.message?.content
184
- ) {
185
- const blocks: UnifiedBlock[] = [];
186
- for (const block of msg.message.content) {
187
- if (block.type === "thinking" && block.thinking) {
188
- blocks.push({ type: "thinking", thinking: block.thinking });
189
- } else if (block.type === "tool_use") {
190
- blocks.push({
191
- type: "tool_use",
192
- id: (block as { id?: string }).id,
193
- name: block.name ?? "unknown",
194
- input: block.input,
195
- });
196
- } else if (block.type === "tool_result") {
197
- blocks.push({
198
- type: "tool_result",
199
- tool_use_id: block.tool_use_id ?? "",
200
- content: block.content,
201
- is_error: block.is_error,
202
- });
203
- } else if (block.type === "redacted_thinking") {
204
- blocks.push({ type: "redacted_thinking" });
205
- } else if (block.type === "search_result") {
206
- blocks.push({
207
- type: "search_result",
208
- query: block.query ?? "",
209
- });
210
- } else if (block.type === "text" && block.text) {
211
- blocks.push({ type: "text", text: block.text });
212
- }
213
- }
214
- return { type: msg.type, blocks };
215
- }
216
-
217
- // 2) system / compact_boundary 消息:上下文压缩事件
218
- if (msg.type === "system" && msg.subtype === "compact_boundary") {
219
- const meta = msg.compact_metadata;
220
- if (!meta) return null;
221
- return {
222
- type: "system",
223
- blocks: [
224
- {
225
- type: "compact_boundary",
226
- trigger: meta.trigger ?? "auto",
227
- pre_tokens: meta.pre_tokens ?? 0,
228
- post_tokens: meta.post_tokens,
229
- },
230
- ],
231
- };
232
- }
233
-
234
- // 3) 其他消息类型:跳过
235
- return null;
236
- }
237
-
238
- // ---------------------------------------------------------------------------
239
- // 适配器实现(私有类,仅通过工厂函数暴露)
240
- // ---------------------------------------------------------------------------
241
-
242
- class ClaudeAdapter implements ToolAdapter {
243
- readonly displayName = "Claude Code";
244
- readonly sessionDescPrefix = "Claude Code Session:";
245
- private model: string;
246
- private effort: string;
247
- private subagentModel: string | undefined;
248
- private isEmpty: (value: string) => boolean;
249
- private apiKey: string | undefined;
250
- private baseUrl: string | undefined;
251
-
252
- constructor(options: ClaudeAdapterOptions) {
253
- this.model = options.model;
254
- this.effort = options.effort;
255
- this.subagentModel = options.subagentModel;
256
- this.isEmpty = options.isEmpty;
257
- this.apiKey = options.apiKey;
258
- this.baseUrl = options.baseUrl;
259
- }
260
-
261
- async createSession(cwd: string): Promise<CreateSessionResult> {
262
- const sessionOpts = buildSessionOptions(
263
- cwd,
264
- this.model,
265
- this.effort,
266
- this.isEmpty,
267
- this.apiKey,
268
- this.baseUrl,
269
- this.subagentModel,
270
- );
271
- const session = unstable_v2_createSession(sessionOpts as any);
272
-
273
- await session.send("ok");
274
-
275
- const stream = session.stream();
276
- const first = await stream.next();
277
-
278
- if (first.done || !(first.value as SdkMessageLike)?.session_id) {
279
- session.close();
280
- throw new Error("No session ID in Claude init event");
281
- }
282
-
283
- const sessionId = (first.value as SdkMessageLike).session_id!;
284
-
285
- // 后台消费剩余的 stream(必须:否则 SDK 内部缓冲可能阻塞)
286
- (async () => {
287
- try {
288
- for await (const _msg of stream) {
289
- // 静默消费
290
- }
291
- } catch {
292
- // stream 异常不阻塞主流程
293
- } finally {
294
- session.close();
295
- }
296
- })();
297
-
298
- return { sessionId };
299
- }
300
-
301
- async *prompt(
302
- sessionId: string,
303
- userText: string,
304
- cwd: string,
305
- signal?: AbortSignal,
306
- ): AsyncIterable<UnifiedStreamMessage> {
307
- const sessionOpts = buildSessionOptions(
308
- cwd,
309
- this.model,
310
- this.effort,
311
- this.isEmpty,
312
- this.apiKey,
313
- this.baseUrl,
314
- this.subagentModel,
315
- );
316
- const session = unstable_v2_resumeSession(
317
- sessionId,
318
- sessionOpts as any,
319
- );
320
-
321
- if (signal?.aborted) {
322
- session.close();
323
- return;
324
- }
325
- const onAbort = () => { session.close(); };
326
- signal?.addEventListener("abort", onAbort, { once: true });
327
-
328
- await session.send(userText);
329
-
330
- const stream = session.stream();
331
-
332
- try {
333
- for await (const msg of stream) {
334
- if (signal?.aborted) break;
335
- const normalized = normalizeSdkMessage(
336
- msg as unknown as SdkMessageLike,
337
- );
338
- if (normalized) yield normalized;
339
- }
340
- } finally {
341
- signal?.removeEventListener("abort", onAbort);
342
- session.close();
343
- }
344
- }
345
-
346
- async getSessionInfo(
347
- sessionId: string,
348
- ): Promise<SessionInfo | undefined> {
349
- const info = await sdkGetSessionInfo(sessionId);
350
- if (!info) return undefined;
351
- return {
352
- sessionId: info.sessionId,
353
- cwd: info.cwd,
354
- summary: info.summary,
355
- lastModified: info.lastModified,
356
- };
357
- }
358
-
359
- async closeSession(_sessionId: string): Promise<void> {
360
- // Claude SDK 在 stream 结束后自动关闭 session,此处为 no-op
361
- }
362
- }
363
-
364
- // ---------------------------------------------------------------------------
365
- // 工厂函数
366
- // ---------------------------------------------------------------------------
367
-
368
- export function createClaudeAdapter(
369
- options: ClaudeAdapterOptions,
370
- ): ToolAdapter {
371
- return new ClaudeAdapter(options);
372
- }
1
+ // =============================================================================
2
+ // claude-adapter.ts — Claude CLI 适配器
3
+ // =============================================================================
4
+ // 通过直接 spawn claude.exe(不再使用 @anthropic-ai/claude-agent-sdk JS API)
5
+ // 以确保 --mcp-config 参数能正确传递给 CLI 子进程,加载用户 MCP 服务器。
6
+ // =============================================================================
7
+
8
+ import { spawn, type ChildProcess } from "node:child_process";
9
+ import { createInterface } from "node:readline";
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { homedir } from "node:os";
13
+
14
+ import type {
15
+ ToolAdapter,
16
+ UnifiedBlock,
17
+ UnifiedStreamMessage,
18
+ CreateSessionResult,
19
+ SessionInfo,
20
+ ToolPromptOptions,
21
+ } from "./adapter-interface.ts";
22
+ import { PROJECT_ROOT } from "../config.ts";
23
+ import { killProcessTree } from "./proc-tree-kill.ts";
24
+ import {
25
+ defaultClaudeSessionMetaStore,
26
+ type ClaudeSessionMetaStore,
27
+ } from "./claude-session-meta-store.ts";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // 常量
31
+ // ---------------------------------------------------------------------------
32
+
33
+ const CLAUDE_EXE = join(
34
+ PROJECT_ROOT,
35
+ "node_modules",
36
+ "@anthropic-ai",
37
+ "claude-agent-sdk-win32-x64",
38
+ "claude.exe",
39
+ );
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // 类型别名(CLI JSONL 消息格式,与旧 SDK 消息格式兼容)
43
+ // ---------------------------------------------------------------------------
44
+
45
+ interface SdkContentBlock {
46
+ type: string;
47
+ text?: string;
48
+ thinking?: string;
49
+ name?: string;
50
+ input?: unknown;
51
+ tool_use_id?: string;
52
+ content?: unknown;
53
+ is_error?: boolean;
54
+ query?: string;
55
+ [key: string]: unknown;
56
+ }
57
+
58
+ interface SdkMessageLike {
59
+ type?: string;
60
+ subtype?: string;
61
+ message?: { content?: SdkContentBlock[] };
62
+ compact_metadata?: {
63
+ trigger?: "manual" | "auto";
64
+ pre_tokens?: number;
65
+ post_tokens?: number;
66
+ };
67
+ session_id?: string;
68
+ model?: string;
69
+ cwd?: string;
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // ClaudeAdapterOptions
74
+ // ---------------------------------------------------------------------------
75
+
76
+ export interface ClaudeAdapterOptions {
77
+ model: string;
78
+ subagentModel?: string;
79
+ effort: string;
80
+ /** Anthropic API Key(选填,留空则不注入环境变量) */
81
+ apiKey?: string;
82
+ /** Anthropic 兼容 API Base URL(选填,留空则不注入环境变量) */
83
+ baseUrl?: string;
84
+ /** 判断字段是否为"不传给 CLI"的占位(空字符串/全空白) */
85
+ isEmpty: (value: string) => boolean;
86
+ /** 注入自定义 meta 持久化 store(测试用);未提供时使用全局默认实例。 */
87
+ metaStore?: ClaudeSessionMetaStore;
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // buildCliEnv CLI 子进程构造 env(仅子进程级别,不污染主进程)
92
+ // ---------------------------------------------------------------------------
93
+
94
+ function buildCliEnv(
95
+ subagentModel: string | undefined,
96
+ apiKey: string | undefined,
97
+ baseUrl: string | undefined,
98
+ ): Record<string, string | undefined> | undefined {
99
+ const subagentModelTrim = (subagentModel ?? "").trim();
100
+ const apiKeyTrim = (apiKey ?? "").trim();
101
+ const baseUrlTrim = (baseUrl ?? "").trim();
102
+
103
+ if (!subagentModelTrim && !apiKeyTrim && !baseUrlTrim) return undefined;
104
+
105
+ const env: Record<string, string | undefined> = { ...process.env };
106
+ delete env.ANTHROPIC_AUTH_TOKEN;
107
+ delete env.CLAUDE_CODE_OAUTH_TOKEN;
108
+ delete env.ANTHROPIC_MODEL;
109
+ delete env.ANTHROPIC_DEFAULT_OPUS_MODEL;
110
+ delete env.ANTHROPIC_DEFAULT_SONNET_MODEL;
111
+ delete env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
112
+ delete env.CLAUDE_CODE_EFFORT_LEVEL;
113
+
114
+ if (subagentModelTrim) env.CLAUDE_CODE_SUBAGENT_MODEL = subagentModelTrim;
115
+ if (apiKeyTrim) env.ANTHROPIC_API_KEY = apiKeyTrim;
116
+ if (baseUrlTrim) env.ANTHROPIC_BASE_URL = baseUrlTrim;
117
+
118
+ return env;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // MCP 配置读取
123
+ // ---------------------------------------------------------------------------
124
+
125
+ function readMcpConfigJson(): string | null {
126
+ const settingsPath = join(homedir(), ".claude", "settings.json");
127
+ try {
128
+ if (!existsSync(settingsPath)) return null;
129
+ const raw = readFileSync(settingsPath, "utf-8");
130
+ const settings = JSON.parse(raw);
131
+ const mcpServers = settings?.mcpServers;
132
+ if (!mcpServers || Object.keys(mcpServers).length === 0) return null;
133
+ // CLI 期望 {"mcpServers": {...}} 格式,而非裸 mcpServers 值
134
+ return JSON.stringify({ mcpServers });
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // 诊断日志
142
+ // ---------------------------------------------------------------------------
143
+
144
+ function logMcpConfig(): void {
145
+ const settingsPath = join(homedir(), ".claude", "settings.json");
146
+ const ts = new Date().toISOString();
147
+ try {
148
+ if (!existsSync(settingsPath)) {
149
+ console.log(`[${ts}] [MCP-DIAG] settings.json not found at ${settingsPath}`);
150
+ return;
151
+ }
152
+ const raw = readFileSync(settingsPath, "utf-8");
153
+ const settings = JSON.parse(raw);
154
+ const mcpServers = settings?.mcpServers;
155
+ if (!mcpServers || Object.keys(mcpServers).length === 0) {
156
+ console.log(`[${ts}] [MCP-DIAG] No mcpServers configured in settings.json`);
157
+ return;
158
+ }
159
+ console.log(`[${ts}] [MCP-DIAG] mcpServers found: ${JSON.stringify(Object.keys(mcpServers))}`);
160
+ for (const [name, cfg] of Object.entries(mcpServers) as [string, { type?: string; command?: string; args?: string[] }][]) {
161
+ console.log(`[${ts}] [MCP-DIAG] ${name}: type=${cfg.type}, command=${cfg.command}, args=${JSON.stringify(cfg.args)}`);
162
+ }
163
+ } catch (err) {
164
+ console.log(`[${ts}] [MCP-DIAG] Failed to read settings.json: ${(err as Error).message}`);
165
+ }
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // normalizeSdkMessage CLI JSONL 消息 → UnifiedStreamMessage | null
170
+ // (CLI SDK 使用相同 JSONL 格式,导出名保留以兼容现有测试)
171
+ // ---------------------------------------------------------------------------
172
+
173
+ export function normalizeSdkMessage(msg: SdkMessageLike): UnifiedStreamMessage | null {
174
+ if (
175
+ (msg.type === "assistant" || msg.type === "user") &&
176
+ msg.message?.content
177
+ ) {
178
+ const blocks: UnifiedBlock[] = [];
179
+ for (const block of msg.message.content) {
180
+ if (block.type === "thinking" && block.thinking) {
181
+ blocks.push({ type: "thinking", thinking: block.thinking });
182
+ } else if (block.type === "tool_use") {
183
+ blocks.push({
184
+ type: "tool_use",
185
+ id: (block as { id?: string }).id,
186
+ name: block.name ?? "unknown",
187
+ input: block.input,
188
+ });
189
+ } else if (block.type === "tool_result") {
190
+ blocks.push({
191
+ type: "tool_result",
192
+ tool_use_id: block.tool_use_id ?? "",
193
+ content: block.content,
194
+ is_error: block.is_error,
195
+ });
196
+ } else if (block.type === "redacted_thinking") {
197
+ blocks.push({ type: "redacted_thinking" });
198
+ } else if (block.type === "search_result") {
199
+ blocks.push({
200
+ type: "search_result",
201
+ query: block.query ?? "",
202
+ });
203
+ } else if (block.type === "text" && block.text) {
204
+ // 跳过 user 消息中的 text block:--replay-user-messages 会重放
205
+ // 之前的用户消息(含内嵌的 IM skill prompt),这些不应出现在最终回复中
206
+ if (msg.type === "user") continue;
207
+ blocks.push({ type: "text", text: block.text });
208
+ }
209
+ }
210
+ return { type: msg.type, blocks };
211
+ }
212
+
213
+ if (msg.type === "system" && msg.subtype === "compact_boundary") {
214
+ const meta = msg.compact_metadata;
215
+ if (!meta) return null;
216
+ return {
217
+ type: "system",
218
+ blocks: [
219
+ {
220
+ type: "compact_boundary",
221
+ trigger: meta.trigger ?? "auto",
222
+ pre_tokens: meta.pre_tokens ?? 0,
223
+ post_tokens: meta.post_tokens,
224
+ },
225
+ ],
226
+ };
227
+ }
228
+
229
+ return null;
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // CLI spawn helpers
234
+ // ---------------------------------------------------------------------------
235
+
236
+ function buildCliArgs(
237
+ model: string,
238
+ effort: string,
239
+ isEmpty: (value: string) => boolean,
240
+ mcpConfigJson: string | null,
241
+ extraArgs: string[],
242
+ ): string[] {
243
+ const args = [
244
+ "-p",
245
+ "--output-format", "stream-json",
246
+ "--verbose",
247
+ "--setting-sources", "user,project,local",
248
+ "--permission-mode", "bypassPermissions",
249
+ "--dangerously-skip-permissions",
250
+ "--settings", "{\"maxTurns\":0}",
251
+ ];
252
+
253
+ if (!isEmpty(model)) args.push("--model", model);
254
+ if (!isEmpty(effort)) args.push("--effort", effort);
255
+
256
+ // extraArgs(如 prompt "ok" 或 --resume <id>)必须在 --mcp-config 之前,
257
+ // 因为 --mcp-config 接受多个空格分隔的值,会把后续非 flag 参数都当作 MCP 配置
258
+ args.push(...extraArgs);
259
+
260
+ if (mcpConfigJson) args.push("--mcp-config", mcpConfigJson);
261
+
262
+ const ts = new Date().toISOString();
263
+ const safeArgs = args.filter(a => a !== mcpConfigJson);
264
+ console.log(`[${ts}] [CLAUDE-CLI] spawn: ${CLAUDE_EXE} ${safeArgs.join(" ")}`);
265
+ if (mcpConfigJson) {
266
+ console.log(`[${ts}] [CLAUDE-CLI] --mcp-config: ${mcpConfigJson}`);
267
+ }
268
+
269
+ return args;
270
+ }
271
+
272
+ function spawnCli(
273
+ args: string[],
274
+ cwd: string,
275
+ env: Record<string, string | undefined> | undefined,
276
+ pipeStdin: boolean,
277
+ ): ChildProcess {
278
+ const proc = spawn(CLAUDE_EXE, args, {
279
+ cwd,
280
+ stdio: [pipeStdin ? "pipe" : "ignore", "pipe", "pipe"],
281
+ windowsHide: true,
282
+ env: env ?? process.env,
283
+ });
284
+
285
+ const ts = new Date().toISOString();
286
+ console.log(`[${ts}] [CLAUDE-CLI] spawned, pid=${proc.pid}`);
287
+
288
+ let stderr = "";
289
+ proc.stderr!.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
290
+ proc.on("close", (code) => {
291
+ if (stderr.trim()) {
292
+ const ts2 = new Date().toISOString();
293
+ console.log(`[${ts2}] [CLAUDE-STDERR] exit=${code}: ${stderr.trim().slice(0, 2000)}`);
294
+ }
295
+ });
296
+
297
+ return proc;
298
+ }
299
+
300
+ async function* readJsonLines(
301
+ proc: ChildProcess,
302
+ signal?: AbortSignal,
303
+ ): AsyncGenerator<SdkMessageLike> {
304
+ const rl = createInterface({ input: proc.stdout!, crlfDelay: Infinity });
305
+ const onAbort = () => { rl.close(); };
306
+ signal?.addEventListener("abort", onAbort, { once: true });
307
+ let lineCount = 0;
308
+ try {
309
+ for await (const line of rl) {
310
+ if (signal?.aborted) break;
311
+ lineCount++;
312
+ const trimmed = line.trim();
313
+ if (!trimmed) continue;
314
+ try {
315
+ yield JSON.parse(trimmed) as SdkMessageLike;
316
+ } catch { /* 非 JSON 行静默跳过 */ }
317
+ }
318
+ } finally {
319
+ signal?.removeEventListener("abort", onAbort);
320
+ rl.close();
321
+ const ts = new Date().toISOString();
322
+ console.log(`[${ts}] [CLAUDE-CLI] readJsonLines done: ${lineCount} raw lines`);
323
+ }
324
+ }
325
+
326
+ /** 构造 stream-json 格式的 stdin 输入行 */
327
+ function buildStreamJsonInput(text: string): string {
328
+ return JSON.stringify({
329
+ type: "user",
330
+ message: {
331
+ role: "user",
332
+ content: [{ type: "text", text }],
333
+ },
334
+ }) + "\n";
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // ClaudeAdapter
339
+ // ---------------------------------------------------------------------------
340
+
341
+ class ClaudeAdapter implements ToolAdapter {
342
+ readonly displayName = "Claude Code";
343
+ readonly sessionDescPrefix = "Claude Code Session:";
344
+ private model: string;
345
+ private effort: string;
346
+ private subagentModel: string | undefined;
347
+ private apiKey: string | undefined;
348
+ private baseUrl: string | undefined;
349
+ private isEmpty: (value: string) => boolean;
350
+ private metaStore: ClaudeSessionMetaStore;
351
+
352
+ constructor(options: ClaudeAdapterOptions) {
353
+ this.model = options.model;
354
+ this.effort = options.effort;
355
+ this.subagentModel = options.subagentModel;
356
+ this.apiKey = options.apiKey;
357
+ this.baseUrl = options.baseUrl;
358
+ this.isEmpty = options.isEmpty;
359
+ this.metaStore = options.metaStore ?? defaultClaudeSessionMetaStore;
360
+ }
361
+
362
+ async createSession(cwd: string): Promise<CreateSessionResult> {
363
+ logMcpConfig();
364
+ const mcpConfigJson = readMcpConfigJson();
365
+ const env = buildCliEnv(this.subagentModel, this.apiKey, this.baseUrl);
366
+ const args = buildCliArgs(
367
+ this.model, this.effort, this.isEmpty, mcpConfigJson,
368
+ ["ok"],
369
+ );
370
+
371
+ const proc = spawnCli(args, cwd, env, false);
372
+
373
+ for await (const msg of readJsonLines(proc)) {
374
+ if (msg.session_id) {
375
+ const sessionId = msg.session_id;
376
+ await this.metaStore.set(sessionId, { cwd }).catch(() => {});
377
+ await killProcessTree(proc.pid);
378
+ const ts = new Date().toISOString();
379
+ console.log(`[${ts}] [CLAUDE-CLI] createSession: ${sessionId}`);
380
+ return { sessionId };
381
+ }
382
+ }
383
+
384
+ await killProcessTree(proc.pid);
385
+ throw new Error("No session ID in Claude init event");
386
+ }
387
+
388
+ async *prompt(
389
+ sessionId: string,
390
+ userText: string,
391
+ cwd: string,
392
+ signal?: AbortSignal,
393
+ options?: ToolPromptOptions,
394
+ ): AsyncIterable<UnifiedStreamMessage> {
395
+ const mcpConfigJson = readMcpConfigJson();
396
+ const env = buildCliEnv(this.subagentModel, this.apiKey, this.baseUrl);
397
+ const args = buildCliArgs(
398
+ this.model, this.effort, this.isEmpty, mcpConfigJson,
399
+ ["--resume", sessionId, "--input-format", "stream-json", "--replay-user-messages"],
400
+ );
401
+
402
+ const proc = spawnCli(args, cwd, env, true);
403
+ if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
404
+
405
+ const onAbort = () => { void killProcessTree(proc.pid); };
406
+ signal?.addEventListener("abort", onAbort, { once: true });
407
+
408
+ proc.stdin!.write(buildStreamJsonInput(userText));
409
+ proc.stdin!.end();
410
+
411
+ try {
412
+ for await (const raw of readJsonLines(proc, signal)) {
413
+ if (signal?.aborted) break;
414
+
415
+ if (raw.type === "system" && raw.subtype === "init" && raw.session_id) {
416
+ const meta: { cwd?: string; model?: string } = {};
417
+ if (raw.cwd) meta.cwd = raw.cwd;
418
+ if (raw.model) meta.model = raw.model;
419
+ if (Object.keys(meta).length > 0) {
420
+ this.metaStore.set(raw.session_id, meta).catch(() => {});
421
+ }
422
+ }
423
+
424
+ const normalized = normalizeSdkMessage(raw);
425
+ if (normalized) yield normalized;
426
+ }
427
+ } finally {
428
+ signal?.removeEventListener("abort", onAbort);
429
+ await killProcessTree(proc.pid);
430
+ if (proc.pid !== undefined) options?.onProcessExit?.({ pid: proc.pid });
431
+ }
432
+ }
433
+
434
+ async getSessionInfo(sessionId: string): Promise<SessionInfo | undefined> {
435
+ const meta = await this.metaStore.get(sessionId);
436
+ if (!meta) return { sessionId };
437
+ return meta.model
438
+ ? { sessionId, cwd: meta.cwd, model: meta.model }
439
+ : { sessionId, cwd: meta.cwd };
440
+ }
441
+
442
+ async closeSession(_sessionId: string): Promise<void> {
443
+ // 子进程由 prompt 的 finally 自动 kill
444
+ }
445
+ }
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // 工厂函数
449
+ // ---------------------------------------------------------------------------
450
+
451
+ export function createClaudeAdapter(
452
+ options: ClaudeAdapterOptions,
453
+ ): ToolAdapter {
454
+ return new ClaudeAdapter(options);
455
+ }