chatccc 0.2.49 → 0.2.51

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/src/index.ts CHANGED
@@ -30,7 +30,7 @@ import { resolve, dirname } from "node:path";
30
30
  import { WSClient, EventDispatcher } from "@larksuiteoapi/node-sdk";
31
31
  import WebSocket from "ws";
32
32
 
33
- import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRelayListenPort, installCrashLogging } from "./shared.ts";
33
+ import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRelayListenPort, installCrashLogging, waitForPortFree } from "./shared.ts";
34
34
  import { createUiRouter, setExtraApiHandler, setReloadConfigHook, startSetupMode } from "./web-ui.ts";
35
35
  import { makeTraceId, logTrace } from "./trace.ts";
36
36
  import {
@@ -97,14 +97,17 @@ import {
97
97
  initClaudeSession,
98
98
  lastMsgTimestamps,
99
99
  processedMessages,
100
+ rebuildBindingsFromRegistry,
100
101
  resetState,
101
102
  resumeAndPrompt,
102
103
  sessionInfoMap,
104
+ switchChatBinding,
103
105
  recordSessionRegistry,
104
106
  getAdapterForTool,
105
107
  stopSession,
106
108
  loadSessionRegistryForBinding,
107
109
  removeSessionRegistryRecord,
110
+ saveSessionTool,
108
111
  } from "./session.ts";
109
112
  import {
110
113
  bindChatToSession,
@@ -493,6 +496,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
493
496
  startTime: Date.now(),
494
497
  running: false,
495
498
  });
499
+ await saveSessionTool(sessionId, tool, initialName);
496
500
  await sendCardReply(
497
501
  freshToken, chatId, `${toolLabel} Session Ready`,
498
502
  `这是你的 **${toolLabel}** 私聊会话。\n\n` +
@@ -542,6 +546,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
542
546
  startTime: Date.now(),
543
547
  running: false,
544
548
  });
549
+ await saveSessionTool(sessionId, tool, initialName);
545
550
 
546
551
  const adapter = getAdapterForTool(tool);
547
552
  await sendCardReply(
@@ -628,6 +633,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
628
633
  await updateChatInfo(freshToken, chatId, newName, description!);
629
634
  console.log(`[${ts()}] [RENAME] First message → group renamed to "${newName}"`);
630
635
  await recordSessionRegistry({ chatId, sessionId, tool: descriptionTool, chatName: newName }).catch(() => {});
636
+ await saveSessionTool(sessionId, descriptionTool, newName).catch(() => {});
631
637
  } catch (err) {
632
638
  console.error(`[${ts()}] [RENAME] Failed: ${(err as Error).message}`);
633
639
  }
@@ -713,11 +719,10 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
713
719
  cwd = await getDefaultCwd(chatId);
714
720
  }
715
721
 
716
- // abort 旧 session,只解绑当前 chat
717
- // 旧 session 如果正在跑,display loop 继续服务其他群
718
- unbindChatFromSession(sessionId, chatId);
719
- displayCards.delete(chatId);
720
-
722
+ // 第一步:创建新 session(此时尚未碰任何内存绑定,失败可直接返回,
723
+ // 旧 session 状态完全保留)。
724
+ // 注意不 abort 旧 session:旧 session 若仍在跑,display loop 会继续
725
+ // 把卡片推到原群直到 prompt 自然结束(或被 /stop)
721
726
  let newSessionId: string;
722
727
  try {
723
728
  const init = await initClaudeSession(descriptionTool, cwd);
@@ -728,36 +733,46 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
728
733
  return;
729
734
  }
730
735
 
731
- // 绑定新 session
732
- bindChatToSession(newSessionId, chatId);
733
- recordLastActiveChat(newSessionId, chatId);
734
-
736
+ // 第二步:事务式切换 chat 绑定 —— 把 chat 从 oldSessionId 改嫁到
737
+ // newSessionId。switchChatBinding 内部保证:
738
+ // - 群聊先调 updateChatInfo 改群描述,失败则内存绑定完全不动
739
+ // - 私聊跳过 updateChatInfo(私聊 chatId 调群 API 必然失败)
740
+ // - API 成功后统一切换内存:unbind 旧 / clear displayCards / bind 新 /
741
+ // recordLastActiveChat / sessionInfoMap.set + 持久化 registry/sessions
742
+ // 详见 session.ts::switchChatBinding 注释。
735
743
  const descPrefix = sessionPrefixForTool(descriptionTool);
736
744
  const newName = sessionChatName("新会话", cwd);
