chatccc 0.2.102 → 0.2.106

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,14 +1,15 @@
1
1
  // =============================================================================
2
- // claude-adapter.ts — Claude Agent SDK 适配器
2
+ // claude-adapter.ts — Claude CLI 适配器
3
3
  // =============================================================================
4
- // 实现 ToolAdapter 接口,将 @anthropic-ai/claude-agent-sdk 调用封装在内。
4
+ // 通过直接 spawn claude.exe(不再使用 @anthropic-ai/claude-agent-sdk JS API)
5
+ // 以确保 --mcp-config 参数能正确传递给 CLI 子进程,加载用户 MCP 服务器。
5
6
  // =============================================================================
6
7
 
7
- import {
8
- unstable_v2_createSession,
9
- unstable_v2_resumeSession,
10
- getSessionInfo as sdkGetSessionInfo,
11
- } from "@anthropic-ai/claude-agent-sdk";
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";
12
13
 
13
14
  import type {
14
15
  ToolAdapter,
@@ -16,10 +17,29 @@ import type {
16
17
  UnifiedStreamMessage,
17
18
  CreateSessionResult,
18
19
  SessionInfo,
20
+ ToolPromptOptions,
19
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
+ );
20
40
 
21
41
  // ---------------------------------------------------------------------------
22
- // 类型别名:SDK 内部消息的形状(避免导入 sdk.d.ts 的大型联合类型)
42
+ // 类型别名(CLI JSONL 消息格式,与旧 SDK 消息格式兼容)
23
43
  // ---------------------------------------------------------------------------
24
44
 
25
45
  interface SdkContentBlock {
@@ -45,6 +65,8 @@ interface SdkMessageLike {
45
65
  post_tokens?: number;
46
66
  };
47
67
  session_id?: string;
68
+ model?: string;
69
+ cwd?: string;
48
70
  }
49
71
 
50
72
  // ---------------------------------------------------------------------------
@@ -55,109 +77,100 @@ export interface ClaudeAdapterOptions {
55
77
  model: string;
56
78
  subagentModel?: string;
57
79
  effort: string;
58
- /** 判断字段是否为"不传给 SDK"的占位(项目约定:空字符串/全空白) */
59
- isEmpty: (value: string) => boolean;
60
- /**
61
- * Anthropic 兼容网关的 API key。
62
- * 非空(trim 后)时会被注入到 SDK 子进程的 ANTHROPIC_API_KEY 环境变量;
63
- * 留空 / 全空白 → 不覆盖,沿用主进程 process.env / 系统环境变量。
64
- * 永远不会写入主进程的 process.env,避免污染其他依赖 env 的代码。
65
- */
80
+ /** Anthropic API Key(选填,留空则不注入环境变量) */
66
81
  apiKey?: string;
67
- /**
68
- * Anthropic 兼容网关的 base URL。
69
- * 非空(trim 后)时会被注入到 SDK 子进程的 ANTHROPIC_BASE_URL 环境变量;
70
- * 留空 / 全空白 → 不覆盖,沿用主进程 process.env / 系统环境变量。
71
- */
82
+ /** Anthropic 兼容 API Base URL(选填,留空则不注入环境变量) */
72
83
  baseUrl?: string;
84
+ /** 判断字段是否为"不传给 CLI"的占位(空字符串/全空白) */
85
+ isEmpty: (value: string) => boolean;
86
+ /** 注入自定义 meta 持久化 store(测试用);未提供时使用全局默认实例。 */
87
+ metaStore?: ClaudeSessionMetaStore;
73
88
  }
74
89
 
75
90
  // ---------------------------------------------------------------------------
76
- // buildSdkEnv — 为 SDK 子进程构造 env
91
+ // buildCliEnv — 为 CLI 子进程构造 env(仅子进程级别,不污染主进程)
77
92
  // ---------------------------------------------------------------------------
78
- // 行为契约(详见单测 "createClaudeAdapter — env 注入"):
79
- // - apiKey 与 baseUrl 都为空(trim 后)→ 返回 undefined,让 SDK 走默认行为
80
- // (即 process.env),避免无意义的拷贝。
81
- // - 任一非空 → 返回 process.env 的浅拷贝,并按需覆盖 ANTHROPIC_API_KEY /
82
- // ANTHROPIC_BASE_URL;其余 env 字段保持不变(PATH、HOME 等子进程必需)。
83
- // - 主进程 process.env 永不被写入,主进程其他模块对 env 的读取不受影响。
84
- function buildSdkEnv(
93
+
94
+ function buildCliEnv(
95
+ subagentModel: string | undefined,
85
96
  apiKey: string | undefined,
86
97
  baseUrl: string | undefined,
87
- subagentModel: string | undefined,
88
98
  ): Record<string, string | undefined> | undefined {
99
+ const subagentModelTrim = (subagentModel ?? "").trim();
89
100
  const apiKeyTrim = (apiKey ?? "").trim();
90
101
  const baseUrlTrim = (baseUrl ?? "").trim();
91
- const subagentModelTrim = (subagentModel ?? "").trim();
92
- const hasApiOverride = Boolean(apiKeyTrim || baseUrlTrim);
93
- if (!hasApiOverride) return undefined;
102
+
103
+ if (!subagentModelTrim && !apiKeyTrim && !baseUrlTrim) return undefined;
94
104
 
95
105
  const env: Record<string, string | undefined> = { ...process.env };
96
- // ChatCCC's third-party Claude API config is authoritative when present.
97
- // Remove Claude Code/user settings env that can silently override gateway/auth/model choice.
98
106
  delete env.ANTHROPIC_AUTH_TOKEN;
99
107
  delete env.CLAUDE_CODE_OAUTH_TOKEN;
100
108
  delete env.ANTHROPIC_MODEL;
101
109
  delete env.ANTHROPIC_DEFAULT_OPUS_MODEL;
102
110
  delete env.ANTHROPIC_DEFAULT_SONNET_MODEL;
103
111
  delete env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
104
- delete env.CLAUDE_CODE_SUBAGENT_MODEL;
105
112
  delete env.CLAUDE_CODE_EFFORT_LEVEL;
106
113
 
107
- if (apiKeyTrim) env.ANTHROPIC_API_KEY = apiKeyTrim;
108
- if (baseUrlTrim) {
109
- env.ANTHROPIC_BASE_URL = baseUrlTrim;
110
- } else {
111
- delete env.ANTHROPIC_BASE_URL;
112
- }
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
+
114
118
  return env;
115
119
  }
116
120
 
117
- function resolveSettingSources(
118
- _apiKey: string | undefined,
119
- _baseUrl: string | undefined,
120
- ): Array<"user" | "project" | "local"> {
121
- // CLAUDE.md / CLAUDE.local.md Agent 指令文件,与 API 来源无关,
122
- // 无论使用官方 Anthropic 还是第三方网关都应加载。
123
- // 包含 "user" 以使 ~/.claude/settings.json 中的配置(如 mcpServers)生效;
124
- // buildSdkEnv() 会删除可能冲突的 env 变量,确保网关配置不被覆盖。
125
- return ["user", "project", "local"];
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
+ }
126
138
  }
127
139
 
128
140
  // ---------------------------------------------------------------------------
129
- // buildSessionOptions — 还原 claudeSdkSessionOptions 的精确行为
141
+ // 诊断日志
130
142
  // ---------------------------------------------------------------------------
131
143
 
132
- function buildSessionOptions(
133
- cwd: string,
134
- model: string,
135
- effort: string,
136
- isEmpty: (value: string) => boolean,
137
- apiKey: string | undefined,
138
- baseUrl: string | undefined,
139
- subagentModel: string | undefined,
140
- ): Record<string, unknown> {
141
- const o: Record<string, unknown> = {
142
- cwd,
143
- permissionMode: "bypassPermissions",
144
- allowDangerouslySkipPermissions: true,
145
- autoCompactEnabled: true,
146
- settingSources: resolveSettingSources(apiKey, baseUrl),
147
- };
148
- if (!isEmpty(model)) o.model = model;
149
- if (!isEmpty(effort)) o.effort = effort;
150
- const env = buildSdkEnv(apiKey, baseUrl, subagentModel);
151
- if (env) o.env = env;
152
- return o;
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
+ }
153
166
  }
