@wu529778790/open-im 1.10.9-beta.1 → 1.10.9-beta.11

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 CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  Multi-platform IM bridge for AI CLI tools. Connect Telegram, Feishu, WeCom, DingTalk, QQ, and WeChat (WorkBuddy) to Claude Code, Codex, and CodeBuddy — use your AI coding assistant from any phone or chat window.
6
6
 
7
+ ## Architecture
8
+
9
+ ![Open-IM Architecture](./diagram/architecture/open-im-architecture.svg)
10
+
7
11
  ## Features
8
12
 
9
13
  - **Six IM platforms** — Telegram, Feishu, WeCom, DingTalk, QQ, WorkBuddy
@@ -62,6 +66,27 @@ After `start`, the CLI prints the dashboard URL (default **`http://127.0.0.1:392
62
66
 
63
67
  Session state is stored in **`~/.open-im/data/sessions.json`** (per user, not IM chat logs).
64
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
+
65
90
  ## Configuration
66
91
 
67
92
  ### Per-platform AI
package/README.zh-CN.md CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  多平台 IM 桥接:把 Telegram、飞书、企业微信、钉钉、QQ、微信(WorkBuddy)接到 Claude Code、Codex、CodeBuddy,在手机或聊天里使用 AI 编程助手。
6
6
 
7
+ ## 架构
8
+
9
+ ![Open-IM 架构图](./diagram/architecture/open-im-architecture.svg)
10
+
7
11
  ## 功能特性
8
12
 
9
13
  - **六个 IM 平台** — Telegram、飞书、企业微信、钉钉、QQ、WorkBuddy
@@ -62,6 +66,27 @@ npx @wu529778790/open-im start
62
66
 
63
67
  会话状态保存在 **`~/.open-im/data/sessions.json`**(按用户,与 IM 聊天记录无关)。
64
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
+
65
90
  ## 配置说明
66
91
 
67
92
  ### 按平台指定 AI
@@ -9,6 +9,18 @@
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
+ size: number;
17
+ }
18
+ /**
19
+ * 扫描 ~/.claude/projects/<encoded-path>/ 找到最新的 CLI session。
20
+ * 只返回 JSONL 文件(排除子目录如 subagents/),按修改时间倒序。
21
+ * @param homeOverride 测试用:覆盖 homedir() 的返回值
22
+ */
23
+ export declare function findLatestClaudeSession(workDir: string, homeOverride?: string): ClaudeSessionMeta | undefined;
12
24
  /**
13
25
  * 由 initAdapters 根据配置调用。ttlMinutes≤0 时关闭空闲回收(仍受 MAX_ACTIVE_SESSIONS 限制)。
14
26
  */
@@ -26,3 +38,4 @@ export declare class ClaudeSDKAdapter implements ToolAdapter {
26
38
  static removeSession(sessionId: string): void;
27
39
  run(prompt: string, sessionId: string | undefined, workDir: string, callbacks: RunCallbacks, options?: RunOptions): RunHandle;
28
40
  }
41
+ 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, openSync, readSync, closeSync } 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,141 @@ 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 将路径中的 / 替换为 -,leading - 保留
57
+ // /Users/mac/github/open-im → -Users-mac-github-open-im
58
+ return workDir.replace(/\//g, '-');
59
+ }
60
+ /**
61
+ * 检查 CLI 进程是否**正在活跃使用**某个 session。
62
+ *
63
+ * 仅当同时满足以下两个条件时返回 true:
64
+ * 1. 存在包含该 sessionId 的 claude 进程(通过 ps 检测)
65
+ * 2. session 文件在最近 30 秒内被修改过(说明 CLI 正在处理消息)
66
+ *
67
+ * 如果 CLI 只是挂在终端等待用户输入,进程仍在但文件长时间未变,
68
+ * 此时安全允许 open-im 接管 session(无缝切换)。
69
+ *
70
+ * 注意:仅支持 macOS/Linux,Windows 上会静默返回 false
71
+ */
72
+ function isCliSessionActive(sessionId, sessionFilePath) {
73
+ try {
74
+ // macOS/Linux: 用 ps 搜索包含该 sessionId 的 claude 进程
75
+ // 排除 open-im 自己的 SDK 子进程(路径含 claude-agent-sdk),只匹配用户交互式 CLI
76
+ // -F 固定字符串匹配,避免正则意外;-- 防止 sessionId 被误认为 flag
77
+ 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
+ if (result.trim().length === 0)
79
+ return false;
80
+ // 进程存在,但可能只是 idle 在终端等输入。检查文件 mtime:
81
+ // 如果 session 文件超过 30 秒未修改,说明 CLI 没在活跃处理。
82
+ if (sessionFilePath) {
83
+ try {
84
+ const stat = statSync(sessionFilePath);
85
+ const ageMs = Date.now() - stat.mtimeMs;
86
+ if (ageMs > 30_000) {
87
+ log.info(`CLI process found for ${sessionId} but session file idle for ${Math.round(ageMs / 1000)}s, treating as inactive`);
88
+ return false;
89
+ }
90
+ }
91
+ catch {
92
+ // stat 失败时保守地认为活跃
93
+ }
94
+ }
95
+ return true;
96
+ }
97
+ catch {
98
+ return false;
99
+ }
100
+ }
101
+ /**
102
+ * 扫描 ~/.claude/projects/<encoded-path>/ 找到最新的 CLI session。
103
+ * 只返回 JSONL 文件(排除子目录如 subagents/),按修改时间倒序。
104
+ * @param homeOverride 测试用:覆盖 homedir() 的返回值
105
+ */
106
+ export function findLatestClaudeSession(workDir, homeOverride) {
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
+ }
112
+ try {
113
+ const entries = readdirSync(projectDir, { withFileTypes: true });
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
+ }
135
+ if (sessions.length === 0) {
136
+ log.info(`No session files found in ${projectDir}`);
137
+ return undefined;
138
+ }
139
+ // 按修改时间倒序,最新的在前
140
+ sessions.sort((a, b) => b.mtime - a.mtime);
141
+ const latest = sessions[0];
142
+ log.info(`Found latest Claude Code session: ${latest.sessionId} (mtime: ${new Date(latest.mtime).toISOString()}, size: ${latest.size})`);
143
+ return latest;
144
+ }
145
+ catch (err) {
146
+ log.warn(`Failed to scan Claude Code project dir: ${err}`);
147
+ return undefined;
148
+ }
149
+ }
150
+ /**
151
+ * 从 JSONL 文件的第一行提取 sessionId,验证文件内容与文件名一致。
152
+ * 只读取前 4KB,避免大文件全量读入内存。
153
+ */
154
+ function validateSessionFile(filePath, expectedSessionId) {
155
+ let fd;
156
+ try {
157
+ fd = openSync(filePath, 'r');
158
+ const buf = Buffer.alloc(4096);
159
+ const bytesRead = readSync(fd, buf, 0, 4096, 0);
160
+ if (bytesRead === 0)
161
+ return false;
162
+ const firstLine = buf.toString('utf-8', 0, bytesRead).split('\n')[0];
163
+ if (!firstLine)
164
+ return false;
165
+ const firstEntry = JSON.parse(firstLine);
166
+ return firstEntry.sessionId === expectedSessionId;
167
+ }
168
+ catch {
169
+ return false;
170
+ }
171
+ finally {
172
+ if (fd !== undefined) {
173
+ try {
174
+ closeSync(fd);
175
+ }
176
+ catch { /* ignore */ }
177
+ }
178
+ }
179
+ }
49
180
  // 存储所有活跃的 SDKSession 对象,key 为 sessionId
50
181
  // 使用 Map 而不是 Set,因为我们需要通过 sessionId 获取 session
51
182
  const activeSessions = new Map();
183
+ // 记录每个 session 创建/恢复时的 workDir,防止跨 workDir 复用已固定 cwd 的子进程
184
+ const sessionWorkDirs = new Map();
52
185
  // 存储正在进行的流式迭代器,用于中断
53
186
  const activeStreams = new Set();
54
187
  // 空闲会话清理:跟踪最后使用时间,定期清除超时会话
@@ -92,34 +225,53 @@ const cleanupInterval = setInterval(() => {
92
225
  activeSessions.delete(id);
93
226
  }
94
227
  sessionLastUsed.delete(id);
228
+ sessionWorkDirs.delete(id);
95
229
  log.info(`Cleaned up idle session (unused ${Math.round((now - lastUsed) / 60000)}min): ${id}`);
96
230
  }
97
231
  }
98
232
  }, CLEANUP_INTERVAL_MS);
99
233
  cleanupInterval.unref(); // 不阻止进程退出
100
234
  /**
101
- * Serializes process.chdir() calls across concurrent users.
235
+ * 串行化进程级 process.chdir() —— 同一时刻仅一个 chdir 生效。
102
236
  *
103
- * process.chdir() is a process-wide global side effect — only one chdir can
104
- * be active at a time. The SDK's createSession/resumeSession do not accept a
105
- * `cwd` parameter, so we must chdir before calling them. This mutex ensures
106
- * concurrent requests don't race on the working directory.
237
+ * SDK V2 createSession/resumeSession 不接受 cwd 参数;且 send()/stream()
238
+ * 会以「调用时的 process.cwd()」派生 Claude Code 子进程。必须用互斥锁串行化
239
+ * 「整个 turn cwd 切换」,否则并发多用户会让工具跑错目录。
107
240
  *
108
- * **TODO:** Remove this mutex entirely once @anthropic-ai/claude-agent-sdk
109
- * supports a `cwd` option in createSession/resumeSession. Track upstream:
241
+ * **TODO:** SDK 支持 cwd 选项后移除此锁。upstream:
110
242
  * https://github.com/anthropics/claude-agent-sdk/issues
111
243
  */
112
244
  let chdirMutex = Promise.resolve();
113
245
  function withChdirMutex(fn) {
114
246
  const previous = chdirMutex;
115
- let resolve;
116
- chdirMutex = new Promise((r) => { resolve = r; });
117
- return previous.then(() => {
247
+ let release;
248
+ chdirMutex = new Promise((r) => { release = r; });
249
+ return previous.then(async () => {
118
250
  try {
119
- return fn();
251
+ return await fn();
120
252
  }
121
253
  finally {
122
- resolve();
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
+ }
123
275
  }
124
276
  });
125
277
  }
@@ -201,18 +353,21 @@ async function getOrCreateSession(sessionId, workDir, model, permissionMode, onS
201
353
  process.chdir(workDir);
202
354
  }
203
355
  if (sessionId) {
204
- // 优先复用内存中已有的 SDKSession,避免每次都启动新进程
356
+ // 优先复用内存中已有的 SDKSession,避免每次都启动新进程。
357
+ // 仅当 workDir 与创建时一致才复用:否则子进程 cwd 已固定在旧目录,
358
+ // 需走 resume 重新派生(在当前 workDir 启动新子进程)。
205
359
  const existing = activeSessions.get(sessionId);
206
- if (existing) {
360
+ if (existing && sessionWorkDirs.get(sessionId) === workDir) {
207
361
  log.info(`Reusing existing in-memory session: ${sessionId}`);
208
362
  sessionLastUsed.set(sessionId, Date.now());
209
363
  return { session: existing, sessionId };
210
364
  }
211
- // 内存中没有,尝试通过 resume 恢复(会启动新 CLI 进程)
365
+ // 内存中没有(或 workDir 变了),尝试通过 resume 恢复(会启动新 CLI 进程)
212
366
  try {
213
367
  log.info(`Attempting to resume session: ${sessionId}`);
214
368
  session = unstable_v2_resumeSession(sessionId, sessionOptions);
215
369
  activeSessions.set(sessionId, session);
370
+ sessionWorkDirs.set(sessionId, workDir);
216
371
  sessionLastUsed.set(sessionId, Date.now());
217
372
  log.info(`Successfully resumed session: ${sessionId}`);
218
373
  return { session, sessionId };
@@ -222,12 +377,43 @@ async function getOrCreateSession(sessionId, workDir, model, permissionMode, onS
222
377
  // 恢复失败,创建新会话
223
378
  }
224
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
+ }
225
410
  // 创建新会话
226
411
  session = unstable_v2_createSession(sessionOptions);
227
412
  // 新会话的 sessionId 需要从第一个消息中获取
228
413
  // 暂时返回 undefined,稍后在 init 消息中获取
229
414
  const tempId = `pending-${++sessionSeq}`;
230
415
  activeSessions.set(tempId, session);
416
+ sessionWorkDirs.set(tempId, workDir);
231
417
  sessionLastUsed.set(tempId, Date.now());
232
418
  log.info(`Created new session (tempId: ${tempId})`);
233
419
  return { session, sessionId: tempId, wasReused: false };
@@ -267,6 +453,7 @@ export class ClaudeSDKAdapter {
267
453
  }
268
454
  activeSessions.clear();
269
455
  sessionLastUsed.clear();
456
+ sessionWorkDirs.clear();
270
457
  }
271
458
  /**
272
459
  * Remove a specific session from the in-memory cache and close it.
@@ -281,6 +468,7 @@ export class ClaudeSDKAdapter {
281
468
  catch { /* ignore */ }
282
469
  activeSessions.delete(sessionId);
283
470
  sessionLastUsed.delete(sessionId);
471
+ sessionWorkDirs.delete(sessionId);
284
472
  log.info(`Explicitly removed session: ${sessionId}`);
285
473
  }
286
474
  }
@@ -320,12 +508,16 @@ export class ClaudeSDKAdapter {
320
508
  }
321
509
  runningSessions.add(returnedId);
322
510
  trackedRunningId = returnedId;
323
- // 发送用户消息
324
- await session.send(prompt);
325
- // 获取响应流
326
- const stream = session.stream();
327
- currentStream = stream;
328
- activeStreams.add(stream);
511
+ // 在持有 chdir 锁期间完成 send() + stream() 获取:SDK V2 会以当前
512
+ // process.cwd() 派生 Claude Code 子进程,必须保证此刻 cwd 为 workDir。
513
+ // 锁串行化后,并发多用户的子进程不会在错误的目录派生。
514
+ const stream = await runWithWorkDir(workDir, async () => {
515
+ await session.send(prompt);
516
+ const s = session.stream();
517
+ currentStream = s;
518
+ activeStreams.add(s);
519
+ return s;
520
+ });
329
521
  let accumulated = '';
330
522
  let accumulatedThinking = '';
331
523
  const toolStats = {};
@@ -348,13 +540,19 @@ export class ClaudeSDKAdapter {
348
540
  // 更新 sessionId 映射
349
541
  // 清理 pending 临时 ID(actualSessionId 尚未赋值时用 pendingTempId)
350
542
  const idToClean = actualSessionId ?? pendingTempId;
543
+ const inheritedWorkDir = idToClean ? sessionWorkDirs.get(idToClean) : undefined;
351
544
  if (idToClean?.startsWith('pending-')) {
352
545
  activeSessions.delete(idToClean);
353
546
  }
354
547
  activeSessions.set(newSessionId, session);
548
+ if (inheritedWorkDir !== undefined) {
549
+ sessionWorkDirs.set(newSessionId, inheritedWorkDir);
550
+ }
355
551
  sessionLastUsed.set(newSessionId, Date.now());
356
- if (idToClean)
552
+ if (idToClean) {
357
553
  sessionLastUsed.delete(idToClean);
554
+ sessionWorkDirs.delete(idToClean);
555
+ }
358
556
  // 更新 runningSessions:移除旧 ID,添加新 ID
359
557
  if (idToClean)
360
558
  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
  }
package/dist/cli.js CHANGED
@@ -97,21 +97,28 @@ async function cmdStop() {
97
97
  }
98
98
  async function cmdRestart() {
99
99
  const status = getManagerStatus();
100
+ let restartedExistingInstance = false;
100
101
  if (status.pid) {
101
102
  await stopBackgroundService();
102
103
  const stopped = await stopManagerProcess();
103
104
  console.log("\nopen-im stopped.");
104
105
  console.log(` pid: ${stopped.pid}`);
106
+ restartedExistingInstance = true;
105
107
  }
106
108
  else {
107
- console.log("open-im is not running in the background. Starting a new instance.");
109
+ console.log("\nopen-im is not running in the background.");
108
110
  }
109
111
  if (!(await ensureConfigured("start"))) {
110
112
  process.exit(1);
111
113
  }
112
114
  await checkAndUpdate();
113
115
  const child = await startManagerProcess(process.cwd());
114
- console.log("\nopen-im restarted in the background.");
116
+ if (restartedExistingInstance) {
117
+ console.log("\nopen-im restarted in the background.");
118
+ }
119
+ else {
120
+ console.log("\nopen-im started in the background.");
121
+ }
115
122
  console.log(` pid: ${child.pid}`);
116
123
  logWebDashboardAndApi();
117
124
  process.exit(0);
@@ -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 (!child.killed)
355
- child.kill('SIGTERM');
380
+ if (cliTimeoutHandle) {
381
+ clearTimeout(cliTimeoutHandle);
382
+ cliTimeoutHandle = null;
383
+ }
384
+ killProcessTree(child);
356
385
  },
357
386
  };
358
387
  }
@@ -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
- if (!child.killed)
395
- child.kill('SIGTERM');
421
+ killProcessTree(child);
396
422
  },
397
423
  };
398
424
  }
@@ -19,8 +19,11 @@ export declare function getClaudeSdkRuntimeIssue(): string | null;
19
19
  export declare function parseCommaSeparated(value: string): string[];
20
20
  /**
21
21
  * 将最新的 Claude 认证环境变量按优先级合并到 process.env。
22
- * 优先级:shell 环境变量 > ~/.open-im/config.json tools.claude.env >
23
- * 本机 Claude 配置(~/.claude/settings.json,与 Claude Code 共用)。
24
- * 多数用户只维护本机 settings;每次创建 Claude SDK 会话前调用,本机文件变更后下次会话即生效。
22
+ * 优先级:shell 环境变量 > 本机 Claude 配置(~/.claude/settings.json,与 Claude Code 共用)>
23
+ * ~/.open-im/config.json tools.claude.env。
24
+ *
25
+ * 设计意图:用户只需维护 ~/.claude/settings.json(与 Claude Code CLI 共用),
26
+ * open-im 自动跟随本地 Claude 配置,无需单独配置。config.json 的 tools.claude.env
27
+ * 仅作为兜底,供没有本地 Claude 安装的场景使用。
25
28
  */
26
29
  export declare function refreshClaudeEnvToProcess(): void;