@wu529778790/open-im 1.11.1-beta.2 → 1.11.1-beta.21
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 +76 -59
- package/README.zh-CN.md +75 -58
- package/dist/adapters/claude-sdk-adapter.d.ts +45 -19
- package/dist/adapters/claude-sdk-adapter.js +240 -414
- package/dist/adapters/registry.js +1 -2
- package/dist/adapters/tool-adapter.interface.d.ts +4 -0
- package/dist/clawbot/client.d.ts +1 -1
- package/dist/clawbot/client.js +71 -3
- package/dist/clawbot/event-handler.d.ts +1 -1
- package/dist/clawbot/event-handler.js +10 -3
- package/dist/commands/handler.d.ts +7 -0
- package/dist/commands/handler.js +270 -27
- package/dist/config-web-page-i18n.d.ts +96 -4
- package/dist/config-web-page-i18n.js +98 -4
- package/dist/config-web.js +15 -0
- package/dist/constants.js +0 -1
- package/dist/index.js +13 -4
- package/dist/logger.d.ts +5 -0
- package/dist/logger.js +21 -0
- package/dist/platform/handle-text-flow.d.ts +2 -0
- package/dist/platform/handle-text-flow.js +35 -2
- package/dist/session/session-manager.d.ts +9 -12
- package/dist/session/session-manager.js +31 -79
- package/dist/shared/ai-task.js +1 -0
- package/dist/shared/message-wal.d.ts +31 -0
- package/dist/shared/message-wal.js +91 -0
- package/package.json +4 -3
- package/web/dist/assets/index-B1c1xlbx.css +1 -0
- package/web/dist/assets/index-Dng7B2mE.js +57 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-7fl61tV1.css +0 -1
- package/web/dist/assets/index-BaLTMeeF.js +0 -57
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Claude SDK Adapter - 使用 Agent SDK
|
|
2
|
+
* Claude SDK Adapter - 使用 Agent SDK query() API 实现多轮对话
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* 认证:ANTHROPIC_API_KEY 或 CLAUDE_CODE_OAUTH_TOKEN
|
|
4
|
+
* query() API:
|
|
5
|
+
* - 返回 AsyncGenerator<SDKMessage>,直接迭代即可
|
|
6
|
+
* - 支持 resume/cwd/model 等 options,无需 process.chdir hack
|
|
7
|
+
* - SDK 内部管理 session 生命周期,无需手动维护 session pool
|
|
10
8
|
*/
|
|
11
|
-
import {
|
|
12
|
-
import { existsSync, readFileSync,
|
|
9
|
+
import { query, listSessions, getSessionMessages, deleteSession, renameSession, forkSession } from '@anthropic-ai/claude-agent-sdk';
|
|
10
|
+
import { existsSync, readFileSync, statSync, openSync, readSync, closeSync } from 'fs';
|
|
13
11
|
import { execSync } from 'child_process';
|
|
14
12
|
import { homedir } from 'os';
|
|
15
13
|
import { join } from 'path';
|
|
@@ -17,6 +15,17 @@ import { createLogger } from '../logger.js';
|
|
|
17
15
|
import { refreshClaudeEnvToProcess } from '../config/file-io.js';
|
|
18
16
|
import { toReplyPlainText } from '../shared/utils.js';
|
|
19
17
|
const log = createLogger('ClaudeSDK');
|
|
18
|
+
/**
|
|
19
|
+
* 注入交互式上下文指令,让 Claude 认为这是交互式终端会话。
|
|
20
|
+
* 解决 query() 非交互环境下 Claude 跳过用户选择、直接自主决策的问题。
|
|
21
|
+
*/
|
|
22
|
+
const INTERACTIVE_CONTEXT = `[SYSTEM: You are in an interactive chat session via instant messenger. The user CAN and WILL respond to your messages — treat this like an interactive terminal session. Rules:
|
|
23
|
+
1. When you need to make a decision involving user preference (choosing an approach, selecting between options, deciding what to do next), ALWAYS present clearly numbered options and WAIT for the user's response.
|
|
24
|
+
2. Do NOT proceed autonomously when user input would be valuable.
|
|
25
|
+
3. Only proceed autonomously for obvious single-path tasks (e.g. reading a file, running a simple command).
|
|
26
|
+
4. When presenting options, format them as a numbered list and end with a question.
|
|
27
|
+
]
|
|
28
|
+
`;
|
|
20
29
|
function loadUserPluginSettings() {
|
|
21
30
|
try {
|
|
22
31
|
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
|
@@ -53,8 +62,6 @@ loadUserPluginSettings();
|
|
|
53
62
|
* ~/.claude/projects/-Users-mac-github-open-im/
|
|
54
63
|
*/
|
|
55
64
|
function workDirToProjectPath(workDir) {
|
|
56
|
-
// Claude Code 将路径中的 / 替换为 -,leading - 保留
|
|
57
|
-
// /Users/mac/github/open-im → -Users-mac-github-open-im
|
|
58
65
|
return workDir.replace(/\//g, '-');
|
|
59
66
|
}
|
|
60
67
|
/**
|
|
@@ -71,14 +78,9 @@ function workDirToProjectPath(workDir) {
|
|
|
71
78
|
*/
|
|
72
79
|
function isCliSessionActive(sessionId, sessionFilePath) {
|
|
73
80
|
try {
|
|
74
|
-
// macOS/Linux: 用 ps 搜索包含该 sessionId 的 claude 进程
|
|
75
|
-
// 排除 open-im 自己的 SDK 子进程(路径含 claude-agent-sdk),只匹配用户交互式 CLI
|
|
76
|
-
// -F 固定字符串匹配,避免正则意外;-- 防止 sessionId 被误认为 flag
|
|
77
81
|
const result = execSync(`ps -axo pid,command 2>/dev/null | grep -v grep | grep "claude" | grep -v "claude-agent-sdk" | grep -F -- "${sessionId}" || true`, { encoding: 'utf-8', timeout: 3000 });
|
|
78
82
|
if (result.trim().length === 0)
|
|
79
83
|
return false;
|
|
80
|
-
// 进程存在,但可能只是 idle 在终端等输入。检查文件 mtime:
|
|
81
|
-
// 如果 session 文件超过 30 秒未修改,说明 CLI 没在活跃处理。
|
|
82
84
|
if (sessionFilePath) {
|
|
83
85
|
try {
|
|
84
86
|
const stat = statSync(sessionFilePath);
|
|
@@ -99,51 +101,28 @@ function isCliSessionActive(sessionId, sessionFilePath) {
|
|
|
99
101
|
}
|
|
100
102
|
}
|
|
101
103
|
/**
|
|
102
|
-
*
|
|
103
|
-
* 只返回 JSONL 文件(排除子目录如 subagents/),按修改时间倒序。
|
|
104
|
-
* @param homeOverride 测试用:覆盖 homedir() 的返回值
|
|
104
|
+
* Find the latest CLI session for a work directory using the SDK's listSessions.
|
|
105
105
|
*/
|
|
106
|
-
export function findLatestClaudeSession(workDir
|
|
107
|
-
const projectDir = join(homeOverride ?? homedir(), '.claude', 'projects', workDirToProjectPath(workDir));
|
|
108
|
-
if (!existsSync(projectDir)) {
|
|
109
|
-
log.info(`No Claude Code project dir found: ${projectDir}`);
|
|
110
|
-
return undefined;
|
|
111
|
-
}
|
|
106
|
+
export async function findLatestClaudeSession(workDir) {
|
|
112
107
|
try {
|
|
113
|
-
const
|
|
114
|
-
const sessions = [];
|
|
115
|
-
for (const entry of entries) {
|
|
116
|
-
// 只处理 .jsonl 文件,跳过子目录(如 subagents/)和 memory 目录
|
|
117
|
-
if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
|
|
118
|
-
continue;
|
|
119
|
-
const sessionId = entry.name.replace('.jsonl', '');
|
|
120
|
-
// 校验 UUID 格式
|
|
121
|
-
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(sessionId))
|
|
122
|
-
continue;
|
|
123
|
-
const filePath = join(projectDir, entry.name);
|
|
124
|
-
try {
|
|
125
|
-
const stat = statSync(filePath);
|
|
126
|
-
// 跳过空文件
|
|
127
|
-
if (stat.size === 0)
|
|
128
|
-
continue;
|
|
129
|
-
sessions.push({ sessionId, mtime: stat.mtimeMs, filePath, size: stat.size });
|
|
130
|
-
}
|
|
131
|
-
catch {
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
108
|
+
const sessions = await listSessions({ dir: workDir, limit: 1 });
|
|
135
109
|
if (sessions.length === 0) {
|
|
136
|
-
log.info(`No
|
|
110
|
+
log.info(`No sessions found for ${workDir}`);
|
|
137
111
|
return undefined;
|
|
138
112
|
}
|
|
139
|
-
// 按修改时间倒序,最新的在前
|
|
140
|
-
sessions.sort((a, b) => b.mtime - a.mtime);
|
|
141
113
|
const latest = sessions[0];
|
|
142
|
-
|
|
143
|
-
|
|
114
|
+
const projectDir = join(homedir(), '.claude', 'projects', workDirToProjectPath(workDir));
|
|
115
|
+
const filePath = join(projectDir, `${latest.sessionId}.jsonl`);
|
|
116
|
+
log.info(`Found latest session via SDK: ${latest.sessionId} (lastModified: ${new Date(latest.lastModified).toISOString()})`);
|
|
117
|
+
return {
|
|
118
|
+
sessionId: latest.sessionId,
|
|
119
|
+
mtime: latest.lastModified,
|
|
120
|
+
filePath,
|
|
121
|
+
size: latest.fileSize ?? 0,
|
|
122
|
+
};
|
|
144
123
|
}
|
|
145
124
|
catch (err) {
|
|
146
|
-
log.warn(`Failed to
|
|
125
|
+
log.warn(`Failed to list sessions via SDK: ${err}`);
|
|
147
126
|
return undefined;
|
|
148
127
|
}
|
|
149
128
|
}
|
|
@@ -177,104 +156,6 @@ function validateSessionFile(filePath, expectedSessionId) {
|
|
|
177
156
|
}
|
|
178
157
|
}
|
|
179
158
|
}
|
|
180
|
-
// 存储所有活跃的 SDKSession 对象,key 为 sessionId
|
|
181
|
-
// 使用 Map 而不是 Set,因为我们需要通过 sessionId 获取 session
|
|
182
|
-
const activeSessions = new Map();
|
|
183
|
-
// 记录每个 session 创建/恢复时的 workDir,防止跨 workDir 复用已固定 cwd 的子进程
|
|
184
|
-
const sessionWorkDirs = new Map();
|
|
185
|
-
// 存储正在进行的流式迭代器,用于中断
|
|
186
|
-
const activeStreams = new Set();
|
|
187
|
-
// 空闲会话清理:跟踪最后使用时间,定期清除超时会话
|
|
188
|
-
const sessionLastUsed = new Map();
|
|
189
|
-
// 跟踪正在执行任务的 session ID,防止空闲清理误杀运行中的长任务
|
|
190
|
-
const runningSessions = new Set();
|
|
191
|
-
let sessionIdleTtlMs = 30 * 60 * 1000; // 默认 30 分钟未使用则清理
|
|
192
|
-
let sessionIdleCleanupDisabled = false;
|
|
193
|
-
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 每 5 分钟检查一次
|
|
194
|
-
const MAX_ACTIVE_SESSIONS = 100;
|
|
195
|
-
const MAX_CAPTURED_STDERR_CHARS = 4000;
|
|
196
|
-
const MAX_EXPOSED_STDERR_CHARS = 500;
|
|
197
|
-
let sessionSeq = 0;
|
|
198
|
-
/**
|
|
199
|
-
* 由 initAdapters 根据配置调用。ttlMinutes≤0 时关闭空闲回收(仍受 MAX_ACTIVE_SESSIONS 限制)。
|
|
200
|
-
*/
|
|
201
|
-
export function configureClaudeSdkSessionIdle(ttlMinutes) {
|
|
202
|
-
if (ttlMinutes <= 0) {
|
|
203
|
-
sessionIdleCleanupDisabled = true;
|
|
204
|
-
log.info('Claude SDK: idle session cleanup disabled (sessionIdleTtlMinutes=0)');
|
|
205
|
-
}
|
|
206
|
-
else {
|
|
207
|
-
sessionIdleCleanupDisabled = false;
|
|
208
|
-
sessionIdleTtlMs = ttlMinutes * 60 * 1000;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
const cleanupInterval = setInterval(() => {
|
|
212
|
-
if (sessionIdleCleanupDisabled)
|
|
213
|
-
return;
|
|
214
|
-
const now = Date.now();
|
|
215
|
-
for (const [id, lastUsed] of sessionLastUsed) {
|
|
216
|
-
if (runningSessions.has(id))
|
|
217
|
-
continue; // 跳过正在运行任务的 session
|
|
218
|
-
if (now - lastUsed > sessionIdleTtlMs) {
|
|
219
|
-
const session = activeSessions.get(id);
|
|
220
|
-
if (session) {
|
|
221
|
-
try {
|
|
222
|
-
session.close();
|
|
223
|
-
}
|
|
224
|
-
catch { /* ignore */ }
|
|
225
|
-
activeSessions.delete(id);
|
|
226
|
-
}
|
|
227
|
-
sessionLastUsed.delete(id);
|
|
228
|
-
sessionWorkDirs.delete(id);
|
|
229
|
-
log.info(`Cleaned up idle session (unused ${Math.round((now - lastUsed) / 60000)}min): ${id}`);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}, CLEANUP_INTERVAL_MS);
|
|
233
|
-
cleanupInterval.unref(); // 不阻止进程退出
|
|
234
|
-
/**
|
|
235
|
-
* 串行化进程级 process.chdir() —— 同一时刻仅一个 chdir 生效。
|
|
236
|
-
*
|
|
237
|
-
* SDK V2 的 createSession/resumeSession 不接受 cwd 参数;且 send()/stream()
|
|
238
|
-
* 会以「调用时的 process.cwd()」派生 Claude Code 子进程。必须用互斥锁串行化
|
|
239
|
-
* 「整个 turn 的 cwd 切换」,否则并发多用户会让工具跑错目录。
|
|
240
|
-
*
|
|
241
|
-
* **TODO:** SDK 支持 cwd 选项后移除此锁。upstream:
|
|
242
|
-
* https://github.com/anthropics/claude-agent-sdk/issues
|
|
243
|
-
*/
|
|
244
|
-
let chdirMutex = Promise.resolve();
|
|
245
|
-
function withChdirMutex(fn) {
|
|
246
|
-
const previous = chdirMutex;
|
|
247
|
-
let release;
|
|
248
|
-
chdirMutex = new Promise((r) => { release = r; });
|
|
249
|
-
return previous.then(async () => {
|
|
250
|
-
try {
|
|
251
|
-
return await fn();
|
|
252
|
-
}
|
|
253
|
-
finally {
|
|
254
|
-
release();
|
|
255
|
-
}
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
/**
|
|
259
|
-
* 在持有全局 chdir 互斥锁期间,把进程 cwd 切到 workDir 执行 fn,结束后恢复。
|
|
260
|
-
* 用于包裹 session.send()+stream(),确保子进程在正确 workDir 派生。
|
|
261
|
-
*/
|
|
262
|
-
function runWithWorkDir(workDir, fn) {
|
|
263
|
-
return withChdirMutex(async () => {
|
|
264
|
-
const originalCwd = process.cwd();
|
|
265
|
-
if (workDir && workDir !== originalCwd) {
|
|
266
|
-
process.chdir(workDir);
|
|
267
|
-
}
|
|
268
|
-
try {
|
|
269
|
-
return await fn();
|
|
270
|
-
}
|
|
271
|
-
finally {
|
|
272
|
-
if (workDir && workDir !== originalCwd) {
|
|
273
|
-
process.chdir(originalCwd);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
159
|
function isStreamEvent(msg) {
|
|
279
160
|
return msg.type === 'stream_event';
|
|
280
161
|
}
|
|
@@ -291,6 +172,8 @@ function isResult(msg) {
|
|
|
291
172
|
function isSessionCorruptionError(msg) {
|
|
292
173
|
return /session\s*(not found|expired|corrupt)|no\s*conversation\s*found/i.test(msg);
|
|
293
174
|
}
|
|
175
|
+
const MAX_CAPTURED_STDERR_CHARS = 4000;
|
|
176
|
+
const MAX_EXPOSED_STDERR_CHARS = 500;
|
|
294
177
|
function appendStderrSnippet(previous, chunk) {
|
|
295
178
|
const next = previous + chunk;
|
|
296
179
|
if (next.length <= MAX_CAPTURED_STDERR_CHARS)
|
|
@@ -320,166 +203,163 @@ function enrichClaudeErrorMessage(message, stderr) {
|
|
|
320
203
|
}
|
|
321
204
|
return message;
|
|
322
205
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
*/
|
|
331
|
-
async function getOrCreateSession(sessionId, workDir, model, permissionMode, onStderr) {
|
|
332
|
-
// 刷新 Claude 环境变量(支持 cc switch 后无需重启即可生效)
|
|
333
|
-
refreshClaudeEnvToProcess();
|
|
334
|
-
const resolvedModel = model?.trim() || process.env.ANTHROPIC_MODEL?.trim() || 'claude-opus-4-5';
|
|
335
|
-
if (activeSessions.size >= MAX_ACTIVE_SESSIONS) {
|
|
336
|
-
throw new Error(`Session pool is full (${MAX_ACTIVE_SESSIONS}). Cannot create new session.`);
|
|
206
|
+
export class ClaudeSDKAdapter {
|
|
207
|
+
toolId = 'claude-sdk';
|
|
208
|
+
/**
|
|
209
|
+
* 清理所有活跃的查询
|
|
210
|
+
*/
|
|
211
|
+
static destroy() {
|
|
212
|
+
// query() API 的 Query 对象通过 abortController.abort() 清理
|
|
337
213
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
};
|
|
343
|
-
const baseUrl = process.env.ANTHROPIC_BASE_URL ?? '(default)';
|
|
344
|
-
log.info(`[V2] getOrCreateSession model param=${String(model ?? '')} resolved=${resolvedModel} baseUrl=${baseUrl} workDir=${workDir}`);
|
|
345
|
-
// NOTE: process.chdir() 是进程级全局副作用,在并发服务器中不理想。
|
|
346
|
-
// 但 SDK 的 createSession/resumeSession 不接受 cwd 参数,且这些调用是同步的,
|
|
347
|
-
// 所以 mutex + try/finally 已是最优方案。如果 SDK 未来支持 cwd 选项,应移除 chdir。
|
|
348
|
-
return withChdirMutex(() => {
|
|
349
|
-
let session;
|
|
350
|
-
const originalCwd = process.cwd();
|
|
214
|
+
/**
|
|
215
|
+
* List sessions for a directory using the SDK's listSessions API.
|
|
216
|
+
*/
|
|
217
|
+
static async listSessionsForDir(workDir, limit = 20) {
|
|
351
218
|
try {
|
|
352
|
-
|
|
353
|
-
process.chdir(workDir);
|
|
354
|
-
}
|
|
355
|
-
if (sessionId) {
|
|
356
|
-
// 优先复用内存中已有的 SDKSession,避免每次都启动新进程。
|
|
357
|
-
// 仅当 workDir 与创建时一致才复用:否则子进程 cwd 已固定在旧目录,
|
|
358
|
-
// 需走 resume 重新派生(在当前 workDir 启动新子进程)。
|
|
359
|
-
const existing = activeSessions.get(sessionId);
|
|
360
|
-
if (existing && sessionWorkDirs.get(sessionId) === workDir) {
|
|
361
|
-
log.info(`Reusing existing in-memory session: ${sessionId}`);
|
|
362
|
-
sessionLastUsed.set(sessionId, Date.now());
|
|
363
|
-
return { session: existing, sessionId };
|
|
364
|
-
}
|
|
365
|
-
// 内存中没有(或 workDir 变了),尝试通过 resume 恢复(会启动新 CLI 进程)
|
|
366
|
-
try {
|
|
367
|
-
log.info(`Attempting to resume session: ${sessionId}`);
|
|
368
|
-
session = unstable_v2_resumeSession(sessionId, sessionOptions);
|
|
369
|
-
activeSessions.set(sessionId, session);
|
|
370
|
-
sessionWorkDirs.set(sessionId, workDir);
|
|
371
|
-
sessionLastUsed.set(sessionId, Date.now());
|
|
372
|
-
log.info(`Successfully resumed session: ${sessionId}`);
|
|
373
|
-
return { session, sessionId };
|
|
374
|
-
}
|
|
375
|
-
catch (err) {
|
|
376
|
-
log.warn(`Failed to resume session ${sessionId}, creating new one: ${err}`);
|
|
377
|
-
// 恢复失败,创建新会话
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
// 没有指定 sessionId 时,尝试自动恢复 Claude Code CLI 的最新 session
|
|
381
|
-
// 实现手机/电脑无缝切换:同目录下默认共享同一个对话
|
|
382
|
-
if (!sessionId) {
|
|
383
|
-
const latest = findLatestClaudeSession(workDir);
|
|
384
|
-
if (latest) {
|
|
385
|
-
// 检测 CLI 是否正在使用该 session(用于日志,不阻止 resume)
|
|
386
|
-
const cliActive = isCliSessionActive(latest.sessionId, latest.filePath);
|
|
387
|
-
if (cliActive) {
|
|
388
|
-
log.info(`CLI is actively using session ${latest.sessionId}, attempting resume anyway (SDK handles concurrency)`);
|
|
389
|
-
}
|
|
390
|
-
// 验证文件内容一致性
|
|
391
|
-
if (validateSessionFile(latest.filePath, latest.sessionId)) {
|
|
392
|
-
try {
|
|
393
|
-
log.info(`Auto-resuming latest CLI session: ${latest.sessionId}${cliActive ? ' (CLI active)' : ''}`);
|
|
394
|
-
session = unstable_v2_resumeSession(latest.sessionId, sessionOptions);
|
|
395
|
-
activeSessions.set(latest.sessionId, session);
|
|
396
|
-
sessionWorkDirs.set(latest.sessionId, workDir);
|
|
397
|
-
sessionLastUsed.set(latest.sessionId, Date.now());
|
|
398
|
-
log.info(`Successfully auto-resumed CLI session: ${latest.sessionId}`);
|
|
399
|
-
return { session, sessionId: latest.sessionId };
|
|
400
|
-
}
|
|
401
|
-
catch (err) {
|
|
402
|
-
log.warn(`Failed to auto-resume CLI session ${latest.sessionId}, creating new one: ${err}`);
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
else {
|
|
406
|
-
log.warn(`Session file validation failed for ${latest.sessionId}, skipping`);
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
// 创建新会话
|
|
411
|
-
session = unstable_v2_createSession(sessionOptions);
|
|
412
|
-
// 新会话的 sessionId 需要从第一个消息中获取
|
|
413
|
-
// 暂时返回 undefined,稍后在 init 消息中获取
|
|
414
|
-
const tempId = `pending-${++sessionSeq}`;
|
|
415
|
-
activeSessions.set(tempId, session);
|
|
416
|
-
sessionWorkDirs.set(tempId, workDir);
|
|
417
|
-
sessionLastUsed.set(tempId, Date.now());
|
|
418
|
-
log.info(`Created new session (tempId: ${tempId})`);
|
|
419
|
-
return { session, sessionId: tempId, wasReused: false };
|
|
219
|
+
return await listSessions({ dir: workDir, limit });
|
|
420
220
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
log.warn(`Failed to list sessions for ${workDir}: ${err}`);
|
|
223
|
+
return [];
|
|
425
224
|
}
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
export class ClaudeSDKAdapter {
|
|
429
|
-
toolId = 'claude-sdk';
|
|
225
|
+
}
|
|
430
226
|
/**
|
|
431
|
-
*
|
|
227
|
+
* Get session messages for a given session ID.
|
|
432
228
|
*/
|
|
433
|
-
static
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
229
|
+
static async getSessionMessagesForId(sessionId, workDir, limit = 50) {
|
|
230
|
+
try {
|
|
231
|
+
return await getSessionMessages(sessionId, { dir: workDir, limit });
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
log.warn(`Failed to get session messages for ${sessionId}: ${err}`);
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Delete a session by ID.
|
|
240
|
+
*/
|
|
241
|
+
static async deleteSessionById(sessionId, workDir) {
|
|
242
|
+
try {
|
|
243
|
+
await deleteSession(sessionId, { dir: workDir });
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
log.warn(`Failed to delete session ${sessionId}: ${err}`);
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Rename a session.
|
|
253
|
+
*/
|
|
254
|
+
static async renameSessionById(sessionId, title, workDir) {
|
|
255
|
+
try {
|
|
256
|
+
await renameSession(sessionId, title, { dir: workDir });
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
log.warn(`Failed to rename session ${sessionId}: ${err}`);
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Fork a session.
|
|
266
|
+
*/
|
|
267
|
+
static async forkSessionById(sessionId, workDir) {
|
|
268
|
+
try {
|
|
269
|
+
const result = await forkSession(sessionId, { dir: workDir });
|
|
270
|
+
return result.sessionId;
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
log.warn(`Failed to fork session ${sessionId}: ${err}`);
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Create a short-lived query for fetching session info (models, context, etc).
|
|
279
|
+
* The caller must close the returned query when done.
|
|
280
|
+
*/
|
|
281
|
+
static async createInfoQuery(workDir, model) {
|
|
282
|
+
const resolvedModel = model?.trim() || process.env.ANTHROPIC_MODEL?.trim() || 'claude-opus-4-5';
|
|
283
|
+
return query({
|
|
284
|
+
prompt: '',
|
|
285
|
+
options: {
|
|
286
|
+
cwd: workDir,
|
|
287
|
+
model: resolvedModel,
|
|
288
|
+
permissionMode: 'default',
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Get available models for a work directory.
|
|
294
|
+
*/
|
|
295
|
+
static async getSupportedModels(workDir, model) {
|
|
296
|
+
let q;
|
|
297
|
+
try {
|
|
298
|
+
q = await this.createInfoQuery(workDir, model);
|
|
299
|
+
return await q.supportedModels();
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
log.warn(`Failed to get supported models: ${err}`);
|
|
303
|
+
return [];
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
if (q) {
|
|
307
|
+
try {
|
|
308
|
+
await q.return(undefined);
|
|
439
309
|
}
|
|
440
|
-
|
|
441
|
-
catch {
|
|
442
|
-
/* ignore */
|
|
310
|
+
catch { /* ignore */ }
|
|
443
311
|
}
|
|
444
312
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Get context usage for a work directory.
|
|
316
|
+
*/
|
|
317
|
+
static async getContextUsage(workDir, model) {
|
|
318
|
+
let q;
|
|
319
|
+
try {
|
|
320
|
+
q = await this.createInfoQuery(workDir, model);
|
|
321
|
+
return await q.getContextUsage();
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
log.warn(`Failed to get context usage: ${err}`);
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
if (q) {
|
|
329
|
+
try {
|
|
330
|
+
await q.return(undefined);
|
|
331
|
+
}
|
|
332
|
+
catch { /* ignore */ }
|
|
452
333
|
}
|
|
453
334
|
}
|
|
454
|
-
activeSessions.clear();
|
|
455
|
-
sessionLastUsed.clear();
|
|
456
|
-
sessionWorkDirs.clear();
|
|
457
335
|
}
|
|
458
336
|
/**
|
|
459
|
-
*
|
|
460
|
-
* Useful when the caller knows a session is corrupted.
|
|
337
|
+
* Get account info for a work directory.
|
|
461
338
|
*/
|
|
462
|
-
static
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
339
|
+
static async getAccountInfo(workDir, model) {
|
|
340
|
+
let q;
|
|
341
|
+
try {
|
|
342
|
+
q = await this.createInfoQuery(workDir, model);
|
|
343
|
+
return await q.accountInfo();
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
log.warn(`Failed to get account info: ${err}`);
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
if (q) {
|
|
351
|
+
try {
|
|
352
|
+
await q.return(undefined);
|
|
353
|
+
}
|
|
354
|
+
catch { /* ignore */ }
|
|
467
355
|
}
|
|
468
|
-
catch { /* ignore */ }
|
|
469
|
-
activeSessions.delete(sessionId);
|
|
470
|
-
sessionLastUsed.delete(sessionId);
|
|
471
|
-
sessionWorkDirs.delete(sessionId);
|
|
472
|
-
log.info(`Explicitly removed session: ${sessionId}`);
|
|
473
356
|
}
|
|
474
357
|
}
|
|
475
358
|
run(prompt, sessionId, workDir, callbacks, options) {
|
|
476
|
-
|
|
359
|
+
// 刷新 Claude 环境变量(支持 cc switch 后无需重启即可生效)
|
|
360
|
+
refreshClaudeEnvToProcess();
|
|
477
361
|
const abortController = new AbortController();
|
|
478
|
-
let streamClosed = false;
|
|
479
|
-
let actualSessionId;
|
|
480
|
-
let pendingTempId; // 记录临时 ID,用于 abort 时清理
|
|
481
362
|
let runSettled = false;
|
|
482
|
-
let currentStream; // 用于 abort 时立即中断 stream
|
|
483
363
|
let recentStderr = '';
|
|
484
364
|
const permissionMode = options?.skipPermissions
|
|
485
365
|
? 'bypassPermissions'
|
|
@@ -488,79 +368,70 @@ export class ClaudeSDKAdapter {
|
|
|
488
368
|
: options?.permissionMode === 'plan'
|
|
489
369
|
? 'plan'
|
|
490
370
|
: 'default';
|
|
491
|
-
const
|
|
492
|
-
|
|
371
|
+
const resolvedModel = options?.model?.trim() || process.env.ANTHROPIC_MODEL?.trim() || 'claude-opus-4-5';
|
|
372
|
+
const baseUrl = process.env.ANTHROPIC_BASE_URL ?? '(default)';
|
|
373
|
+
log.info(`[query] run() entry model=${resolvedModel} baseUrl=${baseUrl} sessionId=${sessionId ?? 'new'} workDir=${workDir}`);
|
|
374
|
+
const runQuery = async () => {
|
|
375
|
+
let accumulated = '';
|
|
376
|
+
let accumulatedThinking = '';
|
|
377
|
+
const toolStats = {};
|
|
378
|
+
let actualSessionId;
|
|
379
|
+
let hadSessionInvalid = false;
|
|
493
380
|
try {
|
|
494
|
-
//
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
381
|
+
// 先尝试自动恢复 CLI 的最新 session(如果用户没有指定 sessionId)
|
|
382
|
+
let resumeId = sessionId;
|
|
383
|
+
if (!resumeId) {
|
|
384
|
+
const latest = await findLatestClaudeSession(workDir);
|
|
385
|
+
if (latest) {
|
|
386
|
+
const cliActive = isCliSessionActive(latest.sessionId, latest.filePath);
|
|
387
|
+
if (cliActive) {
|
|
388
|
+
log.info(`CLI is actively using session ${latest.sessionId}, attempting resume anyway`);
|
|
389
|
+
}
|
|
390
|
+
if (validateSessionFile(latest.filePath, latest.sessionId)) {
|
|
391
|
+
resumeId = latest.sessionId;
|
|
392
|
+
log.info(`Auto-resuming latest CLI session: ${latest.sessionId}${cliActive ? ' (CLI active)' : ''}`);
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
log.warn(`Session file validation failed for ${latest.sessionId}, skipping`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
508
398
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
399
|
+
const q = query({
|
|
400
|
+
prompt: INTERACTIVE_CONTEXT + prompt,
|
|
401
|
+
options: {
|
|
402
|
+
cwd: workDir,
|
|
403
|
+
model: resolvedModel,
|
|
404
|
+
permissionMode,
|
|
405
|
+
// 启用完整工具集,与 cc 终端一致
|
|
406
|
+
tools: { type: 'preset', preset: 'claude_code' },
|
|
407
|
+
// 启用所有技能(superpowers, playwright 等),与 cc 终端一致
|
|
408
|
+
skills: 'all',
|
|
409
|
+
...(resumeId ? { resume: resumeId } : {}),
|
|
410
|
+
...(options?.fallbackModel ? { fallbackModel: options.fallbackModel } : {}),
|
|
411
|
+
...(options?.disallowedTools?.length ? { disallowedTools: options.disallowedTools } : {}),
|
|
412
|
+
abortController,
|
|
413
|
+
stderr: (data) => {
|
|
414
|
+
recentStderr = appendStderrSnippet(recentStderr, data);
|
|
415
|
+
},
|
|
416
|
+
},
|
|
520
417
|
});
|
|
521
|
-
let accumulated = '';
|
|
522
|
-
let accumulatedThinking = '';
|
|
523
|
-
const toolStats = {};
|
|
524
418
|
try {
|
|
525
|
-
for await (const msg of
|
|
419
|
+
for await (const msg of q) {
|
|
526
420
|
if (abortController.signal.aborted) {
|
|
527
|
-
log.info('
|
|
421
|
+
log.info('Query aborted by user');
|
|
528
422
|
break;
|
|
529
423
|
}
|
|
530
424
|
// 获取实际的 sessionId(从 init 消息中)
|
|
531
425
|
if (isSystemInit(msg)) {
|
|
532
426
|
const initMsg = msg;
|
|
533
|
-
// 记录 session 加载的插件和技能
|
|
534
427
|
const pluginNames = initMsg.plugins?.map(p => p.name).join(', ') ?? 'none';
|
|
535
428
|
const skillCount = initMsg.skills?.length ?? 0;
|
|
536
429
|
const toolCount = initMsg.tools?.length ?? 0;
|
|
537
|
-
log.info(`[
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
const idToClean = actualSessionId ?? pendingTempId;
|
|
543
|
-
const inheritedWorkDir = idToClean ? sessionWorkDirs.get(idToClean) : undefined;
|
|
544
|
-
if (idToClean?.startsWith('pending-')) {
|
|
545
|
-
activeSessions.delete(idToClean);
|
|
546
|
-
}
|
|
547
|
-
activeSessions.set(newSessionId, session);
|
|
548
|
-
if (inheritedWorkDir !== undefined) {
|
|
549
|
-
sessionWorkDirs.set(newSessionId, inheritedWorkDir);
|
|
550
|
-
}
|
|
551
|
-
sessionLastUsed.set(newSessionId, Date.now());
|
|
552
|
-
if (idToClean) {
|
|
553
|
-
sessionLastUsed.delete(idToClean);
|
|
554
|
-
sessionWorkDirs.delete(idToClean);
|
|
555
|
-
}
|
|
556
|
-
// 更新 runningSessions:移除旧 ID,添加新 ID
|
|
557
|
-
if (idToClean)
|
|
558
|
-
runningSessions.delete(idToClean);
|
|
559
|
-
runningSessions.add(newSessionId);
|
|
560
|
-
trackedRunningId = newSessionId;
|
|
561
|
-
actualSessionId = newSessionId;
|
|
562
|
-
log.info(`[V2] Got actual sessionId: ${newSessionId}`);
|
|
563
|
-
callbacks.onSessionId?.(newSessionId);
|
|
430
|
+
log.info(`[query] Init: plugins=[${pluginNames}], skills=${skillCount}, tools=${toolCount}`);
|
|
431
|
+
if (initMsg.session_id) {
|
|
432
|
+
actualSessionId = initMsg.session_id;
|
|
433
|
+
log.info(`[query] Got sessionId: ${actualSessionId}`);
|
|
434
|
+
callbacks.onSessionId?.(actualSessionId);
|
|
564
435
|
}
|
|
565
436
|
continue;
|
|
566
437
|
}
|
|
@@ -592,25 +463,21 @@ export class ClaudeSDKAdapter {
|
|
|
592
463
|
}
|
|
593
464
|
// 处理结果消息
|
|
594
465
|
if (isResult(msg)) {
|
|
595
|
-
streamClosed = true;
|
|
596
466
|
const m = msg;
|
|
597
467
|
const success = m.subtype === 'success';
|
|
598
468
|
const errs = m.errors ?? [];
|
|
599
|
-
|
|
600
|
-
|
|
469
|
+
// 更新 sessionId(如果 init 消息中没有)
|
|
470
|
+
if (m.session_id && !actualSessionId) {
|
|
471
|
+
actualSessionId = m.session_id;
|
|
472
|
+
callbacks.onSessionId?.(actualSessionId);
|
|
473
|
+
}
|
|
474
|
+
log.info(`[query] Result: subtype=${m.subtype}, num_turns=${m.num_turns}, sessionId=${actualSessionId ?? 'unknown'}`);
|
|
601
475
|
if (!success) {
|
|
602
476
|
runSettled = true;
|
|
603
477
|
const noConvErr = errs.find((e) => e.includes('No conversation found') || e.includes('session not found'));
|
|
604
478
|
if (noConvErr) {
|
|
605
|
-
log.warn(`Session ${actualSessionId} not found
|
|
606
|
-
|
|
607
|
-
activeSessions.delete(actualSessionId);
|
|
608
|
-
sessionLastUsed.delete(actualSessionId);
|
|
609
|
-
try {
|
|
610
|
-
session.close();
|
|
611
|
-
}
|
|
612
|
-
catch { /* ignore */ }
|
|
613
|
-
}
|
|
479
|
+
log.warn(`Session ${actualSessionId} not found`);
|
|
480
|
+
hadSessionInvalid = true;
|
|
614
481
|
callbacks.onSessionInvalid?.();
|
|
615
482
|
}
|
|
616
483
|
const errMsg = enrichClaudeErrorMessage(errs[0] || '未知错误', recentStderr);
|
|
@@ -640,7 +507,7 @@ export class ClaudeSDKAdapter {
|
|
|
640
507
|
}
|
|
641
508
|
}
|
|
642
509
|
// 如果流正常结束但没有收到 result 消息
|
|
643
|
-
if (!
|
|
510
|
+
if (!runSettled) {
|
|
644
511
|
if (accumulated) {
|
|
645
512
|
log.info('Stream ended without result message, using accumulated text');
|
|
646
513
|
runSettled = true;
|
|
@@ -655,7 +522,6 @@ export class ClaudeSDKAdapter {
|
|
|
655
522
|
});
|
|
656
523
|
}
|
|
657
524
|
else {
|
|
658
|
-
// 流结束但无 result 也无 accumulated:必须触发回调,否则 Promise 永远挂起
|
|
659
525
|
log.warn('Stream ended with no result and no accumulated text, calling onError to prevent stuck state');
|
|
660
526
|
runSettled = true;
|
|
661
527
|
callbacks.onError(enrichClaudeErrorMessage('AI 响应异常结束(无输出),请重试', recentStderr));
|
|
@@ -663,81 +529,41 @@ export class ClaudeSDKAdapter {
|
|
|
663
529
|
}
|
|
664
530
|
}
|
|
665
531
|
finally {
|
|
666
|
-
//
|
|
667
|
-
activeStreams.delete(stream);
|
|
532
|
+
// Query 的 cleanup 由 SDK 内部管理
|
|
668
533
|
}
|
|
669
534
|
}
|
|
670
535
|
catch (err) {
|
|
671
536
|
if (abortController.signal.aborted) {
|
|
672
|
-
log.info('
|
|
673
|
-
// 清理 pending tempId(abort 可能在 init 消息之前发生)
|
|
674
|
-
const idToClean = actualSessionId ?? pendingTempId;
|
|
675
|
-
if (idToClean?.startsWith('pending-')) {
|
|
676
|
-
activeSessions.delete(idToClean);
|
|
677
|
-
log.info(`Cleaned up pending session: ${idToClean}`);
|
|
678
|
-
}
|
|
537
|
+
log.info('Query run aborted');
|
|
679
538
|
return;
|
|
680
539
|
}
|
|
681
540
|
runSettled = true;
|
|
682
541
|
const errorObj = err;
|
|
683
542
|
const msg = enrichClaudeErrorMessage(errorObj.message || String(err), recentStderr);
|
|
684
|
-
log.error(`Claude SDK
|
|
543
|
+
log.error(`Claude SDK error: ${msg}`);
|
|
685
544
|
if (errorObj.stack) {
|
|
686
545
|
log.error(`Error stack: ${errorObj.stack}`);
|
|
687
546
|
}
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
if (errIdToClean?.startsWith('pending-')) {
|
|
691
|
-
activeSessions.delete(errIdToClean);
|
|
692
|
-
log.warn(`Cleaned up pending session after error: ${errIdToClean}`);
|
|
693
|
-
}
|
|
694
|
-
// If error suggests a corrupted session, remove it from cache to prevent reuse
|
|
695
|
-
if (actualSessionId && isSessionCorruptionError(msg)) {
|
|
696
|
-
const corrupted = activeSessions.get(actualSessionId);
|
|
697
|
-
activeSessions.delete(actualSessionId);
|
|
698
|
-
sessionLastUsed.delete(actualSessionId);
|
|
699
|
-
if (corrupted) {
|
|
700
|
-
try {
|
|
701
|
-
corrupted.close();
|
|
702
|
-
}
|
|
703
|
-
catch { /* ignore */ }
|
|
704
|
-
}
|
|
705
|
-
log.warn(`Removed corrupted session ${actualSessionId} after error: ${msg}`);
|
|
547
|
+
if (isSessionCorruptionError(msg)) {
|
|
548
|
+
log.warn(`Session corruption detected: ${msg}`);
|
|
706
549
|
callbacks.onSessionInvalid?.();
|
|
707
550
|
}
|
|
708
551
|
callbacks.onError(msg);
|
|
709
552
|
}
|
|
710
|
-
finally {
|
|
711
|
-
// 无论成功、失败还是 abort,都从运行中集合移除
|
|
712
|
-
if (trackedRunningId) {
|
|
713
|
-
runningSessions.delete(trackedRunningId);
|
|
714
|
-
}
|
|
715
|
-
// 也清理 actualSessionId(可能在 init 后更新了)
|
|
716
|
-
if (actualSessionId && actualSessionId !== trackedRunningId) {
|
|
717
|
-
runningSessions.delete(actualSessionId);
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
553
|
};
|
|
721
|
-
//
|
|
722
|
-
|
|
554
|
+
// 启动查询(不等待),catch 兜底防止 unhandledRejection
|
|
555
|
+
runQuery().catch((err) => {
|
|
723
556
|
if (!runSettled) {
|
|
724
557
|
runSettled = true;
|
|
725
558
|
const msg = err instanceof Error ? err.message : String(err);
|
|
726
|
-
log.error(`Unhandled
|
|
559
|
+
log.error(`Unhandled runQuery error: ${msg}`);
|
|
727
560
|
callbacks.onError(msg);
|
|
728
561
|
}
|
|
729
562
|
});
|
|
730
563
|
return {
|
|
731
564
|
abort: () => {
|
|
732
|
-
log.info('Aborting
|
|
565
|
+
log.info('Aborting query');
|
|
733
566
|
abortController.abort();
|
|
734
|
-
// 立即中断 stream,不等下一条消息
|
|
735
|
-
if (currentStream) {
|
|
736
|
-
try {
|
|
737
|
-
currentStream.return?.();
|
|
738
|
-
}
|
|
739
|
-
catch { /* ignore */ }
|
|
740
|
-
}
|
|
741
567
|
},
|
|
742
568
|
};
|
|
743
569
|
}
|