154
167
 
155
168
  // ---------------------------------------------------------------------------
156
- // normalizeSdkMessage — 关键映射:SDK 消息 → UnifiedStreamMessage | null
169
+ // normalizeSdkMessage — CLI JSONL 消息 → UnifiedStreamMessage | null
170
+ // (CLI 与 SDK 使用相同 JSONL 格式,导出名保留以兼容现有测试)
157
171
  // ---------------------------------------------------------------------------
158
172
 
159
173
  export function normalizeSdkMessage(msg: SdkMessageLike): UnifiedStreamMessage | null {
160
- // 1) assistant / user 消息:遍历 content 块
161
174
  if (
162
175
  (msg.type === "assistant" || msg.type === "user") &&
163
176
  msg.message?.content
@@ -188,13 +201,15 @@ export function normalizeSdkMessage(msg: SdkMessageLike): UnifiedStreamMessage |
188
201
  query: block.query ?? "",
189
202
  });
190
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;
191
207
  blocks.push({ type: "text", text: block.text });
192
208
  }
193
209
  }
194
210
  return { type: msg.type, blocks };
195
211
  }
196
212
 
197
- // 2) system / compact_boundary 消息:上下文压缩事件
198
213
  if (msg.type === "system" && msg.subtype === "compact_boundary") {
199
214
  const meta = msg.compact_metadata;
200
215
  if (!meta) return null;
@@ -211,12 +226,116 @@ export function normalizeSdkMessage(msg: SdkMessageLike): UnifiedStreamMessage |
211
226
  };