737
- await updateChatInfo(freshToken, chatId, newName, `${descPrefix} ${newSessionId}`);
738
- console.log(`[${ts()}] [NEWH] Group updated: name="${newName}" desc="${descPrefix} ${newSessionId}"`);
739
-
740
- sessionInfoMap.set(chatId, {
741
- sessionId: newSessionId,
742
- turnCount: 0,
743
- lastContextTokens: 0,
744
- startTime: Date.now(),
745
- tool: descriptionTool,
746
- });
747
- await recordSessionRegistry({
745
+ const switchResult = await switchChatBinding({
748
746
  chatId,
749
- sessionId: newSessionId,
747
+ chatType,
748
+ oldSessionId: sessionId,
749
+ newSessionId,
750
750
  tool: descriptionTool,
751
751
  chatName: newName,
752
- turnCount: 0,
753
- lastContextTokens: 0,
754
- startTime: Date.now(),
755
- running: false,
752
+ newDescription: `${descPrefix} ${newSessionId}`,
753
+ updateChatInfoFn: (cid, name, desc) => updateChatInfo(freshToken, cid, name, desc),
756
754
  });
755
+ if (!switchResult.ok) {
756
+ // 飞书 API 失败:内存仍指向旧 session,新 session 已创建但无人使用,
757
+ // 留在 sessions.json(loadSessionTools)成为 orphan,可由 /sessions
758
+ // 列表展示供人工清理或下次 /newh 覆盖。代价小于让群 description
759
+ // 与内存绑定不一致。
760
+ logTrace(tid, "DONE", { outcome: "newh_update_chat_fail", error: switchResult.error?.message });
761
+ await sendCardReply(
762
+ freshToken, chatId, "Error",
763
+ `更新群描述失败,会话未切换(新 session 已创建但未启用):\n${switchResult.error?.message}`,
764
+ "red"
765
+ );
766
+ return;
767
+ }
768
+ if (chatType !== "p2p") {
769
+ console.log(`[${ts()}] [NEWH] Group updated: name="${newName}" desc="${descPrefix} ${newSessionId}"`);
770
+ }
757
771
 
758
772
  setChatAvatar(freshToken, chatId, descriptionTool, "new").catch(() => {});
759
773
 
760
- // 如果新 session 有活跃 prompt,启动 display loop 让本群也能看到
774
+ // 如果新 session 有活跃 prompt,启动 display loop 让本群也能看到。
775
+ // /newh 后通常是空 session,这里几乎走不到,但留作防御。
761
776
  if (isSessionRunning(newSessionId)) {
762
777
  const { ensureDisplayLoop } = await import("./session.ts");
763
778
  ensureDisplayLoop(newSessionId);
@@ -829,12 +844,8 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
829
844
  }
830
845
  const target = ordered[index];
831
846
 
832
- // abort 当前 chat 的旧 session,只解绑再重新绑定
833
- if (sessionId) {
834
- unbindChatFromSession(sessionId, chatId);
835
- displayCards.delete(chatId);
836
- }
837
-
847
+ // 第一步:解析目标 session cwd(adapter.getSessionInfo 失败则 fallback,
848
+ // 这一步不动任何内存绑定,失败也不脏)
838
849
  const targetAdapter = getAdapterForTool(target.tool);
839
850
  let cwd2: string;
840
851
  try {
@@ -844,33 +855,43 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
844
855
  cwd2 = await getDefaultCwd(chatId);
845
856
  }
846
857
 
847
- // 绑定到新 session
848
- bindChatToSession(target.sessionId, chatId);
849
- recordLastActiveChat(target.sessionId, chatId);
850
-
858
+ // 第二步:事务式切换 chat 绑定到 target.sessionId。
859
+ // switchChatBinding 内部行为同 /newh,详见 session.ts::switchChatBinding。
860
+ // 注意:这里把 turnCount 沿用 target.turnCount(从 registry 读到的目标 chat
861
+ // 历史轮数),而不是从 0 开始,这样 /status 显示的轮数延续历史。
851
862
  const descPrefix2 = sessionPrefixForTool(target.tool);
852
863
  const newName2 = target.chatName || sessionChatName("新会话", cwd2);
853
- await updateChatInfo(freshToken, chatId, newName2, `${descPrefix2} ${target.sessionId}`);
854
- console.log(`[${ts()}] [SESSION] Switched to session ${target.sessionId} (#${index + 1}), name="${newName2}"`);
855
-
856
- sessionInfoMap.set(chatId, {
857
- sessionId: target.sessionId,
858
- turnCount: target.turnCount,
859
- lastContextTokens: 0,
860
- startTime: Date.now(),
861
- tool: target.tool,
862
- });
863
- await recordSessionRegistry({
864
+ const switchResult = await switchChatBinding({
864
865
  chatId,
865
- sessionId: target.sessionId,
866
+ chatType,
867
+ oldSessionId: sessionId,
868
+ newSessionId: target.sessionId,
866
869
  tool: target.tool,
867
870
  chatName: newName2,
868
- running: false,
871
+ newDescription: `${descPrefix2} ${target.sessionId}`,
872
+ initialTurnCount: target.turnCount,
873
+ initialContextTokens: 0,
874
+ updateChatInfoFn: (cid, name, desc) => updateChatInfo(freshToken, cid, name, desc),
869
875
  });
876
+ if (!switchResult.ok) {
877
+ logTrace(tid, "DONE", { outcome: "session_update_chat_fail", error: switchResult.error?.message });
878
+ await sendCardReply(
879
+ freshToken, chatId, "Error",
880
+ `更新群描述失败,会话未切换:\n${switchResult.error?.message}`,
881
+ "red"
882
+ );
883
+ return;
884
+ }
885
+ if (chatType !== "p2p") {
886
+ console.log(`[${ts()}] [SESSION] Switched to session ${target.sessionId} (#${index + 1}), name="${newName2}"`);
887
+ }
870
888
 
871
889
  setChatAvatar(freshToken, chatId, target.tool, "new").catch(() => {});
872
890
 
873
- // 如果新 session 有活跃 prompt,加上 display loop
891
+ // 如果新 session 有活跃 prompt,加上 display loop。注意:此时
892
+ // recordLastActiveChat 已经把 lastActive 改成当前 chat,会"抢走"
893
+ // 原有群的 display 推送(见 review 中 corner case 5)。这是已知
894
+ // 设计权衡:用户主动切换就是想"接管"该 session 的显示。
874
895
  if (isSessionRunning(target.sessionId)) {
875
896
  const { ensureDisplayLoop } = await import("./session.ts");
876
897
  ensureDisplayLoop(target.sessionId);
@@ -1225,25 +1246,34 @@ async function startBotServiceCore(): Promise<void> {
1225
1246
  console.error(`[WS] Local relay error: ${err.message}`);
1226
1247
  });
1227
1248
  } else {
1249
+ // 进程首次启动:此时所有 Map 都是空的,resetState 主要是打个 LOG 标识"开始
1250
+ // 干净状态"。修正残留的 running stream-state 并重建 session→chat 映射。
1228
1251
  resetState();
1229
- // 启动时修正残留的 running 状态并重建 session→chat 映射
1230
1252
  fixStaleStreamStates().then(async () => {
1231
1253
  const registry = await loadSessionRegistryForBinding();
1232
1254
  rebuildSessionChatsFromRegistry(registry);
1233
1255
  }).catch((err) => console.error(`[${ts()}] Init bindings failed: ${(err as Error).message}`));
1234
1256
 
1257
+ // ⚠️ 关键设计:onReady / onReconnected 只重建只读映射,**绝不**清空运行时
1258
+ // 状态(activePrompts、displayLoops、sessionInfoMap、processedMessages)。
1259
+ // SDK 重连只是底层 WebSocket 抖动,业务层不受影响:
1260
+ // - 后台 generator 仍在跑、stream-state 仍在被写
1261
+ // - display loop 仍在向群推送
1262
+ // - 已处理消息的去重 set 必须保留,避免 SDK 重推老消息时 prompt 跑两遍
1263
+ // 历史 bug:此处曾误调 resetState() 导致重连即让所有后台任务变孤儿,
1264
+ // 同一 session 还可能双开 prompt(详见 session.ts::resetState 注释)。
1235
1265
  const wsClient = new WSClient({
1236
1266
  appId: APP_ID,
1237
1267
  appSecret: APP_SECRET,
1238
1268
  onReady: async () => {
1239
- resetState();
1240
- const registry = await loadSessionRegistryForBinding();
1241
- rebuildSessionChatsFromRegistry(registry);
1269
+ await rebuildBindingsFromRegistry().catch((err) =>
1270
+ console.error(`[${ts()}] [SDK READY] rebuild bindings failed: ${(err as Error).message}`)
1271
+ );
1242
1272
  },
1243
1273
  onReconnected: async () => {
1244
- resetState();
1245
- const registry = await loadSessionRegistryForBinding();
1246
- rebuildSessionChatsFromRegistry(registry);
1274
+ await rebuildBindingsFromRegistry().catch((err) =>
1275
+ console.error(`[${ts()}] [SDK RECONNECT] rebuild bindings failed: ${(err as Error).message}`)
1276
+ );
1247
1277
  },
1248
1278
  });
1249
1279
 
@@ -1405,22 +1435,14 @@ async function main(): Promise<void> {
1405
1435
  // 凭证齐全:自己起 HTTP server(同时挂 UI router),再调 startBotService
1406
1436
  // 把 WS 中继和飞书 SDK 挂上去。
1407
1437
  appendStartupTrace("main: before freeRelayListenPort", { CHATCCC_PORT });
1408
- freeRelayListenPort(CHATCCC_PORT);
1409
- appendStartupTrace("main: after freeRelayListenPort", { CHATCCC_PORT });
1438
+ const killed = freeRelayListenPort(CHATCCC_PORT);
1439
+ appendStartupTrace("main: after freeRelayListenPort", { CHATCCC_PORT, killed });
1440
+ if (killed > 0) {
1441
+ await waitForPortFree(CHATCCC_PORT);
1442
+ appendStartupTrace("main: port free confirmed", { CHATCCC_PORT });
1443
+ }
1410
1444
  const httpServer = createServer(createUiRouter());
1411
- await new Promise<void>((resolveListen, rejectListen) => {
1412
- const onError = (err: NodeJS.ErrnoException): void => {
1413
- httpServer.removeListener("listening", onListening);
1414
- rejectListen(err);
1415
- };
1416
- const onListening = (): void => {
1417
- httpServer.removeListener("error", onError);
1418
- resolveListen();
1419
- };
1420
- httpServer.once("error", onError);
1421
- httpServer.once("listening", onListening);
1422
- httpServer.listen(CHATCCC_PORT, "127.0.0.1");
1423
- }).catch((err: NodeJS.ErrnoException) => {
1445
+ await listenWithRetry(httpServer, CHATCCC_PORT, "127.0.0.1").catch((err: NodeJS.ErrnoException) => {
1424
1446
  console.error(`\n[启动] 本地中继 WebSocket 监听失败:端口 ${CHATCCC_PORT}(${err.code ?? "?"} — ${err.message})`);
1425
1447
  console.error(
1426
1448
  " 处理建议: 关闭占用该端口的其它程序,或在 config.json 的 port 字段里改成其它未占用端口(如 18081)。"
@@ -1439,6 +1461,36 @@ async function main(): Promise<void> {
1439
1461
  installShutdownHandlers(httpServer);
1440
1462
  }
1441
1463
 
1464
+ /**
1465
+ * 带重试的 server.listen,Windows 端口释放有延迟时自动重试。
1466
+ */
1467
+ async function listenWithRetry(
1468
+ server: ReturnType<typeof createServer>,
1469
+ port: number,
1470
+ host: string,
1471
+ maxRetries = 3,
1472
+ ): Promise<void> {
1473
+ for (let i = 0; i < maxRetries; i++) {
1474
+ try {
1475
+ await new Promise<void>((resolve, reject) => {
1476
+ server.once("error", reject);
1477
+ server.once("listening", resolve);
1478
+ server.listen(port, host);
1479
+ });
1480
+ return;
1481
+ } catch (err: unknown) {
1482
+ const e = err as NodeJS.ErrnoException;
1483
+ server.removeAllListeners("listening");
1484
+ server.removeAllListeners("error");
1485
+ if (e.code === "EADDRINUSE" && i < maxRetries - 1) {
1486
+ await new Promise((r) => setTimeout(r, 500 * (i + 1)));
1487
+ continue;
1488
+ }
1489
+ throw err;
1490
+ }
1491
+ }
1492
+ }
1493
+
1442
1494
  /**
1443
1495
  * 注册 SIGINT / SIGTERM 清理:把 relay/setup 共用的 httpServer 关掉再退出。
1444
1496
  *
@@ -31,6 +31,13 @@ export function unbindChatFromSession(sessionId: string, chatId: string): void {
31
31
  chats.delete(chatId);
32
32
  if (chats.size === 0) sessionChatsMap.delete(sessionId);
33
33
  }
34
+ // 双保险:lastActiveChatMap 是 sessionId → 最后活跃 chatId 的快照,
35
+ // 若被解绑的 chatId 正好是该 session 的 lastActive(典型场景:/newh
36
+ // 把当前群从旧 session 改嫁到新 session),不清理会让旧 session 的
37
+ // display loop 继续向已离开的群推送卡片。
38
+ if (lastActiveChatMap.get(sessionId)?.chatId === chatId) {
39
+ lastActiveChatMap.delete(sessionId);
40
+ }
34
41
  }
35
42
 
36
43
  export function getChatsForSession(sessionId: string): string[] {
@@ -75,6 +82,22 @@ export function getLastActiveChat(sessionId: string): string | undefined {
75
82
  return lastActiveChatMap.get(sessionId)?.chatId;
76
83
  }
77
84
 
85
+ /**
86
+ * display loop 决定推送目标 chat 的纯函数:
87
+ * 仅当 lastActiveChat 仍然绑定到该 session 时才返回,否则返回 undefined。
88
+ *
89
+ * 修复 bug:用户 `/newh` 把当前群从旧 session 改嫁到新 session 后,旧
90
+ * session 的 lastActiveChatMap 残留旧 chatId 快照,display loop 会继续
91
+ * 在该群创建/更新卡片。这里通过交叉验证 sessionChatsMap 把这种悬挂记录
92
+ * 排除掉——loop 拿到 undefined 就走"无活跃群"分支自然停推。
93
+ */
94
+ export function pickDisplayChat(sessionId: string): string | undefined {
95
+ const candidate = lastActiveChatMap.get(sessionId)?.chatId;
96
+ if (!candidate) return undefined;
97
+ const chats = sessionChatsMap.get(sessionId);
98
+ return chats?.has(candidate) ? candidate : undefined;
99
+ }
100
+
78
101
  // ---------------------------------------------------------------------------
79
102
  // displayCards: chatId → 展示卡片状态(display loop 用)
80
103
  // ---------------------------------------------------------------------------
package/src/session.ts CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  rebuildSessionChatsFromRegistry,
46
46
  recordLastActiveChat,
47
47
  getLastActiveChat,
48
+ pickDisplayChat,
48
49
  } from "./session-chat-binding.ts";
49
50
 
50
51
  // ---------------------------------------------------------------------------
@@ -84,6 +85,29 @@ export const sessionInfoMap = new Map<string, {
84
85
  tool: string;
85
86
  }>();
86
87
 
88
+ /**
89
+ * 清空所有进程内运行时状态。
90
+ *
91
+ * ⚠️ 红线:**绝对不要**在飞书 SDK 的 onReady / onReconnected 回调里调用本函数。
92
+ * SDK 的 WebSocket 重连只是底层连接抖动,业务层(活跃 prompt、display loop、
93
+ * stream-state 文件、轮数计数)完全不受影响。在重连里调 resetState 会:
94
+ * 1) `activePrompts.clear()` 只是删 Map,**不会** abort 后台 generator。
95
+ * generator 继续跑、继续写 stream-state.json,但 display loop 已被
96
+ * stop,用户群里再也看不到任何更新;最终回复永远不发到群。
97
+ * 2) 该 sessionId 在内存里"看似空闲",下一条用户消息进来会**第二次进入**
98
+ * `runAgentSession`,同一个 cursor/claude session 同时跑两条 prompt,
99
+ * 输出互相串扰、token 计费翻倍。
100
+ * 3) `processedMessages` / `lastMsgTimestamps` 被清,SDK 重连后若服务端
101
+ * 重推已 ack 的消息,去重失效会让同一 prompt 被处理两次。
102
+ * 4) `sessionInfoMap` 清空后,群再发消息时 nextTurnCount 从 1 重新计数。
103
+ *
104
+ * 合法调用点:
105
+ * - 单元测试 setup(清测试间状态)
106
+ * - 进程首次启动(此时 Map 都是空的,调用纯粹是为了打 LOG)
107
+ *
108
+ * SDK 重连场景请改用 `rebuildBindingsFromRegistry()`,它只重建 sessionId →
109
+ * chatId 映射,不动任何运行时状态。
110
+ */
87
111
  export function resetState(): void {
88
112
  for (const entry of chatSessionMap.values()) {
89
113
  if (entry.spinnerTimer) clearInterval(entry.spinnerTimer);
@@ -93,7 +117,6 @@ export function resetState(): void {
93
117
  sessionInfoMap.clear();
94
118
  processedMessages.clear();
95
119
  lastMsgTimestamps.clear();
96
- // 清理新的全局状态
97
120
  activePrompts.clear();
98
121
  displayCards.clear();
99
122
  for (const stop of displayLoops.values()) stop();
@@ -101,6 +124,9 @@ export function resetState(): void {
101
124
  console.log(`[${ts()}] [RESET] State cleared (dedup + active sessions + bindings)`);
102
125
  }
103
126
 
127
+ // 注:`rebuildBindingsFromRegistry` 定义在下方与 loadSessionRegistry 同区域,
128
+ // 是 onReady/onReconnected 取代 resetState 的正确入口。
129
+
104
130
  // ---------------------------------------------------------------------------
105
131
  // Adapter: 按 tool 创建并缓存
106
132
  // ---------------------------------------------------------------------------
@@ -136,11 +162,14 @@ export function getAdapterForTool(tool: string): ToolAdapter {
136
162
  interface SessionToolRecord {
137
163
  tool: string;
138
164
  createdAt: number;
165
+ chatName?: string;
139
166
  }
140
167
 
168
+ let sessionToolsFile = SESSIONS_FILE;
169
+
141
170
  async function loadSessionTools(): Promise<Record<string, SessionToolRecord>> {
142
171
  try {
143
- const raw = await readFile(SESSIONS_FILE, "utf-8");
172
+ const raw = await readFile(sessionToolsFile, "utf-8");
144
173
  return JSON.parse(raw);
145
174
  } catch {
146
175
  return {};
@@ -149,17 +178,23 @@ async function loadSessionTools(): Promise<Record<string, SessionToolRecord>> {
149
178
 
150
179
  async function saveSessionTools(data: Record<string, SessionToolRecord>): Promise<void> {
151
180
  try {
152
- await mkdir(dirname(SESSIONS_FILE), { recursive: true });
153
- await writeFile(SESSIONS_FILE, JSON.stringify(data, null, 2), "utf-8");
181
+ await mkdir(dirname(sessionToolsFile), { recursive: true });
182
+ await writeFile(sessionToolsFile, JSON.stringify(data, null, 2), "utf-8");
154
183
  } catch (err) {
155
184
  console.error(`[${ts()}] Failed to save sessions.json: ${(err as Error).message}`);
156
185
  fileLog.flush();
157
186
  }
158
187
  }
159
188
 
160
- export async function saveSessionTool(sessionId: string, tool: string): Promise<void> {
189
+ export async function saveSessionTool(sessionId: string, tool: string, chatName?: string): Promise<void> {
161
190
  const data = await loadSessionTools();
162
- data[sessionId] = { tool, createdAt: Date.now() };
191
+ const existing = data[sessionId];
192
+ const mergedChatName = chatName ?? existing?.chatName;
193
+ data[sessionId] = {
194
+ tool,
195
+ createdAt: existing?.createdAt ?? Date.now(),
196
+ ...(mergedChatName ? { chatName: mergedChatName } : {}),
197
+ };
163
198
  await saveSessionTools(data);
164
199
  }
165
200
 
@@ -169,6 +204,14 @@ export async function getSessionTool(sessionId: string): Promise<string | null>
169
204
  return record?.tool ?? null;
170
205
  }
171
206
 
207
+ export function _setSessionToolsFileForTest(filePath: string): void {
208
+ sessionToolsFile = filePath;
209
+ }
210
+
211
+ export function _resetSessionToolsFileForTest(): void {
212
+ sessionToolsFile = SESSIONS_FILE;
213
+ }
214
+
172
215
  // ---------------------------------------------------------------------------
173
216
  // Conversation session registry for /sessions
174
217
  // ---------------------------------------------------------------------------
@@ -217,6 +260,26 @@ export async function loadSessionRegistryForBinding(): Promise<SessionRegistryDa
217
260
  return loadSessionRegistry();
218
261
  }
219
262
 
263
+ /**
264
+ * 从持久化的 registry 重建 sessionId → chatId 映射。
265
+ *
266
+ * 设计契约(替代之前 onReady/onReconnected 误用的 resetState):
267
+ * - **不动** activePrompts:后台 prompt 在 SDK 重连后必须继续被识别为活跃,
268
+ * 否则下条用户消息会绕过 isSessionRunning 检查再开一条 prompt,
269
+ * 导致同一 sessionId 双开 generator
270
+ * - **不动** sessionInfoMap:内存里的轮数/contextTokens 比 registry 更新
271
+ * - **不动** displayCards / displayLoops:正在跑的 prompt 还需要它们继续推卡片
272
+ * - **不动** processedMessages / lastMsgTimestamps:SDK 重连若重推已 ack 消息,
273
+ * 去重 set 还在才能避免同一 prompt 跑两遍
274
+ *
275
+ * 唯一被重建的是 sessionChatsMap(通过调用 rebuildSessionChatsFromRegistry)——
276
+ * 该 Map 是从 registry 派生的纯只读映射,重建是幂等且廉价的。
277
+ */
278
+ export async function rebuildBindingsFromRegistry(): Promise<void> {
279
+ const registry = await loadSessionRegistry();
280
+ rebuildSessionChatsFromRegistry(registry);
281
+ }
282
+
220
283
  async function saveSessionRegistry(data: SessionRegistryData): Promise<void> {
221
284
  try {
222
285
  await mkdir(dirname(sessionRegistryFile), { recursive: true });
@@ -361,6 +424,118 @@ export function accumulateBlockContent(
361
424
  }
362
425
  }
363
426
 
427
+ // ---------------------------------------------------------------------------
428
+ // switchChatBinding — /newh、/session N 共用的事务式"切换 chat 绑定"
429
+ // ---------------------------------------------------------------------------
430
+ //
431
+ // 设计契约(解决三类历史 bug):
432
+ //
433
+ // 1. 私聊不能调 updateChatInfo(飞书 API 在 p2p chatId 上会返回非 0 → throw)。
434
+ // 之前的实现没判断 chatType,私聊 /newh、/session N 走到 updateChatInfo
435
+ // 就直接抛错,留下"内存已切换、registry 没更新"的脏状态。
436
+ //
437
+ // 2. updateChatInfo 群聊也可能因为网络/频控失败。之前的代码顺序是
438
+ // 先 unbind 旧 → bind 新 → 再调 updateChatInfo
439
+ // API 失败后内存绑定已经切走,但群 description 还是旧 sessionId。
440
+ // 下次用户在群里发消息时,extractSessionInfo 拿到旧 sessionId,而内存绑定
441
+ // 指向新 sessionId,路由完全错乱(参考 /newh 的 corner case 7)。
442
+ //
443
+ // 3. 改成"先 API 后内存"的顺序后,API 失败就完全不切换内存,下次消息按
444
+ // 旧 description 正常路由到旧 session,新创建的 session 留在 sessions.json
445
+ // 里成为可清理的 orphan。
446
+ //
447
+ // 调用方约定:
448
+ // - newSessionId 必须是已经 createSession 完成的真实 session(本函数不创建)
449
+ // - oldSessionId 为 null 表示当前 chat 没有任何旧绑定(比如私聊首次绑)
450
+ // - 私聊跳过 updateChatInfo,直接做内存切换 + registry 持久化
451
+ // - API 失败时:
452
+ // * 不动内存绑定 / sessionInfoMap / displayCards
453
+ // * 返回 { ok: false, error }
454
+ // * 调用方负责把错误反馈给用户
455
+ // ---------------------------------------------------------------------------
456
+
457
+ export interface SwitchChatBindingArgs {
458
+ chatId: string;
459
+ chatType: string;
460
+ oldSessionId: string | null;
461
+ newSessionId: string;
462
+ tool: string;
463
+ /** 群名(私聊忽略) */
464
+ chatName: string;
465
+ /** 群描述(私聊忽略),通常为 `${sessionPrefixForTool(tool)} ${newSessionId}` */
466
+ newDescription: string;
467
+ /** 切换后 sessionInfoMap 的初始 turnCount/lastContextTokens(如沿用历史) */
468
+ initialTurnCount?: number;
469
+ initialContextTokens?: number;
470
+ /** 飞书 updateChatInfo 实现,依赖注入便于测试 mock */
471
+ updateChatInfoFn: (chatId: string, name: string, description: string) => Promise<void>;
472
+ }
473
+
474
+ export interface SwitchChatBindingResult {
475
+ ok: boolean;
476
+ error?: Error;
477
+ }
478
+
479
+ export async function switchChatBinding(args: SwitchChatBindingArgs): Promise<SwitchChatBindingResult> {
480
+ const {
481
+ chatId,
482
+ chatType,
483
+ oldSessionId,
484
+ newSessionId,
485
+ tool,
486
+ chatName,
487
+ newDescription,
488
+ initialTurnCount = 0,
489
+ initialContextTokens = 0,
490
+ updateChatInfoFn,
491
+ } = args;
492
+
493
+ // Step 1: 群聊场景先调用飞书 API(不可逆操作放最前)。
494
+ // 私聊跳过——p2p chatId 调 updateChatInfo 必然失败。
495
+ if (chatType !== "p2p") {
496
+ try {
497
+ await updateChatInfoFn(chatId, chatName, newDescription);
498
+ } catch (err) {
499
+ // API 失败:完全不动内存,调用方负责回报用户。
500
+ return { ok: false, error: err as Error };
501
+ }
502
+ }
503
+
504
+ // Step 2: API 成功(或私聊跳过)后,原子地切换内存绑定。
505
+ // 这一段全是同步 Map 操作,不会失败。
506
+ if (oldSessionId) {
507
+ unbindChatFromSession(oldSessionId, chatId);
508
+ displayCards.delete(chatId);
509
+ }
510
+ bindChatToSession(newSessionId, chatId);
511
+ recordLastActiveChat(newSessionId, chatId);
512
+
513
+ const now = Date.now();
514
+ sessionInfoMap.set(chatId, {
515
+ sessionId: newSessionId,
516
+ turnCount: initialTurnCount,
517
+ lastContextTokens: initialContextTokens,
518
+ startTime: now,
519
+ tool,
520
+ });
521
+
522
+ // Step 3: 持久化(registry + sessions.json)。
523
+ // 这两步即使失败也不影响内存正确性,下次 prompt 会再写一次。
524
+ await recordSessionRegistry({
525
+ chatId,
526
+ sessionId: newSessionId,
527
+ tool,
528
+ chatName,
529
+ turnCount: initialTurnCount,
530
+ lastContextTokens: initialContextTokens,
531
+ startTime: now,
532
+ running: false,
533
+ });
534
+ await saveSessionTool(newSessionId, tool, chatName);
535
+
536
+ return { ok: true };
537
+ }
538
+
364
539
  // ---------------------------------------------------------------------------
365
540
  // AI tool session management
366
541
  // ---------------------------------------------------------------------------
@@ -658,7 +833,9 @@ export function ensureDisplayLoop(sessionId: string): void {
658
833
  const state = await readStreamState(sessionId);
659
834
  if (!state) return;
660
835
 
661
- const chatId = getLastActiveChat(sessionId);
836
+ // pickDisplayChat lastActiveChat 已与本 session 解绑时返回 undefined,
837
+ // 避免 /newh 等改嫁场景下旧 session 仍向已离开群推卡片。
838
+ const chatId = pickDisplayChat(sessionId);
662
839
  if (!chatId) {
663
840
  // 无活跃群,若 session 已结束则停止 loop
664
841
  if (state.status !== "running") {
@@ -901,9 +1078,31 @@ export interface SessionsListEntry {
901
1078
 
902
1079
  export async function getAllSessionsStatus(): Promise<SessionsListEntry[]> {
903
1080
  const registry = await loadSessionRegistry();
904
- const entries = Object.values(registry)
1081
+ const registryEntries = Object.values(registry)
905
1082
  .filter((record) => record.chatId && record.sessionId && record.tool)
906
- .sort((a, b) => b.updatedAt - a.updatedAt)
1083
+ .map((record) => ({ ...record, sortTime: record.updatedAt }));
1084
+ const registeredSessionIds = new Set(registryEntries.map((record) => record.sessionId));
1085
+ const sessionTools = await loadSessionTools();
1086
+ const orphanEntries = Object.entries(sessionTools)
1087
+ .filter(([sessionId, record]) => sessionId && record?.tool && !registeredSessionIds.has(sessionId))
1088
+ .map(([sessionId, record]) => {
1089
+ const createdAt = Number.isFinite(record.createdAt) ? record.createdAt : 0;
1090
+ const active = activePrompts.get(sessionId);
1091
+ return {
1092
+ chatId: "",
1093
+ sessionId,
1094
+ tool: record.tool,
1095
+ chatName: record.chatName ?? "",
1096
+ turnCount: 0,
1097
+ lastContextTokens: 0,
1098
+ startTime: active?.startTime ?? createdAt,
1099
+ updatedAt: createdAt,
1100
+ running: false,
1101
+ sortTime: active?.startTime ?? createdAt,
1102
+ };
1103
+ });
1104
+ const entries = [...registryEntries, ...orphanEntries]
1105
+ .sort((a, b) => b.sortTime - a.sortTime)
907
1106
  .slice(0, 20);
908
1107
  // 并行解析每个 session 的 model/effort(cursor 涉及异步 store IO)
909
1108
  return Promise.all(