chatccc 0.2.205 → 0.2.207

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.
@@ -411,10 +411,134 @@ function shouldSendWechatProcessingAck(
411
411
  return platform.kind === "wechat" && chatType === "p2p" && !isCommandText;
412
412
  }
413
413
 
414
- /** 飞书私聊是固定会话容器;显式 /new 才创建独立群聊。 */
414
+ /** 飞书私聊是专属会话容器;显式 /new 才创建独立群聊。 */
415
415
  function isFeishuP2p(platform: PlatformAdapter, chatType: string): boolean {
416
416
  return chatType === "p2p" && platform.kind === "feishu";
417
417
  }
418
+
419
+ interface FeishuP2pRegistryRecord {
420
+ sessionId: string;
421
+ tool: string;
422
+ chatType?: string;
423
+ chatName?: string;
424
+ }
425
+
426
+ type FeishuP2pAgentResolution =
427
+ | { kind: "ready"; sessionId: string; tool: string }
428
+ | { kind: "waiting"; sessionId: string; tool: string; desiredTool: AgentTool }
429
+ | { kind: "error"; previousTool: string; desiredTool: AgentTool; error: Error };
430
+
431
+ // 同一个飞书私聊可能短时间收到多条消息。切换期间共享同一个 Promise,避免
432
+ // 为同一次默认 Agent 变化创建多个空会话。
433
+ const feishuP2pAgentSwitches = new Map<string, Promise<FeishuP2pAgentResolution>>();
434
+
435
+ async function resolveFeishuP2pAgent(
436
+ platform: PlatformAdapter,
437
+ chatId: string,
438
+ text: string,
439
+ record: FeishuP2pRegistryRecord,
440
+ traceId: string,
441
+ ): Promise<FeishuP2pAgentResolution> {
442
+ const desiredTool = resolveDefaultAgentTool();
443
+ if (record.tool === desiredTool) {
444
+ return { kind: "ready", sessionId: record.sessionId, tool: record.tool };
445
+ }
446
+
447
+ if (isSessionRunning(record.sessionId)) {
448
+ return {
449
+ kind: "waiting",
450
+ sessionId: record.sessionId,
451
+ tool: record.tool,
452
+ desiredTool,
453
+ };
454
+ }
455
+
456
+ const existingSwitch = feishuP2pAgentSwitches.get(chatId);
457
+ if (existingSwitch) return existingSwitch;
458
+
459
+ const switchOperation = (async (): Promise<FeishuP2pAgentResolution> => {
460
+ try {
461
+ // 异步创建开始前重新读取一次,防止另一个请求刚完成了绑定切换。
462
+ const latestRecord = (await loadSessionRegistryForBinding())[chatId];
463
+ if (!latestRecord?.sessionId || !latestRecord.tool || latestRecord.chatType !== "p2p") {
464
+ return {
465
+ kind: "error",
466
+ previousTool: record.tool,
467
+ desiredTool,
468
+ error: new Error("飞书私聊绑定在切换前已发生变化"),
469
+ };
470
+ }
471
+ if (latestRecord.tool === desiredTool) {
472
+ return { kind: "ready", sessionId: latestRecord.sessionId, tool: latestRecord.tool };
473
+ }
474
+ if (isSessionRunning(latestRecord.sessionId)) {
475
+ return {
476
+ kind: "waiting",
477
+ sessionId: latestRecord.sessionId,
478
+ tool: latestRecord.tool,
479
+ desiredTool,
480
+ };
481
+ }
482
+
483
+ const cwd = homedir();
484
+ const init = await initClaudeSession(desiredTool, cwd);
485
+ const chatName = sessionChatName(text.slice(0, 10) || "私聊会话", cwd);
486
+ const switchResult = await switchChatBinding({
487
+ chatId,
488
+ chatType: "p2p",
489
+ oldSessionId: latestRecord.sessionId,
490
+ newSessionId: init.sessionId,
491
+ tool: desiredTool,
492
+ chatName,
493
+ newDescription: `${sessionPrefixForTool(desiredTool)} ${init.sessionId}`,
494
+ updateChatInfoFn: (id, name, desc) => platform.updateChatInfo(id, name, desc),
495
+ });
496
+ if (!switchResult.ok) {
497
+ return {
498
+ kind: "error",
499
+ previousTool: latestRecord.tool,
500
+ desiredTool,
501
+ error: switchResult.error ?? new Error("更新飞书私聊绑定失败"),
502
+ };
503
+ }
504
+
505
+ const previousLabel = toolDisplayName(latestRecord.tool);
506
+ const desiredLabel = toolDisplayName(desiredTool);
507
+ logTrace(traceId, "BRANCH", {
508
+ reason: "switch_feishu_p2p_default_agent",
509
+ chatId,
510
+ oldSessionId: latestRecord.sessionId,
511
+ newSessionId: init.sessionId,
512
+ oldTool: latestRecord.tool,
513
+ newTool: desiredTool,
514
+ });
515
+ await platform.sendCard(
516
+ chatId,
517
+ "默认 Agent 已切换",
518
+ `检测到默认 Agent 已变化:**${previousLabel} → ${desiredLabel}**。\n\n已创建新的空白 ${desiredLabel} 私聊会话,并从本条消息开始使用。`,
519
+ "green",
520
+ ).catch(() => {});
521
+ platform.setChatAvatar(chatId, desiredTool, "new").catch(() => {});
522
+ return { kind: "ready", sessionId: init.sessionId, tool: desiredTool };
523
+ } catch (err) {
524
+ return {
525
+ kind: "error",
526
+ previousTool: record.tool,
527
+ desiredTool,
528
+ error: err as Error,
529
+ };
530
+ }
531
+ })();
532
+
533
+ feishuP2pAgentSwitches.set(chatId, switchOperation);
534
+ try {
535
+ return await switchOperation;
536
+ } finally {
537
+ if (feishuP2pAgentSwitches.get(chatId) === switchOperation) {
538
+ feishuP2pAgentSwitches.delete(chatId);
539
+ }
540
+ }
541
+ }
418
542
 