212
227
  }
213
228
 
214
- // 3) 其他消息类型:跳过
215
229
  return null;
216
230
  }
217
231
 
218
232
  // ---------------------------------------------------------------------------
219
- // 适配器实现(私有类,仅通过工厂函数暴露)
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
220
339
  // ---------------------------------------------------------------------------
221
340
 
222
341
  class ClaudeAdapter implements ToolAdapter {
@@ -225,57 +344,45 @@ class ClaudeAdapter implements ToolAdapter {
225
344
  private model: string;
226
345
  private effort: string;
227
346
  private subagentModel: string | undefined;
228
- private isEmpty: (value: string) => boolean;
229
347
  private apiKey: string | undefined;
230
348
  private baseUrl: string | undefined;
349
+ private isEmpty: (value: string) => boolean;
350
+ private metaStore: ClaudeSessionMetaStore;
231
351
 
232
352
  constructor(options: ClaudeAdapterOptions) {
233
353
  this.model = options.model;
234
354
  this.effort = options.effort;
235
355
  this.subagentModel = options.subagentModel;
236
- this.isEmpty = options.isEmpty;
237
356
  this.apiKey = options.apiKey;
238
357
  this.baseUrl = options.baseUrl;
358
+ this.isEmpty = options.isEmpty;
359
+ this.metaStore = options.metaStore ?? defaultClaudeSessionMetaStore;
239
360
  }
240
361
 
241
362
  async createSession(cwd: string): Promise<CreateSessionResult> {
242
- const sessionOpts = buildSessionOptions(
243
- cwd,
244
- this.model,
245
- this.effort,
246
- this.isEmpty,
247
- this.apiKey,
248
- this.baseUrl,
249
- this.subagentModel,
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"],
250
369
  );
251
- const session = unstable_v2_createSession(sessionOpts as any);
252
-
253
- await session.send("ok");
254
-
255
- const stream = session.stream();
256
- const first = await stream.next();
257
-
258
- if (first.done || !(first.value as SdkMessageLike)?.session_id) {
259
- session.close();
260
- throw new Error("No session ID in Claude init event");
261
- }
262
370
 
263
- const sessionId = (first.value as SdkMessageLike).session_id!;
371
+ const proc = spawnCli(args, cwd, env, false);
264
372
 
265
- // 后台消费剩余的 stream(必须:否则 SDK 内部缓冲可能阻塞)
266
- (async () => {
267
- try {
268
- for await (const _msg of stream) {
269
- // 静默消费
270
- }
271
- } catch {
272
- // stream 异常不阻塞主流程
273
- } finally {
274
- session.close();
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 };
275
381
  }
276
- })();
382
+ }
277
383
 
278
- return { sessionId };
384
+ await killProcessTree(proc.pid);
385
+ throw new Error("No session ID in Claude init event");
279
386
  }
