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.
- 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 +245 -162
- 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,15 +1,13 @@
|
|
|
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
|
-
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
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
91
|
+
// buildCliEnv — 为 CLI 子进程构造 env(仅子进程级别,不污染主进程)
|
|
80
92
|
// ---------------------------------------------------------------------------
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
118
|
-
|
|
115
|
+
if (apiKeyTrim) env.ANTHROPIC_API_KEY = apiKeyTrim;
|
|
116
|
+
if (baseUrlTrim) env.ANTHROPIC_BASE_URL = baseUrlTrim;
|
|
119
117
|
|
|
120
|
-
|
|
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
|
-
//
|
|
122
|
+
// MCP 配置读取
|
|
133
123
|
// ---------------------------------------------------------------------------
|
|
134
124
|
|
|
135
|
-
function
|
|
136
|
-
const
|
|
137
|
-
if (!existsSync(userSkillsDir)) return [];
|
|
125
|
+
function readMcpConfigJson(): string | null {
|
|
126
|
+
const settingsPath = join(homedir(), ".claude", "settings.json");
|
|
138
127
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
//
|
|
141
|
+
// 诊断日志
|
|
149
142
|
// ---------------------------------------------------------------------------
|
|
150
143
|
|
|
151
|
-
function
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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 —
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
this.isEmpty,
|
|
267
|
-
|
|
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
|
|
371
|
+
const proc = spawnCli(args, cwd, env, false);
|
|
284
372
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
this.effort,
|
|
311
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
-
|
|
405
|
+
const onAbort = () => { void killProcessTree(proc.pid); };
|
|
406
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
329
407
|
|
|
330
|
-
|
|
408
|
+
proc.stdin!.write(buildStreamJsonInput(userText));
|
|
409
|
+
proc.stdin!.end();
|
|
331
410
|
|
|
332
411
|
try {
|
|
333
|
-
for await (const
|
|
412
|
+
for await (const raw of readJsonLines(proc, signal)) {
|
|
334
413
|
if (signal?.aborted) break;
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
//
|
|
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();
|