chatccc 0.2.103 → 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,15 +1,13 @@
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";
12
- import { readdirSync, existsSync } from "node:fs";
8
+ import { spawn, type ChildProcess } from "node:child_process";
9
+ import { createInterface } from "node:readline";
10
+ import { existsSync, readFileSync } from "node:fs";
13
11
  import { join } from "node:path";
14
12
  import { homedir } from "node:os";
15
13
 
@@ -19,10 +17,29 @@ import type {
19
17
  UnifiedStreamMessage,
20
18
  CreateSessionResult,
21
19
  SessionInfo,
20
+ ToolPromptOptions,
22
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
+ );
23
40
 
24
41
  // ---------------------------------------------------------------------------
25
- // 类型别名:SDK 内部消息的形状(避免导入 sdk.d.ts 的大型联合类型)
42
+ // 类型别名(CLI JSONL 消息格式,与旧 SDK 消息格式兼容)
26
43
  // ---------------------------------------------------------------------------
27
44
 
28
45
  interface SdkContentBlock {
@@ -48,6 +65,8 @@ interface SdkMessageLike {
48
65
  post_tokens?: number;
49
66
  };
50
67
  session_id?: string;
68
+ model?: string;
69
+ cwd?: string;
51
70
  }
52
71
 
53
72
  // ---------------------------------------------------------------------------
@@ -58,126 +77,100 @@ export interface ClaudeAdapterOptions {
58
77
  model: string;
59
78
  subagentModel?: string;
60
79
  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
- */
80
+ /** Anthropic API Key(选填,留空则不注入环境变量) */
69
81
  apiKey?: string;
70
- /**
71
- * Anthropic 兼容网关的 base URL。
72
- * 非空(trim 后)时会被注入到 SDK 子进程的 ANTHROPIC_BASE_URL 环境变量;
73
- * 留空 / 全空白 → 不覆盖,沿用主进程 process.env / 系统环境变量。
74
- */
82
+ /** Anthropic 兼容 API Base URL(选填,留空则不注入环境变量) */
75
83
  baseUrl?: string;
84
+ /** 判断字段是否为"不传给 CLI"的占位(空字符串/全空白) */
85
+ isEmpty: (value: string) => boolean;
86
+ /** 注入自定义 meta 持久化 store(测试用);未提供时使用全局默认实例。 */
87
+ metaStore?: ClaudeSessionMetaStore;
76
88
  }
77
89
 
78
90
  // ---------------------------------------------------------------------------
79
- // buildSdkEnv — 为 SDK 子进程构造 env
91
+ // buildCliEnv — 为 CLI 子进程构造 env(仅子进程级别,不污染主进程)
80
92
  // ---------------------------------------------------------------------------
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(
93
+
94
+ function buildCliEnv(
95
+ subagentModel: string | undefined,
88
96
  apiKey: string | undefined,
89
97
  baseUrl: string | undefined,
90
- subagentModel: string | undefined,
91
98
  ): Record<string, string | undefined> | undefined {
99
+ const subagentModelTrim = (subagentModel ?? "").trim();
92
100
  const apiKeyTrim = (apiKey ?? "").trim();
93
101
  const baseUrlTrim = (baseUrl ?? "").trim();
94
- const subagentModelTrim = (subagentModel ?? "").trim();
95
- const hasApiOverride = Boolean(apiKeyTrim || baseUrlTrim);
96
- if (!hasApiOverride) return undefined;
102
+
103
+ if (!subagentModelTrim && !apiKeyTrim && !baseUrlTrim) return undefined;
97
104
 
98
105
  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
106
  delete env.ANTHROPIC_AUTH_TOKEN;
102
107
  delete env.CLAUDE_CODE_OAUTH_TOKEN;
103
108
  delete env.ANTHROPIC_MODEL;
104
109
  delete env.ANTHROPIC_DEFAULT_OPUS_MODEL;
105
110
  delete env.ANTHROPIC_DEFAULT_SONNET_MODEL;
106
111
  delete env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
107
- delete env.CLAUDE_CODE_SUBAGENT_MODEL;
108
112
  delete env.CLAUDE_CODE_EFFORT_LEVEL;
109
113
 
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
114
  if (subagentModelTrim) env.CLAUDE_CODE_SUBAGENT_MODEL = subagentModelTrim;
117
- return env;
118
- }
115
+ if (apiKeyTrim) env.ANTHROPIC_API_KEY = apiKeyTrim;
116
+ if (baseUrlTrim) env.ANTHROPIC_BASE_URL = baseUrlTrim;
119
117
 
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"];
118
+ return env;
129
119
  }
130
120
 
131
121
  // ---------------------------------------------------------------------------
132
- // collectSkillNames — 扫描用户级 skill 目录,返回所有 skill 名
122
+ // MCP 配置读取
133
123
  // ---------------------------------------------------------------------------
134
124
 
135
- function collectSkillNames(): string[] {
136
- const userSkillsDir = join(homedir(), ".claude", "skills");
137
- if (!existsSync(userSkillsDir)) return [];
125
+ function readMcpConfigJson(): string | null {
126
+ const settingsPath = join(homedir(), ".claude", "settings.json");
138
127
  try {
139
- return readdirSync(userSkillsDir, { withFileTypes: true })
140
- .filter((d) => d.isDirectory())
141
- .map((d) => d.name);
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 });
142
135
  } catch {
143
- return [];
136
+ return null;
144
137
  }
145
138
  }
146
139
 
147
140
  // ---------------------------------------------------------------------------
148
- // buildSessionOptions — 还原 claudeSdkSessionOptions 的精确行为
141
+ // 诊断日志
149
142
  // ---------------------------------------------------------------------------