280
387
 
281
388
  async *prompt(
@@ -283,61 +390,57 @@ class ClaudeAdapter implements ToolAdapter {
283
390
  userText: string,
284
391
  cwd: string,
285
392
  signal?: AbortSignal,
393
+ options?: ToolPromptOptions,
286
394
  ): AsyncIterable<UnifiedStreamMessage> {
287
- const sessionOpts = buildSessionOptions(
288
- cwd,
289
- this.model,
290
- this.effort,
291
- this.isEmpty,
292
- this.apiKey,
293
- this.baseUrl,
294
- this.subagentModel,
295
- );
296
- const session = unstable_v2_resumeSession(
297
- sessionId,
298
- sessionOpts as any,
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"],
299
400
  );
300
401
 
301
- if (signal?.aborted) {
302
- session.close();
303
- return;
304
- }
305
- const onAbort = () => { session.close(); };
306
- signal?.addEventListener("abort", onAbort, { once: true });
402
+ const proc = spawnCli(args, cwd, env, true);
403
+ if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
307
404
 
308
- await session.send(userText);
405
+ const onAbort = () => { void killProcessTree(proc.pid); };
406
+ signal?.addEventListener("abort", onAbort, { once: true });
309
407
 
310
- const stream = session.stream();
408
+ proc.stdin!.write(buildStreamJsonInput(userText));
409
+ proc.stdin!.end();
311
410
 
312
411
  try {
313
- for await (const msg of stream) {
412
+ for await (const raw of readJsonLines(proc, signal)) {
314
413
  if (signal?.aborted) break;
315
- const normalized = normalizeSdkMessage(
316
- msg as unknown as SdkMessageLike,
317
- );
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);
318
425
  if (normalized) yield normalized;
319
426
  }
320
427
  } finally {
321
428
  signal?.removeEventListener("abort", onAbort);
322
- session.close();
429
+ await killProcessTree(proc.pid);
430
+ if (proc.pid !== undefined) options?.onProcessExit?.({ pid: proc.pid });
323
431
  }
324
432
  }
325
433
 
326
- async getSessionInfo(
327
- sessionId: string,
328
- ): Promise<SessionInfo | undefined> {
329
- const info = await sdkGetSessionInfo(sessionId);
330
- if (!info) return undefined;
331
- return {
332
- sessionId: info.sessionId,
333
- cwd: info.cwd,
334
- summary: info.summary,
335
- lastModified: info.lastModified,
336
- };
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 };
337
440
  }
338
441
 
339
442
  async closeSession(_sessionId: string): Promise<void> {
340
- // Claude SDK stream 结束后自动关闭 session,此处为 no-op
443
+ // 子进程由 prompt finally 自动 kill
341
444
  }
342
445
  }
343
446
 