419
543
  /** 检测当前进程是否从 npm 全局安装启动 */
420
544
  function isRunningFromGlobalNpm(): boolean {
@@ -532,7 +656,7 @@ export async function handleCommand(
532
656
  "配置已重新加载。",
533
657
  `默认 Agent: ${toolDisplayName(result.defaultAgent)}`,
534
658
  `配置文件: ${result.configPath}`,
535
- "后续新会话会使用最新配置;正在生成的会话不会被中断。",
659
+ "后续新会话会使用最新配置;飞书私聊会在下一条普通消息时跟随默认 Agent,正在生成的会话不会被中断。",
536
660
  ].join("\n"),
537
661
  ).catch(() => {});
538
662
  logTrace(tid, "DONE", { outcome: "reload", defaultAgent: result.defaultAgent });
@@ -965,11 +1089,12 @@ export async function handleCommand(
965
1089
 
966
1090
  // 检测会话上下文:群聊从 description 获取,飞书/微信私聊都从
967
1091
  // session-registry 获取。私聊 chatId 是稳定容器,进程重启后仍恢复绑定。
968
- let sessionId: string | null = null;
969
- let descriptionTool: string | null = null;
970
- let toolLabel: string | null = null;
971
- let chatInfo: Awaited<ReturnType<PlatformAdapter["getChatInfo"]>> | undefined;
972
- let description: string | undefined;
1092
+ let sessionId: string | null = null;
1093
+ let descriptionTool: string | null = null;
1094
+ let toolLabel: string | null = null;
1095
+ let pendingFeishuP2pDefaultTool: AgentTool | null = null;
1096
+ let chatInfo: Awaited<ReturnType<PlatformAdapter["getChatInfo"]>> | undefined;
1097
+ let description: string | undefined;
973
1098
 
974
1099
  if (chatType !== "p2p") {
975
1100
  try {
@@ -1010,9 +1135,38 @@ export async function handleCommand(
1010
1135
  oldSessionId: record.sessionId,
1011
1136
  });
1012
1137
  } else if (record && record.sessionId && record.tool) {
1013
- sessionId = record.sessionId;
1014
- descriptionTool = record.tool;
1015
- toolLabel = toolDisplayName(descriptionTool);
1138
+ let resolvedRecord: { sessionId: string; tool: string } = record;
1139
+ if (platform.kind === "feishu" && !isCommandText && record.chatType === "p2p") {
1140
+ const resolution = await resolveFeishuP2pAgent(platform, chatId, text, record, tid);
1141
+ if (resolution.kind === "error") {
1142
+ const previousLabel = toolDisplayName(resolution.previousTool);
1143
+ const desiredLabel = toolDisplayName(resolution.desiredTool);
1144
+ console.error(
1145
+ `[${ts()}] [P2P-SWITCH] ${previousLabel} -> ${desiredLabel} FAIL: ${resolution.error.message}`,
1146
+ );
1147
+ logTrace(tid, "DONE", {
1148
+ outcome: "switch_feishu_p2p_default_agent_fail",
1149
+ oldTool: resolution.previousTool,
1150
+ newTool: resolution.desiredTool,
1151
+ error: resolution.error.message,
1152
+ });
1153
+ await platform.sendCard(
1154
+ chatId,
1155
+ "Agent 切换失败",
1156
+ `无法从 ${previousLabel} 切换到 ${desiredLabel}:\n${resolution.error.message}`,
1157
+ "red",
1158
+ ).catch(() => {});
1159
+ return;
1160
+ }
1161
+ resolvedRecord = resolution;
1162
+ if (resolution.kind === "waiting") {
1163
+ pendingFeishuP2pDefaultTool = resolution.desiredTool;
1164
+ }
1165
+ }
1166
+
1167
+ sessionId = resolvedRecord.sessionId;
1168
+ descriptionTool = resolvedRecord.tool;
1169
+ toolLabel = toolDisplayName(descriptionTool);
1016
1170
  // 确保内存状态在冷启动后恢复;bindChatToSession 是幂等的。
1017
1171
  if (!sessionInfoMap.has(chatId)) {
1018
1172
  sessionInfoMap.set(chatId, {
@@ -1389,7 +1543,7 @@ export async function handleCommand(
1389
1543
  await platform.sendCard(
1390
1544
  chatId,
1391
1545
  "/session",
1392
- "飞书私聊使用固定的专属会话,不能切换到历史群聊会话。请回到对应群聊继续,或发送 /new 新建群聊。",
1546
+ "飞书私聊不能通过 /session 切换到历史群聊会话;它会在下一条普通消息时跟随默认 Agent。请回到对应群聊继续,或发送 /new 新建群聊。",
1393
1547
  "yellow",
1394
1548
  );
1395
1549
  logTrace(tid, "DONE", { outcome: "session_switch_disabled_feishu_p2p" });
@@ -1762,8 +1916,19 @@ export async function handleCommand(
1762
1916
  if (platform.kind === "wechat") {
1763
1917
  await platform.sendText(chatId, "当前会话正在生成中,你的消息已进入缓存队列,生成完成后会立即处理。发送 /cancel 可取消缓存。").catch(() => {});
1764
1918
  } else {
1765
- await platform.sendRawCard(chatId, buildQueuedCard(text)).catch(() => {});
1766
- }
1919
+ if (isFeishuP2p(platform, chatType) && pendingFeishuP2pDefaultTool) {
1920
+ const currentLabel = toolDisplayName(descriptionTool);
1921
+ const desiredLabel = toolDisplayName(pendingFeishuP2pDefaultTool);
1922
+ await platform.sendCard(
1923
+ chatId,
1924
+ "Agent 切换等待中",
1925
+ `当前 ${currentLabel} 正在生成;完成后会切换到 ${desiredLabel},并用新的空会话处理这条消息。\n\n发送 **/cancel** 可取消缓存。`,
1926
+ "blue",
1927
+ ).catch(() => {});
1928
+ } else {
1929
+ await platform.sendRawCard(chatId, buildQueuedCard(text)).catch(() => {});
1930
+ }
1931
+ }
1767
1932
  } else {
1768
1933
  logTrace(tid, "QUEUE_FULL", { sessionId });
1769
1934
  console.log(
@@ -0,0 +1,28 @@
1
+ /** A snapshot of response output progress while the Agent is generating a reply. */
2
+ export interface ResponseProgressObservation {
3
+ totalChars: number;
4
+ unchangedSince: number;
5
+ }
6
+
7
+ /**
8
+ * Tracks how long the displayed response character count has remained unchanged.
9
+ * Leaving the responding phase clears the window; returning starts a fresh one.
10
+ */
11
+ export function observeResponseProgress(
12
+ previous: ResponseProgressObservation | undefined,
13
+ isResponding: boolean,
14
+ totalChars: number,
15
+ now = Date.now(),
16
+ ): ResponseProgressObservation | undefined {
17
+ if (!isResponding) return undefined;
18
+ if (previous?.totalChars === totalChars) return previous;
19
+ return { totalChars, unchangedSince: now };
20
+ }
21
+
22
+ export function hasResponseStalled(
23
+ observation: ResponseProgressObservation | undefined,
24
+ now: number,
25
+ timeoutMs: number,
26
+ ): boolean {
27
+ return observation !== undefined && now - observation.unchangedSince >= timeoutMs;
28
+ }
@@ -5,7 +5,8 @@
5
5
  // 由 session.ts 在初始化时调用 rebuildSessionChatsFromRegistry 重建
6
6
  // ---------------------------------------------------------------------------
7
7
 
8
- import type { PlatformAdapter } from "./platform-adapter.ts";
8
+ import type { PlatformAdapter } from "./platform-adapter.ts";
9
+ import type { ResponseProgressObservation } from "./response-stall.ts";
9
10
 
10
11
  const sessionChatsMap = new Map<string, Set<string>>();
11
12
 
@@ -52,10 +53,22 @@ export function hasChatsForSession(sessionId: string): boolean {
52
53
  return (sessionChatsMap.get(sessionId)?.size ?? 0) > 0;
53
54
  }
54
55
 
55
- /** 检查 sessionId 是否正被其他 chatId 使用(有活跃 prompt) */
56
- export function isSessionRunning(sessionId: string): boolean {
57
- return activePrompts.has(sessionId);
58
- }
56
+ // Agent generator 退出后,最终卡片、registry、头像等仍可能在异步收尾。该阶段
57
+ // 也必须阻止下一轮提前进入,否则旧会话的落盘可能覆盖新会话绑定。
58
+ const finalizingSessions = new Set<string>();
59
+
60
+ /** 检查 sessionId 是否有活跃 prompt 或正在完成本轮异步收尾。 */
61
+ export function isSessionRunning(sessionId: string): boolean {
62
+ return activePrompts.has(sessionId) || finalizingSessions.has(sessionId);
63
+ }
64
+
65
+ export function markSessionFinalizing(sessionId: string): void {
66
+ finalizingSessions.add(sessionId);
67
+ }
68
+
69
+ export function clearSessionFinalizing(sessionId: string): void {
70
+ finalizingSessions.delete(sessionId);
71
+ }
59
72
 
60
73
  // ---------------------------------------------------------------------------
61
74
  // activePrompts: sessionId → 活跃 prompt 控制
@@ -66,8 +79,14 @@ export interface ActivePrompt {
66
79
  stopped: boolean;
67
80
  startTime: number;
68
81
  /** Root PID for the CLI process currently serving this prompt, if the adapter exposes one. */
69
- processPid?: number;
70
- processMonitor?: ReturnType<typeof setInterval>;
82
+ processPid?: number;
83
+ processMonitor?: ReturnType<typeof setInterval>;
84
+ responseStallMonitor?: ReturnType<typeof setInterval>;
85
+ /** Character-count progress observed only while the activity is "responding". */
86
+ responseProgress?: ResponseProgressObservation;
87
+ /** Set before a response-stall auto-end begins so competing monitors cannot win the race. */
88
+ autoEnded?: boolean;
89
+ autoEndedAt?: number;
71
90
  /** Set when the watchdog detects that the CLI process disappeared before stream finalization. */
72
91
  abnormalExit?: boolean;
73
92
  abnormalExitNotified?: boolean;
@@ -220,11 +239,13 @@ export function consumeQueuedMessage(platform: PlatformAdapter, msg: QueuedMessa
220
239
  export function resetBindingState(): void {
221
240
  sessionChatsMap.clear();
222
241
  lastActiveChatMap.clear();
223
- for (const prompt of activePrompts.values()) {
224
- if (prompt.processMonitor) clearInterval(prompt.processMonitor);
225
- }
226
- activePrompts.clear();
227
- queuedMessages.clear();
242
+ for (const prompt of activePrompts.values()) {
243
+ if (prompt.processMonitor) clearInterval(prompt.processMonitor);
244
+ if (prompt.responseStallMonitor) clearInterval(prompt.responseStallMonitor);
245
+ }
246
+ activePrompts.clear();
247
+ finalizingSessions.clear();
248
+ queuedMessages.clear();
228
249
  displayCards.clear();
229
250
  if (unifiedDisplayLoopHandle !== null) {
230
251
  clearInterval(unifiedDisplayLoopHandle);