@wu529778790/open-im 1.10.9-beta.3 → 1.10.9-beta.5
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 +21 -0
- package/README.zh-CN.md +21 -0
- package/dist/adapters/claude-sdk-adapter.d.ts +12 -0
- package/dist/adapters/claude-sdk-adapter.js +180 -23
- package/dist/adapters/registry.js +3 -0
- package/dist/codebuddy/cli-runner.js +31 -2
- package/dist/codex/cli-runner.js +28 -2
- package/dist/dingtalk/client.js +2 -1
- package/dist/dingtalk/event-handler.js +1 -1
- package/dist/index.js +33 -0
- package/dist/qq/client.js +7 -1
- package/dist/queue/request-queue.js +11 -10
- package/dist/shared/ai-task.d.ts +14 -0
- package/dist/shared/ai-task.js +57 -9
- package/dist/shared/process-kill.d.ts +24 -0
- package/dist/shared/process-kill.js +79 -0
- package/dist/shared/reconnect.d.ts +28 -0
- package/dist/shared/reconnect.js +56 -0
- package/dist/shared/task-cleanup.d.ts +16 -0
- package/dist/shared/task-cleanup.js +34 -1
- package/dist/telegram/client.js +7 -1
- package/dist/telemetry/telemetry-upload.js +1 -1
- package/dist/wework/client.js +17 -5
- package/dist/wework/event-handler.js +3 -0
- package/dist/workbuddy/centrifuge-client.d.ts +4 -0
- package/dist/workbuddy/centrifuge-client.js +76 -28
- package/dist/workbuddy/client.js +39 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,6 +66,27 @@ After `start`, the CLI prints the dashboard URL (default **`http://127.0.0.1:392
|
|
|
66
66
|
|
|
67
67
|
Session state is stored in **`~/.open-im/data/sessions.json`** (per user, not IM chat logs).
|
|
68
68
|
|
|
69
|
+
## Session continuity
|
|
70
|
+
|
|
71
|
+
open-im and Claude Code CLI share the same session storage. When you work in the same directory, you can seamlessly switch between phone and computer.
|
|
72
|
+
|
|
73
|
+
**Phone → Computer:** open-im automatically resumes the latest CLI session in the same directory — no configuration needed.
|
|
74
|
+
|
|
75
|
+
**Computer → Phone:** use `claude --continue` (or `claude -c`) to pick up the conversation that was continued on open-im.
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
# On computer
|
|
79
|
+
cd /my-project && claude # work as usual, then Ctrl+C
|
|
80
|
+
|
|
81
|
+
# On phone (via IM)
|
|
82
|
+
"help me fix the login bug" # open-im auto-resumes the same session
|
|
83
|
+
|
|
84
|
+
# Back on computer
|
|
85
|
+
claude -c # continues the phone conversation
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
> **Note:** only one side can be active at a time. Exit the CLI before sending messages from the phone, and vice versa.
|
|
89
|
+
|
|
69
90
|
## Configuration
|
|
70
91
|
|
|
71
92
|
### Per-platform AI
|
package/README.zh-CN.md
CHANGED
|
@@ -66,6 +66,27 @@ npx @wu529778790/open-im start
|
|
|
66
66
|
|
|
67
67
|
会话状态保存在 **`~/.open-im/data/sessions.json`**(按用户,与 IM 聊天记录无关)。
|
|
68
68
|
|
|
69
|
+
## 会话接力
|
|
70
|
+
|
|
71
|
+
open-im 和 Claude Code CLI 共享同一份 session 存储。在同一个目录下,手机和电脑可以无缝切换。
|
|
72
|
+
|
|
73
|
+
**手机接电脑:** open-im 自动恢复同目录下最新的 CLI session,无需配置。
|
|
74
|
+
|
|
75
|
+
**电脑接手机:** 使用 `claude --continue`(或 `claude -c`)接上 open-im 端的对话。
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# 电脑端
|
|
79
|
+
cd /my-project && claude # 正常工作,退出时 Ctrl+C
|
|
80
|
+
|
|
81
|
+
# 手机端(IM 消息)
|
|
82
|
+
"帮我修复登录 bug" # open-im 自动接续同一个 session
|
|
83
|
+
|
|
84
|
+
# 回到电脑端
|
|
85
|
+
claude -c # 接上手机端的对话
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
> **注意:** 同一时刻只能有一端活跃。从手机发消息前先退出 CLI,反之亦然。
|
|
89
|
+
|
|
69
90
|
## 配置说明
|
|
70
91
|
|
|
71
92
|
### 按平台指定 AI
|
|
@@ -9,6 +9,17 @@
|
|
|
9
9
|
* 认证:ANTHROPIC_API_KEY 或 CLAUDE_CODE_OAUTH_TOKEN
|
|
10
10
|
*/
|
|
11
11
|
import type { ToolAdapter, RunCallbacks, RunOptions, RunHandle } from './tool-adapter.interface.js';
|
|
12
|
+
interface ClaudeSessionMeta {
|
|
13
|
+
sessionId: string;
|
|
14
|
+
mtime: number;
|
|
15
|
+
filePath: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 扫描 ~/.claude/projects/<encoded-path>/ 找到最新的 CLI session。
|
|
19
|
+
* 只返回 JSONL 文件(排除子目录如 subagents/),按修改时间倒序。
|
|
20
|
+
* @param homeOverride 测试用:覆盖 homedir() 的返回值
|
|
21
|
+
*/
|
|
22
|
+
export declare function findLatestClaudeSession(workDir: string, homeOverride?: string): ClaudeSessionMeta | undefined;
|
|
12
23
|
/**
|
|
13
24
|
* 由 initAdapters 根据配置调用。ttlMinutes≤0 时关闭空闲回收(仍受 MAX_ACTIVE_SESSIONS 限制)。
|
|
14
25
|
*/
|
|
@@ -26,3 +37,4 @@ export declare class ClaudeSDKAdapter implements ToolAdapter {
|
|
|
26
37
|
static removeSession(sessionId: string): void;
|
|
27
38
|
run(prompt: string, sessionId: string | undefined, workDir: string, callbacks: RunCallbacks, options?: RunOptions): RunHandle;
|
|
28
39
|
}
|
|
40
|
+
export {};
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* 认证:ANTHROPIC_API_KEY 或 CLAUDE_CODE_OAUTH_TOKEN
|
|
10
10
|
*/
|
|
11
11
|
import { unstable_v2_createSession, unstable_v2_resumeSession } from '@anthropic-ai/claude-agent-sdk';
|
|
12
|
-
import { existsSync, readFileSync } from 'fs';
|
|
12
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
13
|
+
import { execSync } from 'child_process';
|
|
13
14
|
import { homedir } from 'os';
|
|
14
15
|
import { join } from 'path';
|
|
15
16
|
import { createLogger } from '../logger.js';
|
|
@@ -46,9 +47,99 @@ function loadUserPluginSettings() {
|
|
|
46
47
|
}
|
|
47
48
|
// Pre-load user plugin settings to cache Claude Code user preferences
|
|
48
49
|
loadUserPluginSettings();
|
|
50
|
+
// ── 扫描 Claude Code CLI 的最新 session,支持手机/电脑无缝切换 ──
|
|
51
|
+
/**
|
|
52
|
+
* 将 workDir(如 /Users/mac/github/open-im)转换为 Claude Code 的项目路径编码
|
|
53
|
+
* ~/.claude/projects/-Users-mac-github-open-im/
|
|
54
|
+
*/
|
|
55
|
+
function workDirToProjectPath(workDir) {
|
|
56
|
+
// Claude Code 使用路径中的 / 替换为 -,去掉开头的 -
|
|
57
|
+
// /Users/mac/github/open-im → -Users-mac-github-open-im
|
|
58
|
+
return workDir.replace(/\//g, '-');
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 检查 CLI 进程是否正在使用某个 session(通过 /proc 或 ps 检测)
|
|
62
|
+
*/
|
|
63
|
+
function isCliSessionActive(sessionId) {
|
|
64
|
+
try {
|
|
65
|
+
// macOS: 用 ps 搜索包含该 sessionId 的 claude 进程
|
|
66
|
+
const result = execSync(`ps -axo pid,command 2>/dev/null | grep -v grep | grep "claude" | grep "${sessionId}" || true`, { encoding: 'utf-8', timeout: 3000 });
|
|
67
|
+
return result.trim().length > 0;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 扫描 ~/.claude/projects/<encoded-path>/ 找到最新的 CLI session。
|
|
75
|
+
* 只返回 JSONL 文件(排除子目录如 subagents/),按修改时间倒序。
|
|
76
|
+
* @param homeOverride 测试用:覆盖 homedir() 的返回值
|
|
77
|
+
*/
|
|
78
|
+
export function findLatestClaudeSession(workDir, homeOverride) {
|
|
79
|
+
const projectDir = join(homeOverride ?? homedir(), '.claude', 'projects', workDirToProjectPath(workDir));
|
|
80
|
+
if (!existsSync(projectDir)) {
|
|
81
|
+
log.info(`No Claude Code project dir found: ${projectDir}`);
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const entries = readdirSync(projectDir, { withFileTypes: true });
|
|
86
|
+
const sessions = [];
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
// 只处理 .jsonl 文件,跳过子目录(如 subagents/)和 memory 目录
|
|
89
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
|
|
90
|
+
continue;
|
|
91
|
+
const sessionId = entry.name.replace('.jsonl', '');
|
|
92
|
+
// 校验 UUID 格式
|
|
93
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(sessionId))
|
|
94
|
+
continue;
|
|
95
|
+
const filePath = join(projectDir, entry.name);
|
|
96
|
+
try {
|
|
97
|
+
const stat = statSync(filePath);
|
|
98
|
+
// 跳过空文件
|
|
99
|
+
if (stat.size === 0)
|
|
100
|
+
continue;
|
|
101
|
+
sessions.push({ sessionId, mtime: stat.mtimeMs, filePath });
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (sessions.length === 0) {
|
|
108
|
+
log.info(`No session files found in ${projectDir}`);
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
// 按修改时间倒序,最新的在前
|
|
112
|
+
sessions.sort((a, b) => b.mtime - a.mtime);
|
|
113
|
+
const latest = sessions[0];
|
|
114
|
+
log.info(`Found latest Claude Code session: ${latest.sessionId} (mtime: ${new Date(latest.mtime).toISOString()}, size: ${statSync(latest.filePath).size})`);
|
|
115
|
+
return latest;
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
log.warn(`Failed to scan Claude Code project dir: ${err}`);
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 从 JSONL 文件的第一行提取 sessionId,验证文件内容与文件名一致。
|
|
124
|
+
*/
|
|
125
|
+
function validateSessionFile(filePath, expectedSessionId) {
|
|
126
|
+
try {
|
|
127
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
128
|
+
const firstLine = content.split('\n')[0];
|
|
129
|
+
if (!firstLine)
|
|
130
|
+
return false;
|
|
131
|
+
const firstEntry = JSON.parse(firstLine);
|
|
132
|
+
return firstEntry.sessionId === expectedSessionId;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
49
138
|
// 存储所有活跃的 SDKSession 对象,key 为 sessionId
|
|
50
139
|
// 使用 Map 而不是 Set,因为我们需要通过 sessionId 获取 session
|
|
51
140
|
const activeSessions = new Map();
|
|
141
|
+
// 记录每个 session 创建/恢复时的 workDir,防止跨 workDir 复用已固定 cwd 的子进程
|
|
142
|
+
const sessionWorkDirs = new Map();
|
|
52
143
|
// 存储正在进行的流式迭代器,用于中断
|
|
53
144
|
const activeStreams = new Set();
|
|
54
145
|
// 空闲会话清理:跟踪最后使用时间,定期清除超时会话
|
|
@@ -92,34 +183,53 @@ const cleanupInterval = setInterval(() => {
|
|
|
92
183
|
activeSessions.delete(id);
|
|
93
184
|
}
|
|
94
185
|
sessionLastUsed.delete(id);
|
|
186
|
+
sessionWorkDirs.delete(id);
|
|
95
187
|
log.info(`Cleaned up idle session (unused ${Math.round((now - lastUsed) / 60000)}min): ${id}`);
|
|
96
188
|
}
|
|
97
189
|
}
|
|
98
190
|
}, CLEANUP_INTERVAL_MS);
|
|
99
191
|
cleanupInterval.unref(); // 不阻止进程退出
|
|
100
192
|
/**
|
|
101
|
-
*
|
|
193
|
+
* 串行化进程级 process.chdir() —— 同一时刻仅一个 chdir 生效。
|
|
102
194
|
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* concurrent requests don't race on the working directory.
|
|
195
|
+
* SDK V2 的 createSession/resumeSession 不接受 cwd 参数;且 send()/stream()
|
|
196
|
+
* 会以「调用时的 process.cwd()」派生 Claude Code 子进程。必须用互斥锁串行化
|
|
197
|
+
* 「整个 turn 的 cwd 切换」,否则并发多用户会让工具跑错目录。
|
|
107
198
|
*
|
|
108
|
-
* **TODO:**
|
|
109
|
-
* supports a `cwd` option in createSession/resumeSession. Track upstream:
|
|
199
|
+
* **TODO:** SDK 支持 cwd 选项后移除此锁。upstream:
|
|
110
200
|
* https://github.com/anthropics/claude-agent-sdk/issues
|
|
111
201
|
*/
|
|
112
202
|
let chdirMutex = Promise.resolve();
|
|
113
203
|
function withChdirMutex(fn) {
|
|
114
204
|
const previous = chdirMutex;
|
|
115
|
-
let
|
|
116
|
-
chdirMutex = new Promise((r) => {
|
|
117
|
-
return previous.then(() => {
|
|
205
|
+
let release;
|
|
206
|
+
chdirMutex = new Promise((r) => { release = r; });
|
|
207
|
+
return previous.then(async () => {
|
|
208
|
+
try {
|
|
209
|
+
return await fn();
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
release();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* 在持有全局 chdir 互斥锁期间,把进程 cwd 切到 workDir 执行 fn,结束后恢复。
|
|
218
|
+
* 用于包裹 session.send()+stream(),确保子进程在正确 workDir 派生。
|
|
219
|
+
*/
|
|
220
|
+
function runWithWorkDir(workDir, fn) {
|
|
221
|
+
return withChdirMutex(async () => {
|
|
222
|
+
const originalCwd = process.cwd();
|
|
223
|
+
if (workDir && workDir !== originalCwd) {
|
|
224
|
+
process.chdir(workDir);
|
|
225
|
+
}
|
|
118
226
|
try {
|
|
119
|
-
return fn();
|
|
227
|
+
return await fn();
|
|
120
228
|
}
|
|
121
229
|
finally {
|
|
122
|
-
|
|
230
|
+
if (workDir && workDir !== originalCwd) {
|
|
231
|
+
process.chdir(originalCwd);
|
|
232
|
+
}
|
|
123
233
|
}
|
|
124
234
|
});
|
|
125
235
|
}
|
|
@@ -201,18 +311,21 @@ async function getOrCreateSession(sessionId, workDir, model, permissionMode, onS
|
|
|
201
311
|
process.chdir(workDir);
|
|
202
312
|
}
|
|
203
313
|
if (sessionId) {
|
|
204
|
-
// 优先复用内存中已有的 SDKSession
|
|
314
|
+
// 优先复用内存中已有的 SDKSession,避免每次都启动新进程。
|
|
315
|
+
// 仅当 workDir 与创建时一致才复用:否则子进程 cwd 已固定在旧目录,
|
|
316
|
+
// 需走 resume 重新派生(在当前 workDir 启动新子进程)。
|
|
205
317
|
const existing = activeSessions.get(sessionId);
|
|
206
|
-
if (existing) {
|
|
318
|
+
if (existing && sessionWorkDirs.get(sessionId) === workDir) {
|
|
207
319
|
log.info(`Reusing existing in-memory session: ${sessionId}`);
|
|
208
320
|
sessionLastUsed.set(sessionId, Date.now());
|
|
209
321
|
return { session: existing, sessionId };
|
|
210
322
|
}
|
|
211
|
-
//
|
|
323
|
+
// 内存中没有(或 workDir 变了),尝试通过 resume 恢复(会启动新 CLI 进程)
|
|
212
324
|
try {
|
|
213
325
|
log.info(`Attempting to resume session: ${sessionId}`);
|
|
214
326
|
session = unstable_v2_resumeSession(sessionId, sessionOptions);
|
|
215
327
|
activeSessions.set(sessionId, session);
|
|
328
|
+
sessionWorkDirs.set(sessionId, workDir);
|
|
216
329
|
sessionLastUsed.set(sessionId, Date.now());
|
|
217
330
|
log.info(`Successfully resumed session: ${sessionId}`);
|
|
218
331
|
return { session, sessionId };
|
|
@@ -222,12 +335,44 @@ async function getOrCreateSession(sessionId, workDir, model, permissionMode, onS
|
|
|
222
335
|
// 恢复失败,创建新会话
|
|
223
336
|
}
|
|
224
337
|
}
|
|
338
|
+
// 没有指定 sessionId 时,尝试自动恢复 Claude Code CLI 的最新 session
|
|
339
|
+
// 实现手机/电脑无缝切换:同目录下默认共享同一个对话
|
|
340
|
+
if (!sessionId) {
|
|
341
|
+
const latest = findLatestClaudeSession(workDir);
|
|
342
|
+
if (latest) {
|
|
343
|
+
// 安全检查:如果 CLI 正在使用该 session,不能接管
|
|
344
|
+
if (isCliSessionActive(latest.sessionId)) {
|
|
345
|
+
log.info(`CLI is actively using session ${latest.sessionId}, skipping auto-resume`);
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
// 验证文件内容一致性
|
|
349
|
+
if (validateSessionFile(latest.filePath, latest.sessionId)) {
|
|
350
|
+
try {
|
|
351
|
+
log.info(`Auto-resuming latest CLI session: ${latest.sessionId}`);
|
|
352
|
+
session = unstable_v2_resumeSession(latest.sessionId, sessionOptions);
|
|
353
|
+
activeSessions.set(latest.sessionId, session);
|
|
354
|
+
sessionWorkDirs.set(latest.sessionId, workDir);
|
|
355
|
+
sessionLastUsed.set(latest.sessionId, Date.now());
|
|
356
|
+
log.info(`Successfully auto-resumed CLI session: ${latest.sessionId}`);
|
|
357
|
+
return { session, sessionId: latest.sessionId };
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
log.warn(`Failed to auto-resume CLI session ${latest.sessionId}, creating new one: ${err}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
log.warn(`Session file validation failed for ${latest.sessionId}, skipping`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
225
369
|
// 创建新会话
|
|
226
370
|
session = unstable_v2_createSession(sessionOptions);
|
|
227
371
|
// 新会话的 sessionId 需要从第一个消息中获取
|
|
228
372
|
// 暂时返回 undefined,稍后在 init 消息中获取
|
|
229
373
|
const tempId = `pending-${++sessionSeq}`;
|
|
230
374
|
activeSessions.set(tempId, session);
|
|
375
|
+
sessionWorkDirs.set(tempId, workDir);
|
|
231
376
|
sessionLastUsed.set(tempId, Date.now());
|
|
232
377
|
log.info(`Created new session (tempId: ${tempId})`);
|
|
233
378
|
return { session, sessionId: tempId, wasReused: false };
|
|
@@ -267,6 +412,7 @@ export class ClaudeSDKAdapter {
|
|
|
267
412
|
}
|
|
268
413
|
activeSessions.clear();
|
|
269
414
|
sessionLastUsed.clear();
|
|
415
|
+
sessionWorkDirs.clear();
|
|
270
416
|
}
|
|
271
417
|
/**
|
|
272
418
|
* Remove a specific session from the in-memory cache and close it.
|
|
@@ -281,6 +427,7 @@ export class ClaudeSDKAdapter {
|
|
|
281
427
|
catch { /* ignore */ }
|
|
282
428
|
activeSessions.delete(sessionId);
|
|
283
429
|
sessionLastUsed.delete(sessionId);
|
|
430
|
+
sessionWorkDirs.delete(sessionId);
|
|
284
431
|
log.info(`Explicitly removed session: ${sessionId}`);
|
|
285
432
|
}
|
|
286
433
|
}
|
|
@@ -320,12 +467,16 @@ export class ClaudeSDKAdapter {
|
|
|
320
467
|
}
|
|
321
468
|
runningSessions.add(returnedId);
|
|
322
469
|
trackedRunningId = returnedId;
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
//
|
|
326
|
-
const stream =
|
|
327
|
-
|
|
328
|
-
|
|
470
|
+
// 在持有 chdir 锁期间完成 send() + stream() 获取:SDK V2 会以当前
|
|
471
|
+
// process.cwd() 派生 Claude Code 子进程,必须保证此刻 cwd 为 workDir。
|
|
472
|
+
// 锁串行化后,并发多用户的子进程不会在错误的目录派生。
|
|
473
|
+
const stream = await runWithWorkDir(workDir, async () => {
|
|
474
|
+
await session.send(prompt);
|
|
475
|
+
const s = session.stream();
|
|
476
|
+
currentStream = s;
|
|
477
|
+
activeStreams.add(s);
|
|
478
|
+
return s;
|
|
479
|
+
});
|
|
329
480
|
let accumulated = '';
|
|
330
481
|
let accumulatedThinking = '';
|
|
331
482
|
const toolStats = {};
|
|
@@ -348,13 +499,19 @@ export class ClaudeSDKAdapter {
|
|
|
348
499
|
// 更新 sessionId 映射
|
|
349
500
|
// 清理 pending 临时 ID(actualSessionId 尚未赋值时用 pendingTempId)
|
|
350
501
|
const idToClean = actualSessionId ?? pendingTempId;
|
|
502
|
+
const inheritedWorkDir = idToClean ? sessionWorkDirs.get(idToClean) : undefined;
|
|
351
503
|
if (idToClean?.startsWith('pending-')) {
|
|
352
504
|
activeSessions.delete(idToClean);
|
|
353
505
|
}
|
|
354
506
|
activeSessions.set(newSessionId, session);
|
|
507
|
+
if (inheritedWorkDir !== undefined) {
|
|
508
|
+
sessionWorkDirs.set(newSessionId, inheritedWorkDir);
|
|
509
|
+
}
|
|
355
510
|
sessionLastUsed.set(newSessionId, Date.now());
|
|
356
|
-
if (idToClean)
|
|
511
|
+
if (idToClean) {
|
|
357
512
|
sessionLastUsed.delete(idToClean);
|
|
513
|
+
sessionWorkDirs.delete(idToClean);
|
|
514
|
+
}
|
|
358
515
|
// 更新 runningSessions:移除旧 ID,添加新 ID
|
|
359
516
|
if (idToClean)
|
|
360
517
|
runningSessions.delete(idToClean);
|
|
@@ -3,6 +3,7 @@ import { ClaudeSDKAdapter, configureClaudeSdkSessionIdle } from './claude-sdk-ad
|
|
|
3
3
|
import { CodexAdapter } from './codex-adapter.js';
|
|
4
4
|
import { CodeBuddyAdapter } from './codebuddy-adapter.js';
|
|
5
5
|
import { createLogger } from '../logger.js';
|
|
6
|
+
import { destroyAllLiveChildren } from '../shared/process-kill.js';
|
|
6
7
|
const log = createLogger('Registry');
|
|
7
8
|
const adapters = new Map();
|
|
8
9
|
export function initAdapters(config) {
|
|
@@ -30,5 +31,7 @@ export function getAdapter(aiCommand) {
|
|
|
30
31
|
}
|
|
31
32
|
export function cleanupAdapters() {
|
|
32
33
|
ClaudeSDKAdapter.destroy();
|
|
34
|
+
// 强制终止仍在运行的 CLI 子进程(Codex/CodeBuddy),避免僵尸 / 孤儿
|
|
35
|
+
destroyAllLiveChildren();
|
|
33
36
|
adapters.clear();
|
|
34
37
|
}
|
|
@@ -3,6 +3,7 @@ import { accessSync, constants } from 'node:fs';
|
|
|
3
3
|
import { isAbsolute, join } from 'node:path';
|
|
4
4
|
import { createLogger } from '../logger.js';
|
|
5
5
|
import { processEnvForNonClaudeCliChild } from '../config/file-io.js';
|
|
6
|
+
import { killProcessTree, trackChild } from '../shared/process-kill.js';
|
|
6
7
|
const log = createLogger('CodeBuddyCli');
|
|
7
8
|
export function buildCodeBuddyArgs(prompt, sessionId, options) {
|
|
8
9
|
const args = ['--print', '--output-format', 'stream-json'];
|
|
@@ -200,10 +201,14 @@ export function runCodeBuddy(cliPath, prompt, sessionId, workDir, callbacks, opt
|
|
|
200
201
|
log.info(`Spawning CodeBuddy CLI: path=${normalizedCliPath}, cwd=${workDir}, session=${sessionId ?? 'new'}, args=${args.join(' ')}`);
|
|
201
202
|
const child = spawn(spawnCmd, spawnArgs, {
|
|
202
203
|
cwd: workDir,
|
|
204
|
+
// Unix 上放入独立进程组,使 abort/关停能用 process.kill(-pid) 杀掉整棵子树(含 MCP/shell 孙进程)
|
|
205
|
+
detached: process.platform !== 'win32',
|
|
203
206
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
204
207
|
env,
|
|
205
208
|
windowsHide: process.platform === 'win32',
|
|
206
209
|
});
|
|
210
|
+
trackChild(child);
|
|
211
|
+
let cliTimeoutHandle = null;
|
|
207
212
|
let accumulated = '';
|
|
208
213
|
let accumulatedThinking = '';
|
|
209
214
|
let completed = false;
|
|
@@ -311,6 +316,10 @@ export function runCodeBuddy(cliPath, prompt, sessionId, workDir, callbacks, opt
|
|
|
311
316
|
}
|
|
312
317
|
});
|
|
313
318
|
child.on('close', (code) => {
|
|
319
|
+
if (cliTimeoutHandle) {
|
|
320
|
+
clearTimeout(cliTimeoutHandle);
|
|
321
|
+
cliTimeoutHandle = null;
|
|
322
|
+
}
|
|
314
323
|
if (completed)
|
|
315
324
|
return;
|
|
316
325
|
if (stdoutState.buffer.trim()) {
|
|
@@ -343,16 +352,36 @@ export function runCodeBuddy(cliPath, prompt, sessionId, workDir, callbacks, opt
|
|
|
343
352
|
});
|
|
344
353
|
});
|
|
345
354
|
child.on('error', (err) => {
|
|
355
|
+
if (cliTimeoutHandle) {
|
|
356
|
+
clearTimeout(cliTimeoutHandle);
|
|
357
|
+
cliTimeoutHandle = null;
|
|
358
|
+
}
|
|
346
359
|
if (completed)
|
|
347
360
|
return;
|
|
348
361
|
completed = true;
|
|
349
362
|
callbacks.onError(`Failed to start CodeBuddy CLI: ${err.message}`);
|
|
350
363
|
});
|
|
364
|
+
// 墙钟超时:防止 CLI 挂死永久占用用户队列槽
|
|
365
|
+
const cliTimeoutMs = Number(process.env.OPEN_IM_CLI_TIMEOUT_MS) || 30 * 60 * 1000;
|
|
366
|
+
cliTimeoutHandle = setTimeout(() => {
|
|
367
|
+
if (completed)
|
|
368
|
+
return;
|
|
369
|
+
log.warn(`CodeBuddy CLI 超过 ${cliTimeoutMs}ms,强制终止 (pid=${child.pid})`);
|
|
370
|
+
completed = true;
|
|
371
|
+
killProcessTree(child);
|
|
372
|
+
callbacks.onError(`CodeBuddy CLI 运行超时(${Math.round(cliTimeoutMs / 1000)}s),已终止。请重试。`);
|
|
373
|
+
}, cliTimeoutMs);
|
|
374
|
+
cliTimeoutHandle.unref();
|
|
351
375
|
return {
|
|
352
376
|
abort: () => {
|
|
377
|
+
if (completed)
|
|
378
|
+
return;
|
|
353
379
|
completed = true;
|
|
354
|
-
if (
|
|
355
|
-
|
|
380
|
+
if (cliTimeoutHandle) {
|
|
381
|
+
clearTimeout(cliTimeoutHandle);
|
|
382
|
+
cliTimeoutHandle = null;
|
|
383
|
+
}
|
|
384
|
+
killProcessTree(child);
|
|
356
385
|
},
|
|
357
386
|
};
|
|
358
387
|
}
|
package/dist/codex/cli-runner.js
CHANGED
|
@@ -7,6 +7,7 @@ import { dirname, join } from 'node:path';
|
|
|
7
7
|
import { createInterface } from 'node:readline';
|
|
8
8
|
import { createLogger } from '../logger.js';
|
|
9
9
|
import { processEnvForNonClaudeCliChild } from '../config/file-io.js';
|
|
10
|
+
import { killProcessTree, trackChild } from '../shared/process-kill.js';
|
|
10
11
|
const log = createLogger('CodexCli');
|
|
11
12
|
const windowsCodexLaunchCache = new Map();
|
|
12
13
|
const SUPPORTED_IMAGE_EXTENSIONS = new Set([
|
|
@@ -201,10 +202,13 @@ export function runCodex(cliPath, prompt, sessionId, workDir, callbacks, options
|
|
|
201
202
|
: args;
|
|
202
203
|
const child = spawn(spawnCmd, spawnArgs, {
|
|
203
204
|
cwd: workDir,
|
|
205
|
+
// Unix 上放入独立进程组,使 abort/关停能用 process.kill(-pid) 杀掉整棵子树(含 MCP/shell 孙进程)
|
|
206
|
+
detached: process.platform !== 'win32',
|
|
204
207
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
205
208
|
env,
|
|
206
209
|
windowsHide: process.platform === 'win32',
|
|
207
210
|
});
|
|
211
|
+
trackChild(child);
|
|
208
212
|
child.stdin?.write(prompt);
|
|
209
213
|
child.stdin?.end();
|
|
210
214
|
let accumulated = '';
|
|
@@ -327,7 +331,12 @@ export function runCodex(cliPath, prompt, sessionId, workDir, callbacks, options
|
|
|
327
331
|
let exitCode = null;
|
|
328
332
|
let rlClosed = false;
|
|
329
333
|
let childClosed = false;
|
|
334
|
+
let cliTimeoutHandle = null;
|
|
330
335
|
const finalize = () => {
|
|
336
|
+
if (cliTimeoutHandle) {
|
|
337
|
+
clearTimeout(cliTimeoutHandle);
|
|
338
|
+
cliTimeoutHandle = null;
|
|
339
|
+
}
|
|
331
340
|
if (!rlClosed || !childClosed)
|
|
332
341
|
return;
|
|
333
342
|
if (completed)
|
|
@@ -387,12 +396,29 @@ export function runCodex(cliPath, prompt, sessionId, workDir, callbacks, options
|
|
|
387
396
|
childClosed = true;
|
|
388
397
|
finalize();
|
|
389
398
|
});
|
|
399
|
+
// 墙钟超时:防止 CLI 挂死(网络卡住、工具死循环等)永久占用用户队列槽
|
|
400
|
+
const cliTimeoutMs = Number(process.env.OPEN_IM_CLI_TIMEOUT_MS) || 30 * 60 * 1000;
|
|
401
|
+
cliTimeoutHandle = setTimeout(() => {
|
|
402
|
+
if (completed)
|
|
403
|
+
return;
|
|
404
|
+
log.warn(`Codex CLI 超过 ${cliTimeoutMs}ms,强制终止 (pid=${child.pid})`);
|
|
405
|
+
completed = true;
|
|
406
|
+
rl.close();
|
|
407
|
+
killProcessTree(child);
|
|
408
|
+
callbacks.onError(`Codex CLI 运行超时(${Math.round(cliTimeoutMs / 1000)}s),已终止。请重试。`);
|
|
409
|
+
}, cliTimeoutMs);
|
|
410
|
+
cliTimeoutHandle.unref();
|
|
390
411
|
return {
|
|
391
412
|
abort: () => {
|
|
413
|
+
if (completed)
|
|
414
|
+
return;
|
|
392
415
|
completed = true;
|
|
416
|
+
if (cliTimeoutHandle) {
|
|
417
|
+
clearTimeout(cliTimeoutHandle);
|
|
418
|
+
cliTimeoutHandle = null;
|
|
419
|
+
}
|
|
393
420
|
rl.close();
|
|
394
|
-
|
|
395
|
-
child.kill('SIGTERM');
|
|
421
|
+
killProcessTree(child);
|
|
396
422
|
},
|
|
397
423
|
};
|
|
398
424
|
}
|
package/dist/dingtalk/client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DWClient, TOPIC_ROBOT } from 'dingtalk-stream';
|
|
2
2
|
import { createLogger } from '../logger.js';
|
|
3
|
+
import { jitteredDelay } from '../shared/reconnect.js';
|
|
3
4
|
import { registerSessionWebhook as registerWebhook, clearWebhooks, sendText as webhookSendText, sendMarkdown as webhookSendMarkdown, downloadRobotMessageFile as webhookDownloadFile, sendProactiveText as webhookSendProactive, } from './webhook.js';
|
|
4
5
|
import { prepareStreamingCard as cardPrepare, updateStreamingCard as cardUpdate, finishStreamingCard as cardFinish, createAndDeliverCard as cardCreateAndDeliver, updateCardInstance as cardUpdateInstance, sendRobotInteractiveCard as cardSendInteractive, updateRobotInteractiveCard as cardUpdateInteractive, clearUnionIdCache, } from './streaming-card.js';
|
|
5
6
|
const log = createLogger('DingTalk');
|
|
@@ -111,7 +112,7 @@ export async function initDingTalk(cfg, eventHandler) {
|
|
|
111
112
|
lastErr = err;
|
|
112
113
|
if (is429(err) && attempt < maxTries) {
|
|
113
114
|
log.warn(`DingTalk gateway 429 (attempt ${attempt}/${maxTries}), retrying in ${retryDelayMs / 1000}s...`);
|
|
114
|
-
await new Promise((r) => setTimeout(r, retryDelayMs));
|
|
115
|
+
await new Promise((r) => setTimeout(r, jitteredDelay(retryDelayMs)));
|
|
115
116
|
continue;
|
|
116
117
|
}
|
|
117
118
|
throw new Error(formatDingTalkInitError(err));
|
|
@@ -163,7 +163,7 @@ export function setupDingTalkHandlers(config, sessionManager) {
|
|
|
163
163
|
sessionManager,
|
|
164
164
|
sender: { sendTextReply, sendDirectorySelection },
|
|
165
165
|
});
|
|
166
|
-
const { accessControl, requestQueue, runningTasks
|
|
166
|
+
const { accessControl, requestQueue, runningTasks } = ctx;
|
|
167
167
|
// DingTalk-specific sender callbacks for the factory
|
|
168
168
|
const dingtalkSender = {
|
|
169
169
|
sendThinkingMessage: async (chatId, replyToMessageId, toolId) => {
|
package/dist/index.js
CHANGED
|
@@ -27,11 +27,13 @@ import { sendTextReply as sendWorkBuddyTextReply } from "./workbuddy/message-sen
|
|
|
27
27
|
import { initAdapters, cleanupAdapters } from "./adapters/registry.js";
|
|
28
28
|
import { SessionManager } from "./session/session-manager.js";
|
|
29
29
|
import { loadActiveChats, getActiveChatId, flushActiveChats, } from "./shared/active-chats.js";
|
|
30
|
+
import { destroyAllLiveChildren } from "./shared/process-kill.js";
|
|
30
31
|
import { initLogger, createLogger, closeLogger, emitStructuredEvent, shutdownLoggerTelemetry, } from "./logger.js";
|
|
31
32
|
import { APP_HOME, SHUTDOWN_PORT } from "./constants.js";
|
|
32
33
|
import { createRequire } from "node:module";
|
|
33
34
|
import { escapePathForMarkdown } from "./shared/utils.js";
|
|
34
35
|
import { applyOpenImGitCoauthorToProcessEnv } from "./shared/git-coauthor.js";
|
|
36
|
+
import { emitInterruptedTerminals } from "./shared/task-cleanup.js";
|
|
35
37
|
const require = createRequire(import.meta.url);
|
|
36
38
|
const { version: APP_VERSION } = require("../package.json");
|
|
37
39
|
const log = createLogger("Main");
|
|
@@ -262,6 +264,15 @@ export async function main() {
|
|
|
262
264
|
const uptimeSec = Math.floor((Date.now() - startedAt) / 1000);
|
|
263
265
|
const m = Math.floor(uptimeSec / 60);
|
|
264
266
|
const shutdownMsg = buildShutdownMessage(m);
|
|
267
|
+
// 在任何异步等待之前,先为仍在运行的任务补发终态遥测事件。
|
|
268
|
+
// 这样即使后续 await(通知发送、platform.stop)期间进程被第二次信号杀死,
|
|
269
|
+
// ai.task.start 也有对应的 interrupted 终态,避免遥测里的 miss。
|
|
270
|
+
for (const platform of successfulPlatforms) {
|
|
271
|
+
const handle = activeHandles.get(platform);
|
|
272
|
+
if (handle?.runningTasks && handle.runningTasks.size > 0) {
|
|
273
|
+
emitInterruptedTerminals(handle.runningTasks);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
265
276
|
// Send notification only to successfully initialized platforms
|
|
266
277
|
for (const platform of successfulPlatforms) {
|
|
267
278
|
await sendLifecycleNotification(platform, shutdownMsg).catch((err) => {
|
|
@@ -299,6 +310,16 @@ export async function main() {
|
|
|
299
310
|
};
|
|
300
311
|
process.on("SIGINT", () => shutdown().catch(() => process.exit(1)));
|
|
301
312
|
process.on("SIGTERM", () => shutdown().catch(() => process.exit(1)));
|
|
313
|
+
// 兜底:进程退出(含异常路径,如未捕获异常 / SIGKILL)时强制清理 CLI 子进程,
|
|
314
|
+
// 避免僵尸 / 孤儿。正常 shutdown 已在 cleanupAdapters() 里清理过。
|
|
315
|
+
process.on("exit", () => {
|
|
316
|
+
try {
|
|
317
|
+
destroyAllLiveChildren();
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
/* 退出期间 best effort */
|
|
321
|
+
}
|
|
322
|
+
});
|
|
302
323
|
// Global error handlers to prevent unhandled crashes
|
|
303
324
|
process.on("unhandledRejection", (reason) => {
|
|
304
325
|
log.error("Unhandled Promise rejection (this indicates a bug — the affected request may hang without a response):", reason);
|
|
@@ -312,6 +333,18 @@ export async function main() {
|
|
|
312
333
|
return;
|
|
313
334
|
}
|
|
314
335
|
log.error("Uncaught exception (process will exit):", err);
|
|
336
|
+
// 进程即将因未捕获异常退出:补发在途任务的终态,避免 miss
|
|
337
|
+
for (const platform of successfulPlatforms) {
|
|
338
|
+
const handle = activeHandles.get(platform);
|
|
339
|
+
if (handle?.runningTasks && handle.runningTasks.size > 0) {
|
|
340
|
+
try {
|
|
341
|
+
emitInterruptedTerminals(handle.runningTasks);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
/* best effort:不能因遥测失败再次抛出 */
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
315
348
|
void shutdownLoggerTelemetry()
|
|
316
349
|
.then(() => closeLogger())
|
|
317
350
|
.finally(() => process.exit(1));
|
package/dist/qq/client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import { createLogger } from "../logger.js";
|
|
3
|
+
import { jitteredDelay, SLOW_PROBE_MS } from "../shared/reconnect.js";
|
|
3
4
|
const log = createLogger("QQ");
|
|
4
5
|
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
|
5
6
|
const API_BASE = "https://api.sgroup.qq.com";
|
|
@@ -271,7 +272,12 @@ async function connectWebSocket(config, handler) {
|
|
|
271
272
|
sessionId = null;
|
|
272
273
|
seq = null;
|
|
273
274
|
}
|
|
274
|
-
const
|
|
275
|
+
const isFatalClose = code === 4004 || code === 4006 || code === 4007;
|
|
276
|
+
const baseDelay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
277
|
+
const delay = isFatalClose ? jitteredDelay(SLOW_PROBE_MS) : jitteredDelay(baseDelay);
|
|
278
|
+
if (isFatalClose) {
|
|
279
|
+
log.warn(`QQ 致命关闭码 ${code}(鉴权失败),转慢探测(每 ${Math.round(SLOW_PROBE_MS / 1000)}s 一次)`);
|
|
280
|
+
}
|
|
275
281
|
reconnectAttempt += 1;
|
|
276
282
|
reconnectTimer = setTimeout(() => {
|
|
277
283
|
if (currentConfig && currentHandler) {
|