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.
- package/README.md +3 -3
- package/config.sample.json +1 -2
- package/package.json +63 -63
- package/src/__tests__/claude-adapter.test.ts +104 -574
- package/src/__tests__/config-reload.test.ts +6 -6
- package/src/__tests__/web-ui.test.ts +15 -127
- package/src/adapters/claude-adapter.ts +253 -150
- package/src/adapters/claude-session-meta-store.ts +120 -0
- package/src/cards.ts +0 -31
- package/src/config.ts +5 -17
- package/src/orchestrator.ts +0 -134
- package/src/session.ts +1 -1
- package/src/web-ui.ts +43 -285
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// =============================================================================
|
|
2
|
-
// claude-adapter.ts — Claude
|
|
2
|
+
// claude-adapter.ts — Claude CLI 适配器
|
|
3
3
|
// =============================================================================
|
|
4
|
-
//
|
|
4
|
+
// 通过直接 spawn claude.exe(不再使用 @anthropic-ai/claude-agent-sdk JS API)
|
|
5
|
+
// 以确保 --mcp-config 参数能正确传递给 CLI 子进程,加载用户 MCP 服务器。
|
|
5
6
|
// =============================================================================
|
|
6
7
|
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
} from "
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
91
|
+
// buildCliEnv — 为 CLI 子进程构造 env(仅子进程级别,不污染主进程)
|
|
77
92
|
// ---------------------------------------------------------------------------
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
//
|
|
141
|
+
// 诊断日志
|
|
130
142
|
// ---------------------------------------------------------------------------
|
|
131
143
|
|
|
132
|
-
function
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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 —
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
this.isEmpty,
|
|
247
|
-
|
|
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
|
|
371
|
+
const proc = spawnCli(args, cwd, env, false);
|
|
264
372
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
this.effort,
|
|
291
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
405
|
+
const onAbort = () => { void killProcessTree(proc.pid); };
|
|
406
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
309
407
|
|
|
310
|
-
|
|
408
|
+
proc.stdin!.write(buildStreamJsonInput(userText));
|
|
409
|
+
proc.stdin!.end();
|
|
311
410
|
|
|
312
411
|
try {
|
|
313
|
-
for await (const
|
|
412
|
+
for await (const raw of readJsonLines(proc, signal)) {
|
|
314
413
|
if (signal?.aborted) break;
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
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
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
//
|
|
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();
|