@@ -349,4 +452,4 @@ export function createClaudeAdapter(
349
452
  options: ClaudeAdapterOptions,
350
453
  ): ToolAdapter {
351
454
  return new ClaudeAdapter(options);
352
- }
455
+ }
@@ -0,0 +1,120 @@
1
+ // =============================================================================
2
+ // claude-session-meta-store.ts — Claude 会话 sessionId → meta 持久化映射
3
+ // =============================================================================
4
+ // 背景:切换到直接调用 Claude CLI 后,不再有 SDK 的 getSessionInfo 可用。
5
+ // ChatCCC 必须自己维护 sessionId → { cwd, model } 映射。
6
+ //
7
+ // 存储:
8
+ // 文件 state/claude-session-meta.json,结构:
9
+ // { "<sessionId>": { "cwd": "...", "model": "..." } }
10
+ //
11
+ // API 设计:
12
+ // set(sid, partial) → 部分合并写入;只更新非空字段,不会清空其他字段
13
+ // =============================================================================
14
+
15
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
16
+ import { dirname, join } from "node:path";
17
+
18
+ import { USER_DATA_DIR } from "../config.ts";
19
+
20
+ export const CLAUDE_SESSION_META_FILE = join(
21
+ USER_DATA_DIR,
22
+ "state",
23
+ "claude-session-meta.json",
24
+ );
25
+
26
+ export interface ClaudeSessionMeta {
27
+ cwd: string;
28
+ model?: string;
29
+ }
30
+
31
+ export interface ClaudeSessionMetaStore {
32
+ get(sessionId: string): Promise<ClaudeSessionMeta | undefined>;
33
+ set(sessionId: string, partial: Partial<ClaudeSessionMeta>): Promise<void>;
34
+ }
35
+
36
+ interface RawEntry {
37
+ cwd?: string;
38
+ model?: string;
39
+ }
40
+
41
+ function isNonEmptyString(v: unknown): v is string {
42
+ return typeof v === "string" && v.length > 0;
43
+ }
44
+
45
+ function parseEntry(raw: unknown): RawEntry | null {
46
+ if (typeof raw === "string" && raw.length > 0) {
47
+ return { cwd: raw };
48
+ }
49
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
50
+ const obj = raw as Record<string, unknown>;
51
+ const out: RawEntry = {};
52
+ if (isNonEmptyString(obj.cwd)) out.cwd = obj.cwd;
53
+ if (isNonEmptyString(obj.model)) out.model = obj.model;
54
+ return out;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ export function createClaudeSessionMetaStore(
60
+ filePath: string = CLAUDE_SESSION_META_FILE,
61
+ ): ClaudeSessionMetaStore {
62
+ let cache: Record<string, RawEntry> | null = null;
63
+
64
+ async function load(): Promise<Record<string, RawEntry>> {
65
+ if (cache) return cache;
66
+ try {
67
+ const raw = await readFile(filePath, "utf-8");
68
+ const parsed = JSON.parse(raw);
69
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
70
+ const out: Record<string, RawEntry> = {};
71
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
72
+ const entry = parseEntry(v);
73
+ if (entry) out[k] = entry;
74
+ }
75
+ cache = out;
76
+ return out;
77
+ }
78
+ } catch {
79
+ // 文件不存在 / JSON 损坏 → 视为空映射
80
+ }
81
+ cache = {};
82
+ return cache;
83
+ }
84
+
85
+ return {
86
+ async get(sessionId: string): Promise<ClaudeSessionMeta | undefined> {
87
+ const map = await load();
88
+ const entry = map[sessionId];
89
+ if (!entry || !isNonEmptyString(entry.cwd)) return undefined;
90
+ return entry.model
91
+ ? { cwd: entry.cwd, model: entry.model }
92
+ : { cwd: entry.cwd };
93
+ },
94
+
95
+ async set(
96
+ sessionId: string,
97
+ partial: Partial<ClaudeSessionMeta>,
98
+ ): Promise<void> {
99
+ const map = await load();
100
+ const existing = map[sessionId] ?? {};
101
+ const merged: RawEntry = { ...existing };
102
+ if (isNonEmptyString(partial.cwd)) merged.cwd = partial.cwd;
103
+ if (isNonEmptyString(partial.model)) merged.model = partial.model;
104
+
105
+ if (existing.cwd === merged.cwd && existing.model === merged.model) return;
106
+
107
+ map[sessionId] = merged;
108
+ try {
109
+ await mkdir(dirname(filePath), { recursive: true });
110
+ await writeFile(filePath, JSON.stringify(map, null, 2), "utf-8");
111
+ } catch (err) {
112
+ console.error(
113
+ `[claude-session-meta] failed to persist ${filePath}: ${(err as Error).message}`,
114
+ );
115
+ }
116
+ },
117
+ };
118
+ }
119
+
120
+ export const defaultClaudeSessionMetaStore = createClaudeSessionMetaStore();