@wu529778790/open-im 1.9.3-beta.2 → 1.9.3-beta.4

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.
@@ -16,6 +16,28 @@ const log = createLogger('ClaudeSDK');
16
16
  const activeSessions = new Map();
17
17
  // 存储正在进行的流式迭代器,用于中断
18
18
  const activeStreams = new Set();
19
+ // 空闲会话清理:跟踪最后使用时间,定期清除超时会话
20
+ const sessionLastUsed = new Map();
21
+ const SESSION_IDLE_TTL_MS = 30 * 60 * 1000; // 30 分钟未使用则清理
22
+ const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 每 5 分钟检查一次
23
+ const cleanupInterval = setInterval(() => {
24
+ const now = Date.now();
25
+ for (const [id, lastUsed] of sessionLastUsed) {
26
+ if (now - lastUsed > SESSION_IDLE_TTL_MS) {
27
+ const session = activeSessions.get(id);
28
+ if (session) {
29
+ try {
30
+ session.close();
31
+ }
32
+ catch { /* ignore */ }
33
+ activeSessions.delete(id);
34
+ }
35
+ sessionLastUsed.delete(id);
36
+ log.info(`Cleaned up idle session (unused ${Math.round((now - lastUsed) / 60000)}min): ${id}`);
37
+ }
38
+ }
39
+ }, CLEANUP_INTERVAL_MS);
40
+ cleanupInterval.unref(); // 不阻止进程退出
19
41
  // Mutex to serialize process.chdir() calls across concurrent users
20
42
  let chdirMutex = Promise.resolve();