150
143
 
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;
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
+ }
173
166
  }
174
167
 
175
168
  // ---------------------------------------------------------------------------
176
- // normalizeSdkMessage — 关键映射:SDK 消息 → UnifiedStreamMessage | null
169
+ // normalizeSdkMessage — CLI JSONL 消息 → UnifiedStreamMessage | null
170
+ // (CLI 与 SDK 使用相同 JSONL 格式,导出名保留以兼容现有测试)
177
171
  // ---------------------------------------------------------------------------
178
172
 
179
173
  export function normalizeSdkMessage(msg: SdkMessageLike): UnifiedStreamMessage | null {
180
- // 1) assistant / user 消息:遍历 content 块
181
174
  if (
182
175
  (msg.type === "assistant" || msg.type === "user") &&
183
176
  msg.message?.content
@@ -208,13 +201,15 @@ export function normalizeSdkMessage(msg: SdkMessageLike): UnifiedStreamMessage |
208
201
  query: block.query ?? "",
209
202
  });
210
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;
211
207
  blocks.push({ type: "text", text: block.text });
212
208
  }
213
209
  }
214
210
  return { type: msg.type, blocks };
215
211
  }
216
212
 
217
- // 2) system / compact_boundary 消息:上下文压缩事件
218
213
  if (msg.type === "system" && msg.subtype === "compact_boundary") {
219
214
  const meta = msg.compact_metadata;
220
215
  if (!meta) return null;
@@ -231,12 +226,116 @@ export function normalizeSdkMessage(msg: SdkMessageLike): UnifiedStreamMessage |
231
226
  };
232
227
  }
233
228
 
234
- // 3) 其他消息类型:跳过
235
229
  return null;
236
230
  }
237
231
 
238
232
  // ---------------------------------------------------------------------------
239
- // 适配器实现(私有类,仅通过工厂函数暴露)
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
240
339
  // ---------------------------------------------------------------------------
241
340
 
242
341
  class ClaudeAdapter implements ToolAdapter {
@@ -245,57 +344,45 @@ class ClaudeAdapter implements ToolAdapter {
245
344
  private model: string;
246
345
  private effort: string;
247
346
  private subagentModel: string | undefined;
248
- private isEmpty: (value: string) => boolean;
249
347
  private apiKey: string | undefined;
250
348
  private baseUrl: string | undefined;
349
+ private isEmpty: (value: string) => boolean;
350
+ private metaStore: ClaudeSessionMetaStore;
251
351
 
252
352
  constructor(options: ClaudeAdapterOptions) {
253
353
  this.model = options.model;
254
354
  this.effort = options.effort;
255
355
  this.subagentModel = options.subagentModel;
256
- this.isEmpty = options.isEmpty;
257
356
  this.apiKey = options.apiKey;
258
357
  this.baseUrl = options.baseUrl;
358
+ this.isEmpty = options.isEmpty;
359
+ this.metaStore = options.metaStore ?? defaultClaudeSessionMetaStore;
259
360
  }
260
361
 
261
362
  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,
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"],
270
369
  );
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
370
 
283
- const sessionId = (first.value as SdkMessageLike).session_id!;
371
+ const proc = spawnCli(args, cwd, env, false);
284
372
 
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();
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 };
295
381
  }
296
- })();
382
+ }
297
383
 
298
- return { sessionId };
384
+ await killProcessTree(proc.pid);
385
+ throw new Error("No session ID in Claude init event");
299
386
  }
300
387
 
301
388
  async *prompt(
@@ -303,61 +390,57 @@ class ClaudeAdapter implements ToolAdapter {
303
390
  userText: string,
304
391
  cwd: string,
305
392
  signal?: AbortSignal,
393
+ options?: ToolPromptOptions,
306
394
  ): 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,
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"],
319
400
  );
320
401
 
321
- if (signal?.aborted) {
322
- session.close();
323
- return;
324
- }
325
- const onAbort = () => { session.close(); };
326
- signal?.addEventListener("abort", onAbort, { once: true });
402
+ const proc = spawnCli(args, cwd, env, true);
403
+ if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
327
404
 
328
- await session.send(userText);
405
+ const onAbort = () => { void killProcessTree(proc.pid); };
406
+ signal?.addEventListener("abort", onAbort, { once: true });
329
407
 
330
- const stream = session.stream();
408
+ proc.stdin!.write(buildStreamJsonInput(userText));
409
+ proc.stdin!.end();
331
410
 
332
411
  try {
333
- for await (const msg of stream) {
412
+ for await (const raw of readJsonLines(proc, signal)) {
334
413
  if (signal?.aborted) break;
335
- const normalized = normalizeSdkMessage(
336
- msg as unknown as SdkMessageLike,
337
- );
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);
338
425
  if (normalized) yield normalized;
339
426
  }
340
427
  } finally {
341
428
  signal?.removeEventListener("abort", onAbort);
342
- session.close();
429
+ await killProcessTree(proc.pid);
430
+ if (proc.pid !== undefined) options?.onProcessExit?.({ pid: proc.pid });
343
431
  }
344
432
  }
345
433
 
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
- };
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 };
357
440
  }
358
441
 
359
442
  async closeSession(_sessionId: string): Promise<void> {
360
- // Claude SDK stream 结束后自动关闭 session,此处为 no-op
443
+ // 子进程由 prompt finally 自动 kill
361
444
  }
362
445
  }
363
446
 
@@ -369,4 +452,4 @@ export function createClaudeAdapter(
369
452
  options: ClaudeAdapterOptions,
370
453
  ): ToolAdapter {
371
454
  return new ClaudeAdapter(options);
372
- }
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();