@wu529778790/open-im 1.11.1-beta.6 → 1.11.1-beta.8
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/dist/adapters/claude-sdk-adapter.d.ts +11 -19
- package/dist/adapters/claude-sdk-adapter.js +99 -421
- package/dist/adapters/registry.js +1 -2
- package/dist/commands/handler.js +25 -42
- package/dist/session/session-manager.d.ts +5 -16
- package/dist/session/session-manager.js +28 -121
- package/dist/shared/ai-task.js +0 -1
- package/package.json +3 -2
|
@@ -1,13 +1,12 @@
|
|
|
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
|
*/
|
|
9
|
+
import type { SDKSessionInfo } from '@anthropic-ai/claude-agent-sdk';
|
|
11
10
|
import type { ToolAdapter, RunCallbacks, RunOptions, RunHandle } from './tool-adapter.interface.js';
|
|
12
11
|
interface ClaudeSessionMeta {
|
|
13
12
|
sessionId: string;
|
|
@@ -16,26 +15,19 @@ interface ClaudeSessionMeta {
|
|
|
16
15
|
size: number;
|
|
17
16
|
}
|
|
18
17
|
/**
|
|
19
|
-
*
|
|
20
|
-
* 只返回 JSONL 文件(排除子目录如 subagents/),按修改时间倒序。
|
|
21
|
-
* @param homeOverride 测试用:覆盖 homedir() 的返回值
|
|
22
|
-
*/
|
|
23
|
-
export declare function findLatestClaudeSession(workDir: string, homeOverride?: string): ClaudeSessionMeta | undefined;
|
|
24
|
-
/**
|
|
25
|
-
* 由 initAdapters 根据配置调用。ttlMinutes≤0 时关闭空闲回收(仍受 MAX_ACTIVE_SESSIONS 限制)。
|
|
18
|
+
* Find the latest CLI session for a work directory using the SDK's listSessions.
|
|
26
19
|
*/
|
|
27
|
-
export declare function
|
|
20
|
+
export declare function findLatestClaudeSession(workDir: string): Promise<ClaudeSessionMeta | undefined>;
|
|
28
21
|
export declare class ClaudeSDKAdapter implements ToolAdapter {
|
|
29
22
|
readonly toolId = "claude-sdk";
|
|
30
23
|
/**
|
|
31
|
-
*
|
|
24
|
+
* 清理所有活跃的查询
|
|
32
25
|
*/
|
|
33
26
|
static destroy(): void;
|
|
34
27
|
/**
|
|
35
|
-
*
|
|
36
|
-
* Useful when the caller knows a session is corrupted.
|
|
28
|
+
* List sessions for a directory using the SDK's listSessions API.
|
|
37
29
|
*/
|
|
38
|
-
static
|
|
30
|
+
static listSessionsForDir(workDir: string, limit?: number): Promise<SDKSessionInfo[]>;
|
|
39
31
|
run(prompt: string, sessionId: string | undefined, workDir: string, callbacks: RunCallbacks, options?: RunOptions): RunHandle;
|
|
40
32
|
}
|
|
41
33
|
export {};
|
|
@@ -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 } 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';
|
|
@@ -53,8 +51,6 @@ loadUserPluginSettings();
|
|
|
53
51
|
* ~/.claude/projects/-Users-mac-github-open-im/
|
|
54
52
|
*/
|
|
55
53
|
function workDirToProjectPath(workDir) {
|
|
56
|
-
// Claude Code 将路径中的 / 替换为 -,leading - 保留
|
|
57
|
-
// /Users/mac/github/open-im → -Users-mac-github-open-im
|
|
58
54
|
return workDir.replace(/\//g, '-');
|
|
59
55
|
}
|
|
60
56
|
/**
|
|
@@ -71,14 +67,9 @@ function workDirToProjectPath(workDir) {
|
|
|
71
67
|
*/
|
|
72
68
|
function isCliSessionActive(sessionId, sessionFilePath) {
|
|
73
69
|
try {
|
|
74
|
-
// macOS/Linux: 用 ps 搜索包含该 sessionId 的 claude 进程
|
|
75
|
-
// 排除 open-im 自己的 SDK 子进程(路径含 claude-agent-sdk),只匹配用户交互式 CLI
|
|
76
|
-
// -F 固定字符串匹配,避免正则意外;-- 防止 sessionId 被误认为 flag
|
|
77
70
|
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
71
|
if (result.trim().length === 0)
|
|
79
72
|
return false;
|
|
80
|
-
// 进程存在,但可能只是 idle 在终端等输入。检查文件 mtime:
|
|
81
|
-
// 如果 session 文件超过 30 秒未修改,说明 CLI 没在活跃处理。
|
|
82
73
|
if (sessionFilePath) {
|
|
83
74
|
try {
|
|
84
75
|
const stat = statSync(sessionFilePath);
|
|
@@ -99,51 +90,28 @@ function isCliSessionActive(sessionId, sessionFilePath) {
|
|
|
99
90
|
}
|
|
100
91
|
}
|
|
101
92
|
/**
|
|
102
|
-
*
|
|
103
|
-
* 只返回 JSONL 文件(排除子目录如 subagents/),按修改时间倒序。
|
|
104
|
-
* @param homeOverride 测试用:覆盖 homedir() 的返回值
|
|
93
|
+
* Find the latest CLI session for a work directory using the SDK's listSessions.
|
|
105
94
|
*/
|
|
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
|
-
}
|
|
95
|
+
export async function findLatestClaudeSession(workDir) {
|
|
112
96
|
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
|
-
}
|
|
97
|
+
const sessions = await listSessions({ dir: workDir, limit: 1 });
|
|
135
98
|
if (sessions.length === 0) {
|
|
136
|
-
log.info(`No
|
|
99
|
+
log.info(`No sessions found for ${workDir}`);
|
|
137
100
|
return undefined;
|
|
138
101
|
}
|
|
139
|
-
// 按修改时间倒序,最新的在前
|
|
140
|
-
sessions.sort((a, b) => b.mtime - a.mtime);
|
|
141
102
|
const latest = sessions[0];
|
|
142
|
-
|
|
143
|
-
|
|
103
|
+
const projectDir = join(homedir(), '.claude', 'projects', workDirToProjectPath(workDir));
|
|
104
|
+
const filePath = join(projectDir, `${latest.sessionId}.jsonl`);
|
|
105
|
+
log.info(`Found latest session via SDK: ${latest.sessionId} (lastModified: ${new Date(latest.lastModified).toISOString()})`);
|
|
106
|
+
return {
|
|
107
|
+
sessionId: latest.sessionId,
|
|
108
|
+
mtime: latest.lastModified,
|
|
109
|
+
filePath,
|
|
110
|
+
size: latest.fileSize ?? 0,
|
|
111
|
+
};
|
|
144
112
|
}
|
|
145
113
|
catch (err) {
|
|
146
|
-
log.warn(`Failed to
|
|
114
|
+
log.warn(`Failed to list sessions via SDK: ${err}`);
|
|
147
115
|
return undefined;
|
|
148
116
|
}
|
|
149
117
|
}
|
|
@@ -177,104 +145,6 @@ function validateSessionFile(filePath, expectedSessionId) {
|
|
|
177
145
|
}
|
|
178
146
|
}
|
|
179
147
|
}
|
|
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
148
|
function isStreamEvent(msg) {
|
|
279
149
|
return msg.type === 'stream_event';
|
|
280
150
|
}
|
|
@@ -291,6 +161,8 @@ function isResult(msg) {
|
|
|
291
161
|
function isSessionCorruptionError(msg) {
|
|
292
162
|
return /session\s*(not found|expired|corrupt)|no\s*conversation\s*found/i.test(msg);
|
|
293
163
|
}
|
|
164
|
+
const MAX_CAPTURED_STDERR_CHARS = 4000;
|
|
165
|
+
const MAX_EXPOSED_STDERR_CHARS = 500;
|
|
294
166
|
function appendStderrSnippet(previous, chunk) {
|
|
295
167
|
const next = previous + chunk;
|
|
296
168
|
if (next.length <= MAX_CAPTURED_STDERR_CHARS)
|
|
@@ -320,166 +192,32 @@ function enrichClaudeErrorMessage(message, stderr) {
|
|
|
320
192
|
}
|
|
321
193
|
return message;
|
|
322
194
|
}
|
|
323
|
-
/**
|
|
324
|
-
* 获取或创建 SDKSession
|
|
325
|
-
* @param sessionId 已有的 sessionId,如果为 undefined 则创建新会话
|
|
326
|
-
* @param workDir 工作目录
|
|
327
|
-
* @param model 模型名称
|
|
328
|
-
* @param permissionMode 权限模式
|
|
329
|
-
* @returns SDKSession 对象和实际的 sessionId
|
|
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.`);
|
|
337
|
-
}
|
|
338
|
-
const sessionOptions = {
|
|
339
|
-
model: resolvedModel,
|
|
340
|
-
permissionMode,
|
|
341
|
-
stderr: onStderr,
|
|
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();
|
|
351
|
-
try {
|
|
352
|
-
if (workDir && workDir !== originalCwd) {
|
|
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 };
|
|
420
|
-
}
|
|
421
|
-
finally {
|
|
422
|
-
if (workDir && workDir !== originalCwd) {
|
|
423
|
-
process.chdir(originalCwd);
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
195
|
export class ClaudeSDKAdapter {
|
|
429
196
|
toolId = 'claude-sdk';
|
|
430
197
|
/**
|
|
431
|
-
*
|
|
198
|
+
* 清理所有活跃的查询
|
|
432
199
|
*/
|
|
433
200
|
static destroy() {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
try {
|
|
437
|
-
if (stream && typeof stream.return === 'function') {
|
|
438
|
-
stream.return();
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
catch {
|
|
442
|
-
/* ignore */
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
activeStreams.clear();
|
|
446
|
-
for (const session of activeSessions.values()) {
|
|
447
|
-
try {
|
|
448
|
-
session.close();
|
|
449
|
-
}
|
|
450
|
-
catch {
|
|
451
|
-
/* ignore */
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
activeSessions.clear();
|
|
455
|
-
sessionLastUsed.clear();
|
|
456
|
-
sessionWorkDirs.clear();
|
|
201
|
+
// query() API 的 Query 对象通过 abortController.abort() 清理
|
|
202
|
+
// 不再需要手动管理 session pool
|
|
457
203
|
}
|
|
458
204
|
/**
|
|
459
|
-
*
|
|
460
|
-
* Useful when the caller knows a session is corrupted.
|
|
205
|
+
* List sessions for a directory using the SDK's listSessions API.
|
|
461
206
|
*/
|
|
462
|
-
static
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
activeSessions.delete(sessionId);
|
|
470
|
-
sessionLastUsed.delete(sessionId);
|
|
471
|
-
sessionWorkDirs.delete(sessionId);
|
|
472
|
-
log.info(`Explicitly removed session: ${sessionId}`);
|
|
207
|
+
static async listSessionsForDir(workDir, limit = 20) {
|
|
208
|
+
try {
|
|
209
|
+
return await listSessions({ dir: workDir, limit });
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
log.warn(`Failed to list sessions for ${workDir}: ${err}`);
|
|
213
|
+
return [];
|
|
473
214
|
}
|
|
474
215
|
}
|
|
475
216
|
run(prompt, sessionId, workDir, callbacks, options) {
|
|
476
|
-
|
|
217
|
+
// 刷新 Claude 环境变量(支持 cc switch 后无需重启即可生效)
|
|
218
|
+
refreshClaudeEnvToProcess();
|
|
477
219
|
const abortController = new AbortController();
|
|
478
|
-
let streamClosed = false;
|
|
479
|
-
let actualSessionId;
|
|
480
|
-
let pendingTempId; // 记录临时 ID,用于 abort 时清理
|
|
481
220
|
let runSettled = false;
|
|
482
|
-
let currentStream; // 用于 abort 时立即中断 stream
|
|
483
221
|
let recentStderr = '';
|
|
484
222
|
const permissionMode = options?.skipPermissions
|
|
485
223
|
? 'bypassPermissions'
|
|
@@ -488,79 +226,64 @@ export class ClaudeSDKAdapter {
|
|
|
488
226
|
: options?.permissionMode === 'plan'
|
|
489
227
|
? 'plan'
|
|
490
228
|
: 'default';
|
|
491
|
-
const
|
|
492
|
-
|
|
229
|
+
const resolvedModel = options?.model?.trim() || process.env.ANTHROPIC_MODEL?.trim() || 'claude-opus-4-5';
|
|
230
|
+
const baseUrl = process.env.ANTHROPIC_BASE_URL ?? '(default)';
|
|
231
|
+
log.info(`[query] run() entry model=${resolvedModel} baseUrl=${baseUrl} sessionId=${sessionId ?? 'new'} workDir=${workDir}`);
|
|
232
|
+
const runQuery = async () => {
|
|
233
|
+
let accumulated = '';
|
|
234
|
+
let accumulatedThinking = '';
|
|
235
|
+
const toolStats = {};
|
|
236
|
+
let actualSessionId;
|
|
237
|
+
let hadSessionInvalid = false;
|
|
493
238
|
try {
|
|
494
|
-
//
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
239
|
+
// 先尝试自动恢复 CLI 的最新 session(如果用户没有指定 sessionId)
|
|
240
|
+
let resumeId = sessionId;
|
|
241
|
+
if (!resumeId) {
|
|
242
|
+
const latest = await findLatestClaudeSession(workDir);
|
|
243
|
+
if (latest) {
|
|
244
|
+
const cliActive = isCliSessionActive(latest.sessionId, latest.filePath);
|
|
245
|
+
if (cliActive) {
|
|
246
|
+
log.info(`CLI is actively using session ${latest.sessionId}, attempting resume anyway`);
|
|
247
|
+
}
|
|
248
|
+
if (validateSessionFile(latest.filePath, latest.sessionId)) {
|
|
249
|
+
resumeId = latest.sessionId;
|
|
250
|
+
log.info(`Auto-resuming latest CLI session: ${latest.sessionId}${cliActive ? ' (CLI active)' : ''}`);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
log.warn(`Session file validation failed for ${latest.sessionId}, skipping`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
508
256
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
257
|
+
const q = query({
|
|
258
|
+
prompt,
|
|
259
|
+
options: {
|
|
260
|
+
cwd: workDir,
|
|
261
|
+
model: resolvedModel,
|
|
262
|
+
permissionMode,
|
|
263
|
+
...(resumeId ? { resume: resumeId } : {}),
|
|
264
|
+
abortController,
|
|
265
|
+
stderr: (data) => {
|
|
266
|
+
recentStderr = appendStderrSnippet(recentStderr, data);
|
|
267
|
+
},
|
|
268
|
+
},
|
|
520
269
|
});
|
|
521
|
-
let accumulated = '';
|
|
522
|
-
let accumulatedThinking = '';
|
|
523
|
-
const toolStats = {};
|
|
524
270
|
try {
|
|
525
|
-
for await (const msg of
|
|
271
|
+
for await (const msg of q) {
|
|
526
272
|
if (abortController.signal.aborted) {
|
|
527
|
-
log.info('
|
|
273
|
+
log.info('Query aborted by user');
|
|
528
274
|
break;
|
|
529
275
|
}
|
|
530
276
|
// 获取实际的 sessionId(从 init 消息中)
|
|
531
277
|
if (isSystemInit(msg)) {
|
|
532
278
|
const initMsg = msg;
|
|
533
|
-
// 记录 session 加载的插件和技能
|
|
534
279
|
const pluginNames = initMsg.plugins?.map(p => p.name).join(', ') ?? 'none';
|
|
535
280
|
const skillCount = initMsg.skills?.length ?? 0;
|
|
536
281
|
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);
|
|
282
|
+
log.info(`[query] Init: plugins=[${pluginNames}], skills=${skillCount}, tools=${toolCount}`);
|
|
283
|
+
if (initMsg.session_id) {
|
|
284
|
+
actualSessionId = initMsg.session_id;
|
|
285
|
+
log.info(`[query] Got sessionId: ${actualSessionId}`);
|
|
286
|
+
callbacks.onSessionId?.(actualSessionId);
|
|
564
287
|
}
|
|
565
288
|
continue;
|
|
566
289
|
}
|
|
@@ -592,25 +315,21 @@ export class ClaudeSDKAdapter {
|
|
|
592
315
|
}
|
|
593
316
|
// 处理结果消息
|
|
594
317
|
if (isResult(msg)) {
|
|
595
|
-
streamClosed = true;
|
|
596
318
|
const m = msg;
|
|
597
319
|
const success = m.subtype === 'success';
|
|
598
320
|
const errs = m.errors ?? [];
|
|
599
|
-
|
|
600
|
-
|
|
321
|
+
// 更新 sessionId(如果 init 消息中没有)
|
|
322
|
+
if (m.session_id && !actualSessionId) {
|
|
323
|
+
actualSessionId = m.session_id;
|
|
324
|
+
callbacks.onSessionId?.(actualSessionId);
|
|
325
|
+
}
|
|
326
|
+
log.info(`[query] Result: subtype=${m.subtype}, num_turns=${m.num_turns}, sessionId=${actualSessionId ?? 'unknown'}`);
|
|
601
327
|
if (!success) {
|
|
602
328
|
runSettled = true;
|
|
603
329
|
const noConvErr = errs.find((e) => e.includes('No conversation found') || e.includes('session not found'));
|
|
604
330
|
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
|
-
}
|
|
331
|
+
log.warn(`Session ${actualSessionId} not found`);
|
|
332
|
+
hadSessionInvalid = true;
|
|
614
333
|
callbacks.onSessionInvalid?.();
|
|
615
334
|
}
|
|
616
335
|
const errMsg = enrichClaudeErrorMessage(errs[0] || '未知错误', recentStderr);
|
|
@@ -640,7 +359,7 @@ export class ClaudeSDKAdapter {
|
|
|
640
359
|
}
|
|
641
360
|
}
|
|
642
361
|
// 如果流正常结束但没有收到 result 消息
|
|
643
|
-
if (!
|
|
362
|
+
if (!runSettled) {
|
|
644
363
|
if (accumulated) {
|
|
645
364
|
log.info('Stream ended without result message, using accumulated text');
|
|
646
365
|
runSettled = true;
|
|
@@ -655,7 +374,6 @@ export class ClaudeSDKAdapter {
|
|
|
655
374
|
});
|
|
656
375
|
}
|
|
657
376
|
else {
|
|
658
|
-
// 流结束但无 result 也无 accumulated:必须触发回调,否则 Promise 永远挂起
|
|
659
377
|
log.warn('Stream ended with no result and no accumulated text, calling onError to prevent stuck state');
|
|
660
378
|
runSettled = true;
|
|
661
379
|
callbacks.onError(enrichClaudeErrorMessage('AI 响应异常结束(无输出),请重试', recentStderr));
|
|
@@ -663,81 +381,41 @@ export class ClaudeSDKAdapter {
|
|
|
663
381
|
}
|
|
664
382
|
}
|
|
665
383
|
finally {
|
|
666
|
-
//
|
|
667
|
-
activeStreams.delete(stream);
|
|
384
|
+
// Query 的 cleanup 由 SDK 内部管理
|
|
668
385
|
}
|
|
669
386
|
}
|
|
670
387
|
catch (err) {
|
|
671
388
|
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
|
-
}
|
|
389
|
+
log.info('Query run aborted');
|
|
679
390
|
return;
|
|
680
391
|
}
|
|
681
392
|
runSettled = true;
|
|
682
393
|
const errorObj = err;
|
|
683
394
|
const msg = enrichClaudeErrorMessage(errorObj.message || String(err), recentStderr);
|
|
684
|
-
log.error(`Claude SDK
|
|
395
|
+
log.error(`Claude SDK error: ${msg}`);
|
|
685
396
|
if (errorObj.stack) {
|
|
686
397
|
log.error(`Error stack: ${errorObj.stack}`);
|
|
687
398
|
}
|
|
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}`);
|
|
399
|
+
if (isSessionCorruptionError(msg)) {
|
|
400
|
+
log.warn(`Session corruption detected: ${msg}`);
|
|
706
401
|
callbacks.onSessionInvalid?.();
|
|
707
402
|
}
|
|
708
403
|
callbacks.onError(msg);
|
|
709
404
|
}
|
|
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
405
|
};
|
|
721
|
-
//
|
|
722
|
-
|
|
406
|
+
// 启动查询(不等待),catch 兜底防止 unhandledRejection
|
|
407
|
+
runQuery().catch((err) => {
|
|
723
408
|
if (!runSettled) {
|
|
724
409
|
runSettled = true;
|
|
725
410
|
const msg = err instanceof Error ? err.message : String(err);
|
|
726
|
-
log.error(`Unhandled
|
|
411
|
+
log.error(`Unhandled runQuery error: ${msg}`);
|
|
727
412
|
callbacks.onError(msg);
|
|
728
413
|
}
|
|
729
414
|
});
|
|
730
415
|
return {
|
|
731
416
|
abort: () => {
|
|
732
|
-
log.info('Aborting
|
|
417
|
+
log.info('Aborting query');
|
|
733
418
|
abortController.abort();
|
|
734
|
-
// 立即中断 stream,不等下一条消息
|
|
735
|
-
if (currentStream) {
|
|
736
|
-
try {
|
|
737
|
-
currentStream.return?.();
|
|
738
|
-
}
|
|
739
|
-
catch { /* ignore */ }
|
|
740
|
-
}
|
|
741
419
|
},
|
|
742
420
|
};
|
|
743
421
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getConfiguredAiCommands } from '../config.js';
|
|
2
|
-
import { ClaudeSDKAdapter
|
|
2
|
+
import { ClaudeSDKAdapter } from './claude-sdk-adapter.js';
|
|
3
3
|
import { CodexAdapter } from './codex-adapter.js';
|
|
4
4
|
import { CodeBuddyAdapter } from './codebuddy-adapter.js';
|
|
5
5
|
import { OpenCodeAdapter } from './opencode-adapter.js';
|
|
@@ -12,7 +12,6 @@ export function initAdapters(config) {
|
|
|
12
12
|
for (const aiCommand of getConfiguredAiCommands(config)) {
|
|
13
13
|
if (aiCommand === 'claude') {
|
|
14
14
|
log.info('Claude Agent SDK adapter enabled');
|
|
15
|
-
configureClaudeSdkSessionIdle(config.claudeSessionIdleTtlMinutes);
|
|
16
15
|
adapters.set('claude', new ClaudeSDKAdapter());
|
|
17
16
|
continue;
|
|
18
17
|
}
|
package/dist/commands/handler.js
CHANGED
|
@@ -2,6 +2,7 @@ import { resolvePlatformAiCommand } from '../config.js';
|
|
|
2
2
|
import { escapePathForMarkdown } from '../shared/utils.js';
|
|
3
3
|
import { TERMINAL_ONLY_COMMANDS } from '../constants.js';
|
|
4
4
|
import { createLogger } from '../logger.js';
|
|
5
|
+
import { ClaudeSDKAdapter } from '../adapters/claude-sdk-adapter.js';
|
|
5
6
|
const log = createLogger('Commands');
|
|
6
7
|
function formatRelativeTime(ts) {
|
|
7
8
|
const sec = Math.floor((Date.now() - ts) / 1000);
|
|
@@ -20,10 +21,9 @@ function formatRelativeTime(ts) {
|
|
|
20
21
|
return `${day}天前`;
|
|
21
22
|
return new Date(ts).toLocaleDateString('zh-CN');
|
|
22
23
|
}
|
|
23
|
-
function
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const firstLine = msg.split('\n')[0].trim();
|
|
24
|
+
function truncateSummary(session, maxLen = 30) {
|
|
25
|
+
const text = session.customTitle || session.summary || session.firstPrompt || '新会话';
|
|
26
|
+
const firstLine = text.split('\n')[0].trim();
|
|
27
27
|
return firstLine.length > maxLen ? firstLine.slice(0, maxLen) + '...' : firstLine;
|
|
28
28
|
}
|
|
29
29
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
@@ -116,45 +116,36 @@ export class CommandHandler {
|
|
|
116
116
|
return true;
|
|
117
117
|
}
|
|
118
118
|
async handleSessions(chatId, userId, _platform) {
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
119
|
+
const workDir = this.deps.sessionManager.getWorkDir(userId);
|
|
120
|
+
const sessions = await ClaudeSDKAdapter.listSessionsForDir(workDir);
|
|
121
|
+
if (sessions.length === 0) {
|
|
122
122
|
await this.replySender().sendTextReply(chatId, '📋 暂无会话记录。');
|
|
123
123
|
return true;
|
|
124
124
|
}
|
|
125
125
|
const lines = ['📋 会话列表:', ''];
|
|
126
|
-
|
|
127
|
-
const preview =
|
|
128
|
-
const time =
|
|
129
|
-
lines.push(`${i + 1}. ${preview}
|
|
126
|
+
sessions.forEach((session, i) => {
|
|
127
|
+
const preview = truncateSummary(session);
|
|
128
|
+
const time = session.lastModified ? ` · ${formatRelativeTime(session.lastModified)}` : '';
|
|
129
|
+
lines.push(`${i + 1}. ${preview}${time}`);
|
|
130
130
|
});
|
|
131
|
-
if (active) {
|
|
132
|
-
const num = history.length + 1;
|
|
133
|
-
const preview = truncatePreview(active.firstMessage);
|
|
134
|
-
lines.push(`▸ ${num}. ${preview} · ${active.totalTurns}轮(当前)`);
|
|
135
|
-
}
|
|
136
131
|
lines.push('', '使用 /resume <序号> 恢复,或 /resume 恢复最近一条');
|
|
137
132
|
await this.replySender().sendTextReply(chatId, lines.join('\n'));
|
|
138
133
|
return true;
|
|
139
134
|
}
|
|
140
135
|
async handleResume(chatId, userId, arg, _platform) {
|
|
141
|
-
const
|
|
136
|
+
const workDir = this.deps.sessionManager.getWorkDir(userId);
|
|
137
|
+
const sessions = await ClaudeSDKAdapter.listSessionsForDir(workDir);
|
|
142
138
|
// /resume (no arg) — resume the most recent session
|
|
143
139
|
if (!arg) {
|
|
144
|
-
if (
|
|
140
|
+
if (sessions.length === 0) {
|
|
145
141
|
await this.replySender().sendTextReply(chatId, '没有可恢复的历史会话。');
|
|
146
142
|
return true;
|
|
147
143
|
}
|
|
148
|
-
const
|
|
144
|
+
const session = sessions[0];
|
|
149
145
|
this.deps.requestQueue.cancelUser(userId);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
await this.replySender().sendTextReply(chatId, `✅ 已恢复最近会话: ${preview}(${entry.totalTurns}轮)\n继续发消息即可。`);
|
|
154
|
-
}
|
|
155
|
-
else {
|
|
156
|
-
await this.replySender().sendTextReply(chatId, '❌ 恢复会话失败,请重试。');
|
|
157
|
-
}
|
|
146
|
+
this.deps.sessionManager.setActiveSessionId(userId, session.sessionId);
|
|
147
|
+
const preview = truncateSummary(session);
|
|
148
|
+
await this.replySender().sendTextReply(chatId, `✅ 已恢复最近会话: ${preview}\n继续发消息即可。`);
|
|
158
149
|
return true;
|
|
159
150
|
}
|
|
160
151
|
const index = parseInt(arg, 10);
|
|
@@ -162,20 +153,15 @@ export class CommandHandler {
|
|
|
162
153
|
await this.replySender().sendTextReply(chatId, '用法: /resume [序号]\n\n不带序号则恢复最近一条会话。');
|
|
163
154
|
return true;
|
|
164
155
|
}
|
|
165
|
-
if (index >
|
|
166
|
-
await this.replySender().sendTextReply(chatId, `序号 ${index} 无效,共 ${
|
|
156
|
+
if (index > sessions.length) {
|
|
157
|
+
await this.replySender().sendTextReply(chatId, `序号 ${index} 无效,共 ${sessions.length} 个历史会话。`);
|
|
167
158
|
return true;
|
|
168
159
|
}
|
|
169
|
-
const
|
|
160
|
+
const session = sessions[index - 1];
|
|
170
161
|
this.deps.requestQueue.cancelUser(userId);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
await this.replySender().sendTextReply(chatId, `✅ 已恢复会话: ${preview}(${entry.totalTurns}轮)\n继续发消息即可。`);
|
|
175
|
-
}
|
|
176
|
-
else {
|
|
177
|
-
await this.replySender().sendTextReply(chatId, '❌ 恢复会话失败,请重试。');
|
|
178
|
-
}
|
|
162
|
+
this.deps.sessionManager.setActiveSessionId(userId, session.sessionId);
|
|
163
|
+
const preview = truncateSummary(session);
|
|
164
|
+
await this.replySender().sendTextReply(chatId, `✅ 已恢复会话: ${preview}\n继续发消息即可。`);
|
|
179
165
|
return true;
|
|
180
166
|
}
|
|
181
167
|
async handleNew(chatId, userId) {
|
|
@@ -224,10 +210,7 @@ export class CommandHandler {
|
|
|
224
210
|
try {
|
|
225
211
|
this.deps.requestQueue.cancelUser(userId);
|
|
226
212
|
const result = await this.deps.sessionManager.setWorkDir(userId, dir);
|
|
227
|
-
|
|
228
|
-
? `📁 工作目录已切换到: ${escapePathForMarkdown(result.path)}\n\n🔄 已恢复该目录的最近会话,继续之前的上下文。`
|
|
229
|
-
: `📁 工作目录已切换到: ${escapePathForMarkdown(result.path)}\n\n🆕 该目录暂无历史会话,已创建全新上下文。`;
|
|
230
|
-
await this.replySender().sendTextReply(chatId, msg);
|
|
213
|
+
await this.replySender().sendTextReply(chatId, `📁 工作目录已切换到: ${escapePathForMarkdown(result.path)}\n\n下一条消息将自动查找该目录的最近会话。`);
|
|
231
214
|
}
|
|
232
215
|
catch (err) {
|
|
233
216
|
await this.replySender().sendTextReply(chatId, err instanceof Error ? err.message : String(err));
|
|
@@ -1,11 +1,4 @@
|
|
|
1
1
|
type ToolId = 'claude' | 'codex' | 'codebuddy' | 'opencode';
|
|
2
|
-
interface ConvHistoryEntry {
|
|
3
|
-
convId: string;
|
|
4
|
-
totalTurns: number;
|
|
5
|
-
createdAt: number;
|
|
6
|
-
workDir?: string;
|
|
7
|
-
firstMessage?: string;
|
|
8
|
-
}
|
|
9
2
|
export declare function resolveWorkDirInput(baseDir: string, targetDir: string): string;
|
|
10
3
|
export declare class SessionManager {
|
|
11
4
|
private sessions;
|
|
@@ -31,18 +24,14 @@ export declare class SessionManager {
|
|
|
31
24
|
resumed: boolean;
|
|
32
25
|
}>;
|
|
33
26
|
newSession(userId: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Set the active session to a specific SDK session (used by /resume).
|
|
29
|
+
* Bridges the SDK sessionId to our internal activeConvId tracking.
|
|
30
|
+
*/
|
|
31
|
+
setActiveSessionId(userId: string, sessionId: string): void;
|
|
34
32
|
clearActiveToolSession(userId: string, toolId: ToolId): boolean;
|
|
35
|
-
listConvHistory(userId: string): ConvHistoryEntry[];
|
|
36
|
-
getActiveConvInfo(userId: string): {
|
|
37
|
-
convId: string;
|
|
38
|
-
totalTurns: number;
|
|
39
|
-
firstMessage?: string;
|
|
40
|
-
} | undefined;
|
|
41
|
-
resumeConv(userId: string, convId: string): boolean;
|
|
42
33
|
addTurns(userId: string, turns: number): number;
|
|
43
34
|
addTurnsForThread(userId: string, threadId: string, turns: number): number;
|
|
44
|
-
/** Save the first message preview for the current conversation (only on first turn). */
|
|
45
|
-
setConvPreview(userId: string, preview: string): void;
|
|
46
35
|
getModel(userId: string, threadId?: string): string | undefined;
|
|
47
36
|
setModel(userId: string, model: string | undefined, threadId?: string): void;
|
|
48
37
|
private resolveAndValidate;
|
|
@@ -107,54 +107,16 @@ export class SessionManager {
|
|
|
107
107
|
const currentDir = this.getWorkDir(userId);
|
|
108
108
|
const realPath = await this.resolveAndValidate(currentDir, workDir);
|
|
109
109
|
const s = this.sessions.get(userId);
|
|
110
|
-
let oldConvId;
|
|
111
|
-
let resumed = false;
|
|
112
110
|
if (s) {
|
|
113
111
|
// Same directory — no-op
|
|
114
112
|
if (realPath === currentDir) {
|
|
115
113
|
return { path: realPath, resumed: false };
|
|
116
114
|
}
|
|
117
|
-
oldConvId = s.activeConvId;
|
|
118
115
|
this.persistActiveConvSessions(userId, s);
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
s.convHistory.push({
|
|
124
|
-
convId: oldConvId,
|
|
125
|
-
totalTurns: s.totalTurns ?? 0,
|
|
126
|
-
createdAt: Date.now(),
|
|
127
|
-
workDir: currentDir,
|
|
128
|
-
firstMessage: s.firstMessage,
|
|
129
|
-
});
|
|
130
|
-
if (s.convHistory.length > 10)
|
|
131
|
-
s.convHistory = s.convHistory.slice(-10);
|
|
132
|
-
}
|
|
133
|
-
// Look for a previous conversation in the target directory
|
|
134
|
-
if (!s.convHistory)
|
|
135
|
-
s.convHistory = [];
|
|
136
|
-
const matchIdx = s.convHistory.findIndex((e) => e.workDir === realPath);
|
|
137
|
-
if (matchIdx !== -1) {
|
|
138
|
-
const [entry] = s.convHistory.splice(matchIdx, 1);
|
|
139
|
-
s.activeConvId = entry.convId;
|
|
140
|
-
s.totalTurns = entry.totalTurns;
|
|
141
|
-
s.sessionIds = {};
|
|
142
|
-
for (const toolId of ['claude', 'codex', 'codebuddy', 'opencode']) {
|
|
143
|
-
const sessionId = this.convSessionMap.get(this.getConvSessionKey(userId, entry.convId, toolId));
|
|
144
|
-
if (sessionId) {
|
|
145
|
-
if (!s.sessionIds)
|
|
146
|
-
s.sessionIds = {};
|
|
147
|
-
s.sessionIds[toolId] = sessionId;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
resumed = true;
|
|
151
|
-
log.info(`Resumed session for user ${userId}: convId=${entry.convId}, workDir=${realPath}, turns=${entry.totalTurns}`);
|
|
152
|
-
}
|
|
153
|
-
else {
|
|
154
|
-
s.sessionIds = {};
|
|
155
|
-
s.activeConvId = randomBytes(4).toString('hex');
|
|
156
|
-
s.firstMessage = undefined;
|
|
157
|
-
}
|
|
116
|
+
// Clear active session IDs; SDK adapter will auto-resume latest CLI session for new dir
|
|
117
|
+
s.sessionIds = {};
|
|
118
|
+
s.activeConvId = randomBytes(4).toString('hex');
|
|
119
|
+
s.totalTurns = 0;
|
|
158
120
|
s.workDir = realPath;
|
|
159
121
|
}
|
|
160
122
|
else {
|
|
@@ -164,8 +126,8 @@ export class SessionManager {
|
|
|
164
126
|
});
|
|
165
127
|
}
|
|
166
128
|
this.flushSync();
|
|
167
|
-
log.info(`WorkDir changed for user ${userId}: ${realPath}
|
|
168
|
-
return { path: realPath, resumed };
|
|
129
|
+
log.info(`WorkDir changed for user ${userId}: ${realPath}`);
|
|
130
|
+
return { path: realPath, resumed: false };
|
|
169
131
|
}
|
|
170
132
|
newSession(userId) {
|
|
171
133
|
const s = this.sessions.get(userId);
|
|
@@ -173,31 +135,34 @@ export class SessionManager {
|
|
|
173
135
|
const oldSessionIds = { ...(s.sessionIds ?? {}) };
|
|
174
136
|
const oldConvId = s.activeConvId;
|
|
175
137
|
this.persistActiveConvSessions(userId, s);
|
|
176
|
-
// Archive old conversation to history (keep convSessionMap entries for resume)
|
|
177
|
-
if (oldConvId) {
|
|
178
|
-
if (!s.convHistory)
|
|
179
|
-
s.convHistory = [];
|
|
180
|
-
s.convHistory.push({
|
|
181
|
-
convId: oldConvId,
|
|
182
|
-
totalTurns: s.totalTurns ?? 0,
|
|
183
|
-
createdAt: Date.now(),
|
|
184
|
-
workDir: s.workDir,
|
|
185
|
-
firstMessage: s.firstMessage,
|
|
186
|
-
});
|
|
187
|
-
// Keep last 10 entries
|
|
188
|
-
if (s.convHistory.length > 10)
|
|
189
|
-
s.convHistory = s.convHistory.slice(-10);
|
|
190
|
-
}
|
|
191
138
|
s.sessionIds = {};
|
|
192
139
|
s.activeConvId = randomBytes(4).toString('hex');
|
|
193
140
|
s.totalTurns = 0;
|
|
194
|
-
s.firstMessage = undefined;
|
|
195
141
|
this.flushSync();
|
|
196
142
|
log.info(`New session for user ${userId}: oldConvId=${oldConvId}, oldSessionIds=${JSON.stringify(oldSessionIds)}, newConvId=${s.activeConvId}, sessionIds={}`);
|
|
197
143
|
return true;
|
|
198
144
|
}
|
|
199
145
|
return false;
|
|
200
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Set the active session to a specific SDK session (used by /resume).
|
|
149
|
+
* Bridges the SDK sessionId to our internal activeConvId tracking.
|
|
150
|
+
*/
|
|
151
|
+
setActiveSessionId(userId, sessionId) {
|
|
152
|
+
let s = this.sessions.get(userId);
|
|
153
|
+
if (!s) {
|
|
154
|
+
s = { workDir: this.defaultWorkDir };
|
|
155
|
+
this.sessions.set(userId, s);
|
|
156
|
+
}
|
|
157
|
+
this.persistActiveConvSessions(userId, s);
|
|
158
|
+
s.activeConvId = randomBytes(4).toString('hex');
|
|
159
|
+
s.totalTurns = 0;
|
|
160
|
+
if (!s.sessionIds)
|
|
161
|
+
s.sessionIds = {};
|
|
162
|
+
s.sessionIds.claude = sessionId;
|
|
163
|
+
this.flushSync();
|
|
164
|
+
log.info(`Set active session for user ${userId}: sessionId=${sessionId}, convId=${s.activeConvId}`);
|
|
165
|
+
}
|
|
201
166
|
clearActiveToolSession(userId, toolId) {
|
|
202
167
|
const s = this.sessions.get(userId);
|
|
203
168
|
if (!s)
|
|
@@ -212,59 +177,6 @@ export class SessionManager {
|
|
|
212
177
|
log.info(`Cleared active ${toolId} session for user ${userId}, convId=${activeConvId ?? 'none'}`);
|
|
213
178
|
return hadSession;
|
|
214
179
|
}
|
|
215
|
-
listConvHistory(userId) {
|
|
216
|
-
const s = this.sessions.get(userId);
|
|
217
|
-
if (!s)
|
|
218
|
-
return [];
|
|
219
|
-
return s.convHistory ?? [];
|
|
220
|
-
}
|
|
221
|
-
getActiveConvInfo(userId) {
|
|
222
|
-
const s = this.sessions.get(userId);
|
|
223
|
-
if (!s?.activeConvId)
|
|
224
|
-
return undefined;
|
|
225
|
-
return { convId: s.activeConvId, totalTurns: s.totalTurns ?? 0, firstMessage: s.firstMessage };
|
|
226
|
-
}
|
|
227
|
-
resumeConv(userId, convId) {
|
|
228
|
-
const s = this.sessions.get(userId);
|
|
229
|
-
if (!s)
|
|
230
|
-
return false;
|
|
231
|
-
// Find target in history
|
|
232
|
-
const idx = s.convHistory?.findIndex((e) => e.convId === convId) ?? -1;
|
|
233
|
-
if (idx === -1)
|
|
234
|
-
return false;
|
|
235
|
-
// Archive current active session
|
|
236
|
-
this.persistActiveConvSessions(userId, s);
|
|
237
|
-
if (s.activeConvId) {
|
|
238
|
-
if (!s.convHistory)
|
|
239
|
-
s.convHistory = [];
|
|
240
|
-
s.convHistory.push({
|
|
241
|
-
convId: s.activeConvId,
|
|
242
|
-
totalTurns: s.totalTurns ?? 0,
|
|
243
|
-
createdAt: Date.now(),
|
|
244
|
-
workDir: s.workDir,
|
|
245
|
-
firstMessage: s.firstMessage,
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
// Remove target from history
|
|
249
|
-
const [entry] = s.convHistory.splice(idx, 1);
|
|
250
|
-
// Restore target as active
|
|
251
|
-
s.activeConvId = entry.convId;
|
|
252
|
-
s.totalTurns = entry.totalTurns;
|
|
253
|
-
s.firstMessage = entry.firstMessage;
|
|
254
|
-
s.sessionIds = {};
|
|
255
|
-
// Restore sessionIds from convSessionMap
|
|
256
|
-
for (const toolId of ['claude', 'codex', 'codebuddy']) {
|
|
257
|
-
const sessionId = this.convSessionMap.get(this.getConvSessionKey(userId, entry.convId, toolId));
|
|
258
|
-
if (sessionId) {
|
|
259
|
-
if (!s.sessionIds)
|
|
260
|
-
s.sessionIds = {};
|
|
261
|
-
s.sessionIds[toolId] = sessionId;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
this.flushSync();
|
|
265
|
-
log.info(`Resumed session for user ${userId}: convId=${entry.convId}, turns=${entry.totalTurns}`);
|
|
266
|
-
return true;
|
|
267
|
-
}
|
|
268
180
|
addTurns(userId, turns) {
|
|
269
181
|
const s = this.sessions.get(userId);
|
|
270
182
|
if (!s)
|
|
@@ -286,14 +198,6 @@ export class SessionManager {
|
|
|
286
198
|
this.save();
|
|
287
199
|
return t.totalTurns;
|
|
288
200
|
}
|
|
289
|
-
/** Save the first message preview for the current conversation (only on first turn). */
|
|
290
|
-
setConvPreview(userId, preview) {
|
|
291
|
-
const s = this.sessions.get(userId);
|
|
292
|
-
if (!s || (s.totalTurns ?? 0) > 0)
|
|
293
|
-
return;
|
|
294
|
-
s.firstMessage = preview;
|
|
295
|
-
this.save();
|
|
296
|
-
}
|
|
297
201
|
getModel(userId, threadId) {
|
|
298
202
|
const s = this.sessions.get(userId);
|
|
299
203
|
if (threadId) {
|
|
@@ -359,6 +263,9 @@ export class SessionManager {
|
|
|
359
263
|
log.warn(`Legacy shared sessionId found for user ${k}; clearing it to avoid cross-tool resume conflicts`);
|
|
360
264
|
}
|
|
361
265
|
delete v.sessionId;
|
|
266
|
+
// Clean up legacy fields
|
|
267
|
+
delete v.firstMessage;
|
|
268
|
+
delete v.convHistory;
|
|
362
269
|
if (v.threads) {
|
|
363
270
|
for (const thread of Object.values(v.threads)) {
|
|
364
271
|
if (!thread.sessionIds)
|
package/dist/shared/ai-task.js
CHANGED
|
@@ -153,7 +153,6 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
|
|
|
153
153
|
const aiCommand = resolvePlatformAiCommand(config, ctx.platform);
|
|
154
154
|
const startRun = () => {
|
|
155
155
|
log.info(`[AITask] Starting: userId=${ctx.userId}, initialSessionId=${currentSessionId ?? 'new'}, prompt="${prompt.slice(0, 50)}..."`);
|
|
156
|
-
sessionManager.setConvPreview(ctx.userId, prompt.slice(0, 80));
|
|
157
156
|
emitStructuredEvent('AITask', 'ai.task.start', {
|
|
158
157
|
platform: ctx.platform,
|
|
159
158
|
taskKey: hashUserId(ctx.taskKey),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wu529778790/open-im",
|
|
3
|
-
"version": "1.11.1-beta.
|
|
3
|
+
"version": "1.11.1-beta.8",
|
|
4
4
|
"description": "Multi-platform IM bridge for AI CLI tools (Claude, Codex, CodeBuddy)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -51,7 +51,8 @@
|
|
|
51
51
|
"url": "https://github.com/wu529778790/open-im/issues"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@anthropic-ai/claude-agent-sdk": "^0.
|
|
54
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.179",
|
|
55
|
+
"@anthropic-ai/sdk": "^0.104.2",
|
|
55
56
|
"@aws-sdk/client-s3": "^3.1035.0",
|
|
56
57
|
"@larksuiteoapi/node-sdk": "^1.59.0",
|
|
57
58
|
"centrifuge": "^5.5.3",
|