21
43
  function withChdirMutex(fn) {
@@ -60,7 +82,9 @@ async function getOrCreateSession(sessionId, workDir, model, permissionMode) {
60
82
  };
61
83
  const baseUrl = process.env.ANTHROPIC_BASE_URL ?? '(default)';
62
84
  log.info(`[V2] getOrCreateSession model param=${String(model ?? '')} resolved=${resolvedModel} baseUrl=${baseUrl} workDir=${workDir}`);
63
- // Use mutex to serialize process.chdir() calls across concurrent users
85
+ // NOTE: process.chdir() 是进程级全局副作用,在并发服务器中不理想。
86
+ // 但 SDK 的 createSession/resumeSession 不接受 cwd 参数,且这些调用是同步的,
87
+ // 所以 mutex + try/finally 已是最优方案。如果 SDK 未来支持 cwd 选项,应移除 chdir。
64
88
  return withChdirMutex(() => {
65
89
  let session;
66
90
  const originalCwd = process.cwd();
@@ -69,11 +93,19 @@ async function getOrCreateSession(sessionId, workDir, model, permissionMode) {
69
93
  process.chdir(workDir);
70
94
  }
71
95
  if (sessionId) {
72
- // 尝试恢复已有会话
96
+ // 优先复用内存中已有的 SDKSession,避免每次都启动新进程
97
+ const existing = activeSessions.get(sessionId);
98
+ if (existing) {
99
+ log.info(`Reusing existing in-memory session: ${sessionId}`);
100
+ sessionLastUsed.set(sessionId, Date.now());
101
+ return { session: existing, sessionId };
102
+ }
103
+ // 内存中没有,尝试通过 resume 恢复(会启动新 CLI 进程)
73
104
  try {
74
105
  log.info(`Attempting to resume session: ${sessionId}`);
75
106
  session = unstable_v2_resumeSession(sessionId, sessionOptions);
76
107
  activeSessions.set(sessionId, session);
108
+ sessionLastUsed.set(sessionId, Date.now());
77
109
  log.info(`Successfully resumed session: ${sessionId}`);
78
110
  return { session, sessionId };
79
111
  }
@@ -88,6 +120,7 @@ async function getOrCreateSession(sessionId, workDir, model, permissionMode) {
88
120
  // 暂时返回 undefined,稍后在 init 消息中获取
89
121
  const tempId = `pending-${Date.now()}`;
90
122
  activeSessions.set(tempId, session);
123
+ sessionLastUsed.set(tempId, Date.now());
91
124
  log.info(`Created new session (tempId: ${tempId})`);
92
125
  return { session, sessionId: tempId };
93
126
  }
@@ -104,6 +137,7 @@ export class ClaudeSDKAdapter {
104
137
  * 清理所有活跃的 SDK 会话和流
105
138
  */
106
139
  static destroy() {
140
+ clearInterval(cleanupInterval);
107
141
  for (const stream of activeStreams) {
108
142
  try {
109
143
  if (stream && typeof stream.return === 'function') {
@@ -124,6 +158,7 @@ export class ClaudeSDKAdapter {
124
158
  }
125
159
  }
126
160
  activeSessions.clear();
161
+ sessionLastUsed.clear();
127
162
  }
128
163
  run(prompt, sessionId, workDir, callbacks, options) {
129
164
  log.info(`[V2] run() entry model=${String(options?.model ?? '')} baseUrl=${process.env.ANTHROPIC_BASE_URL ?? '(default)'}`);
@@ -173,10 +208,15 @@ export class ClaudeSDKAdapter {
173
208
  const newSessionId = msg.session_id;
174
209
  if (newSessionId && newSessionId !== actualSessionId) {
175
210
  // 更新 sessionId 映射
176
- if (actualSessionId && actualSessionId.startsWith('pending-')) {
177
- activeSessions.delete(actualSessionId);
211
+ // 清理 pending 临时 ID(actualSessionId 尚未赋值时用 pendingTempId)
212
+ const idToClean = actualSessionId ?? pendingTempId;
213
+ if (idToClean?.startsWith('pending-')) {
214
+ activeSessions.delete(idToClean);
178
215
  }
179
216
  activeSessions.set(newSessionId, session);
217
+ sessionLastUsed.set(newSessionId, Date.now());
218
+ if (idToClean)
219
+ sessionLastUsed.delete(idToClean);
180
220
  actualSessionId = newSessionId;
181
221
  log.info(`[V2] Got actual sessionId: ${newSessionId}`);
182
222
  callbacks.onSessionId?.(newSessionId);
@@ -305,8 +345,15 @@ export class ClaudeSDKAdapter {
305
345
  callbacks.onError(msg);
306
346
  }
307
347
  };
308
- // 启动会话(不等待)
309
- runSession();
348
+ // 启动会话(不等待),catch 兜底防止 unhandledRejection 导致用户请求挂起
349
+ runSession().catch((err) => {
350
+ if (!runSettled) {
351
+ runSettled = true;
352
+ const msg = err instanceof Error ? err.message : String(err);
353
+ log.error(`Unhandled runSession error: ${msg}`);
354
+ callbacks.onError(msg);
355
+ }
356
+ });
310
357
  return {
311
358
  abort: () => {
312
359
  log.info('Aborting session run');
@@ -180,6 +180,15 @@ function clean(value) {
180
180
  const trimmed = value.trim();
181
181
  return trimmed ? trimmed : undefined;
182
182
  }
183
+ function isMasked(value) {
184
+ return typeof value === "string" && value.includes("****");
185
+ }
186
+ /** 如果前端传回的是掩码值(包含 ****),保留 existing 中的真实密钥,避免覆盖 */
187
+ function resolveSecret(incoming, existing) {
188
+ if (isMasked(incoming))
189
+ return existing;
190
+ return clean(incoming);
191
+ }
183
192
  const MAX_REQUEST_BODY_BYTES = 1 * 1024 * 1024; // 1 MB
184
193
  function readJson(request) {
185
194
  return new Promise((resolve, reject) => {
@@ -558,8 +567,10 @@ export async function testPlatformConfig(platform, config) {
558
567
  function toFileConfig(payload, existing) {
559
568
  // Save Claude environment variables to ~/.claude/settings.json
560
569
  const claudeEnv = {};
561
- if (payload.ai.claudeAuthToken)
562
- claudeEnv.ANTHROPIC_AUTH_TOKEN = payload.ai.claudeAuthToken;
570
+ const existingClaudeEnv = loadClaudeSettingsEnv();
571
+ const resolvedAuthToken = resolveSecret(payload.ai.claudeAuthToken, existingClaudeEnv.ANTHROPIC_AUTH_TOKEN);
572
+ if (resolvedAuthToken)
573
+ claudeEnv.ANTHROPIC_AUTH_TOKEN = resolvedAuthToken;
563
574
  if (payload.ai.claudeBaseUrl)
564
575
  claudeEnv.ANTHROPIC_BASE_URL = payload.ai.claudeBaseUrl;
565
576
  if (payload.ai.claudeModel)
@@ -600,7 +611,7 @@ function toFileConfig(payload, existing) {
600
611
  ...existing.platforms?.telegram,
601
612
  enabled: payload.platforms.telegram.enabled,
602
613
  aiCommand: clean(payload.platforms.telegram.aiCommand),
603
- botToken: clean(payload.platforms.telegram.botToken),
614
+ botToken: resolveSecret(payload.platforms.telegram.botToken, existing.platforms?.telegram?.botToken),
604
615
  proxy: clean(payload.platforms.telegram.proxy),
605
616
  allowedUserIds: splitCsv(payload.platforms.telegram.allowedUserIds),
606
617
  },
@@ -609,7 +620,7 @@ function toFileConfig(payload, existing) {
609
620
  enabled: payload.platforms.feishu.enabled,
610
621
  aiCommand: clean(payload.platforms.feishu.aiCommand),
611
622
  appId: clean(payload.platforms.feishu.appId),
612
- appSecret: clean(payload.platforms.feishu.appSecret),
623
+ appSecret: resolveSecret(payload.platforms.feishu.appSecret, existing.platforms?.feishu?.appSecret),
613
624
  allowedUserIds: splitCsv(payload.platforms.feishu.allowedUserIds),
614
625
  },
615
626
  qq: {
@@ -617,7 +628,7 @@ function toFileConfig(payload, existing) {
617
628
  enabled: payload.platforms.qq.enabled,
618
629
  aiCommand: clean(payload.platforms.qq.aiCommand),
619
630
  appId: clean(payload.platforms.qq.appId),
620
- secret: clean(payload.platforms.qq.secret),
631
+ secret: resolveSecret(payload.platforms.qq.secret, existing.platforms?.qq?.secret),
621
632
  allowedUserIds: splitCsv(payload.platforms.qq.allowedUserIds),
622
633
  },
623
634
  wework: {
@@ -625,7 +636,7 @@ function toFileConfig(payload, existing) {
625
636
  enabled: payload.platforms.wework.enabled,
626
637
  aiCommand: clean(payload.platforms.wework.aiCommand),
627
638
  corpId: clean(payload.platforms.wework.corpId),
628
- secret: clean(payload.platforms.wework.secret),
639
+ secret: resolveSecret(payload.platforms.wework.secret, existing.platforms?.wework?.secret),
629
640
  allowedUserIds: splitCsv(payload.platforms.wework.allowedUserIds),
630
641
  },
631
642
  dingtalk: {
@@ -633,7 +644,7 @@ function toFileConfig(payload, existing) {
633
644
  enabled: payload.platforms.dingtalk.enabled,
634
645
  aiCommand: clean(payload.platforms.dingtalk.aiCommand),
635
646
  clientId: clean(payload.platforms.dingtalk.clientId),
636
- clientSecret: clean(payload.platforms.dingtalk.clientSecret),
647
+ clientSecret: resolveSecret(payload.platforms.dingtalk.clientSecret, existing.platforms?.dingtalk?.clientSecret),
637
648
  cardTemplateId: clean(payload.platforms.dingtalk.cardTemplateId),
638
649
  allowedUserIds: splitCsv(payload.platforms.dingtalk.allowedUserIds),
639
650
  },
@@ -641,8 +652,8 @@ function toFileConfig(payload, existing) {
641
652
  ...existing.platforms?.workbuddy,
642
653
  enabled: payload.platforms.workbuddy.enabled,
643
654
  aiCommand: clean(payload.platforms.workbuddy.aiCommand),
644
- accessToken: clean(payload.platforms.workbuddy.accessToken),
645
- refreshToken: clean(payload.platforms.workbuddy.refreshToken),
655
+ accessToken: resolveSecret(payload.platforms.workbuddy.accessToken, existing.platforms?.workbuddy?.accessToken),
656
+ refreshToken: resolveSecret(payload.platforms.workbuddy.refreshToken, existing.platforms?.workbuddy?.refreshToken),
646
657
  userId: clean(payload.platforms.workbuddy.userId),
647
658
  baseUrl: clean(payload.platforms.workbuddy.baseUrl),
648
659
  allowedUserIds: splitCsv(payload.platforms.workbuddy.allowedUserIds),
package/dist/index.js CHANGED
@@ -294,7 +294,7 @@ export async function main() {
294
294
  process.on("SIGTERM", () => shutdown().catch(() => process.exit(1)));
295
295
  // Global error handlers to prevent unhandled crashes
296
296
  process.on("unhandledRejection", (reason) => {
297
- log.error("Unhandled Promise rejection:", reason);
297
+ log.error("Unhandled Promise rejection (this indicates a bug — the affected request may hang without a response):", reason);
298
298
  });
299
299
  process.on("uncaughtException", (err) => {
300
300
  const msg = err?.message ?? String(err);
@@ -250,33 +250,46 @@ export class SessionManager {
250
250
  }
251
251
  load(previousDefaultWorkDir) {
252
252
  try {
253
- if (existsSync(SESSIONS_FILE)) {
254
- const data = JSON.parse(readFileSync(SESSIONS_FILE, 'utf-8'));
255
- for (const [k, v] of Object.entries(data)) {
256
- if (v && typeof v.workDir === 'string') {
257
- // 如果该会话目录等于旧默认目录,则迁移到新的默认目录(认为用户没有手动 /cd 过)
258
- if (previousDefaultWorkDir && v.workDir === previousDefaultWorkDir) {
259
- v.workDir = this.defaultWorkDir;
260
- }
261
- if (!v.activeConvId)
262
- v.activeConvId = randomBytes(4).toString('hex');
263
- if (!v.sessionIds)
264
- v.sessionIds = {};
265
- if ('sessionId' in v) {
266
- log.warn(`Legacy shared sessionId found for user ${k}; clearing it to avoid cross-tool resume conflicts`);
267
- }
268
- delete v.sessionId;
269
- if (v.threads) {
270
- for (const thread of Object.values(v.threads)) {
271
- if (!thread.sessionIds)
272
- thread.sessionIds = {};
273
- if ('sessionId' in thread) {
274
- log.warn(`Legacy thread sessionId found for user ${k}; clearing it during session migration`);
275
- }
276
- delete thread.sessionId;
253
+ if (!existsSync(SESSIONS_FILE))
254
+ return;
255
+ const raw = JSON.parse(readFileSync(SESSIONS_FILE, 'utf-8'));
256
+ // v2 格式: { sessions: {...}, convSessionMap: {...} }
257
+ // v1 格式: 直接是 Record<string, UserSession>
258
+ const isV2 = raw && typeof raw === 'object' && raw.sessions && typeof raw.sessions === 'object' && !raw.workDir;
259
+ const sessionData = isV2 ? raw.sessions : raw;
260
+ const convMapData = isV2 ? raw.convSessionMap : undefined;
261
+ for (const [k, v] of Object.entries(sessionData)) {
262
+ if (v && typeof v.workDir === 'string') {
263
+ // 如果该会话目录等于旧默认目录,则迁移到新的默认目录(认为用户没有手动 /cd 过)
264
+ if (previousDefaultWorkDir && v.workDir === previousDefaultWorkDir) {
265
+ v.workDir = this.defaultWorkDir;
266
+ }
267
+ if (!v.activeConvId)
268
+ v.activeConvId = randomBytes(4).toString('hex');
269
+ if (!v.sessionIds)
270
+ v.sessionIds = {};
271
+ if ('sessionId' in v) {
272
+ log.warn(`Legacy shared sessionId found for user ${k}; clearing it to avoid cross-tool resume conflicts`);
273
+ }
274
+ delete v.sessionId;
275
+ if (v.threads) {
276
+ for (const thread of Object.values(v.threads)) {
277
+ if (!thread.sessionIds)
278
+ thread.sessionIds = {};
279
+ if ('sessionId' in thread) {
280
+ log.warn(`Legacy thread sessionId found for user ${k}; clearing it during session migration`);
277
281
  }
282
+ delete thread.sessionId;
278
283
  }
279
- this.sessions.set(k, v);
284
+ }
285
+ this.sessions.set(k, v);
286
+ }
287
+ }
288
+ // 恢复 convSessionMap
289
+ if (convMapData && typeof convMapData === 'object') {
290
+ for (const [k, v] of Object.entries(convMapData)) {
291
+ if (typeof v === 'string') {
292
+ this.convSessionMap.set(k, v);
280
293
  }
281
294
  }
282
295
  }
@@ -312,10 +325,13 @@ export class SessionManager {
312
325
  const dir = dirname(SESSIONS_FILE);
313
326
  if (!existsSync(dir))
314
327
  mkdirSync(dir, { recursive: true });
315
- const obj = {};
328
+ const sessions = {};
316
329
  for (const [k, v] of this.sessions)
317
- obj[k] = v;
318
- writeFileSync(SESSIONS_FILE, JSON.stringify(obj, null, 2), 'utf-8');
330
+ sessions[k] = v;
331
+ const convSessionMapObj = {};
332
+ for (const [k, v] of this.convSessionMap)
333
+ convSessionMapObj[k] = v;
334
+ writeFileSync(SESSIONS_FILE, JSON.stringify({ sessions, convSessionMap: convSessionMapObj }, null, 2), 'utf-8');
319
335
  }
320
336
  catch (err) {
321
337
  log.error('Failed to save sessions:', err);
@@ -149,13 +149,23 @@ export class WorkBuddyCentrifugeClient {
149
149
  // The WorkBuddy server uses the registered channelId as the WeChat KF send_msg
150
150
  // `touser`. Re-register the channel with the current WeChat user's externalUserId
151
151
  // so that the server sends the reply to the correct customer.
152
- if (this.config.registerChannelFn && sessionId.includes('::')) {
153
- const externalUserId = sessionId.split('::')[0];
154
- try {
155
- await this.config.registerChannelFn(externalUserId);
156
- }
157
- catch (err) {
158
- log.warn(`${this.logPrefix} registerChannelFn failed (reply may go to wrong user):`, err);
152
+ const externalUserId = sessionId.includes('::') ? sessionId.split('::')[0] : null;
153
+ if (this.config.registerChannelFn && externalUserId) {
154
+ // Retry registerChannelFn up to 3 times on network failure
155
+ for (let attempt = 1; attempt <= 3; attempt++) {
156
+ try {
157
+ await this.config.registerChannelFn(externalUserId);
158
+ break;
159
+ }
160
+ catch (err) {
161
+ if (attempt < 3) {
162
+ log.warn(`${this.logPrefix} registerChannelFn attempt ${attempt} failed, retrying in 2s:`, err);
163
+ await new Promise((r) => setTimeout(r, 2000));
164
+ }
165
+ else {
166
+ log.warn(`${this.logPrefix} registerChannelFn failed after 3 attempts (reply may go to wrong user):`, err);
167
+ }
168
+ }
159
169
  }
160
170
  }
161
171
  const httpPayload = {
@@ -172,31 +182,44 @@ export class WorkBuddyCentrifugeClient {
172
182
  };
173
183
  const url = `${this.config.httpBaseUrl}/v2/backgroundagent/wecom/local-proxy/receive`;
174
184
  log.debug(`${this.logPrefix} HTTP COPILOT_RESPONSE → ${url} chatId=${sessionId} msgLen=${message.length}`);
175
- try {
176
- const res = await fetch(url, {
177
- method: 'POST',
178
- headers: {
179
- 'Content-Type': 'application/json',
180
- Authorization: `Bearer ${this.config.httpAccessToken}`,
181
- },
182
- body: JSON.stringify(httpPayload),
183
- signal: AbortSignal.timeout(30_000),
184
- });
185
- const body = await res.text().catch(() => '');
186
- if (!res.ok) {
187
- log.error(`${this.logPrefix} HTTP COPILOT_RESPONSE failed: ${res.status} ${body.substring(0, 300)}`);
185
+ // Retry COPILOT_RESPONSE up to 3 times on network failure
186
+ let sent = false;
187
+ for (let attempt = 1; attempt <= 3; attempt++) {
188
+ try {
189
+ const res = await fetch(url, {
190
+ method: 'POST',
191
+ headers: {
192
+ 'Content-Type': 'application/json',
193
+ Authorization: `Bearer ${this.config.httpAccessToken}`,
194
+ },
195
+ body: JSON.stringify(httpPayload),
196
+ signal: AbortSignal.timeout(30_000),
197
+ });
198
+ const body = await res.text().catch(() => '');
199
+ if (!res.ok) {
200
+ log.error(`${this.logPrefix} HTTP COPILOT_RESPONSE failed: ${res.status} ${body.substring(0, 300)}`);
201
+ }
202
+ else {
203
+ log.info(`${this.logPrefix} HTTP COPILOT_RESPONSE ok: ${res.status} ${body.substring(0, 200)}`);
204
+ }
205
+ sent = true;
206
+ break;
188
207
  }
189
- else {
190
- log.info(`${this.logPrefix} HTTP COPILOT_RESPONSE ok: ${res.status} ${body.substring(0, 200)}`);
208
+ catch (err) {
209
+ if (attempt < 3) {
210
+ log.warn(`${this.logPrefix} HTTP COPILOT_RESPONSE attempt ${attempt} failed, retrying in 2s:`, err);
211
+ await new Promise((r) => setTimeout(r, 2000));
212
+ }
213
+ else {
214
+ log.error(`${this.logPrefix} HTTP COPILOT_RESPONSE error after 3 attempts:`, err);
215
+ }
191
216
  }
192
217
  }
193
- catch (err) {
194
- log.error(`${this.logPrefix} HTTP COPILOT_RESPONSE error:`, err);
195
- }
196
- finally {
197
- // Release the heartbeat lock so the periodic registration can resume
198
- this.config.releaseChannelLockFn?.();
218
+ if (!sent) {
219
+ log.error(`${this.logPrefix} Failed to send COPILOT_RESPONSE after retries`);
199
220
  }
221
+ // Release the heartbeat lock so the periodic registration can resume
222
+ this.config.releaseChannelLockFn?.();
200
223
  return;
201
224
  }
202
225
  this.sendEnvelope('session.promptResponse', payload, _guid, _userId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.9.3-beta.2",
3
+ "version": "1.9.3-beta.4",
4
4
  "description": "Multi-platform IM bridge for AI CLI tools (Claude, Codex, CodeBuddy)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",