chatccc 0.2.251 → 0.2.253

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.
Files changed (277) hide show
  1. package/README.md +1 -1
  2. package/bin/cccagent.mjs +12 -3
  3. package/bin/chatccc.mjs +15 -6
  4. package/deepccc-agent/package.json +1 -1
  5. package/dist/deepccc-agent/src/cli.js +634 -0
  6. package/dist/deepccc-agent/src/config.js +76 -0
  7. package/dist/deepccc-agent/src/context.js +348 -0
  8. package/dist/deepccc-agent/src/file-log.js +34 -0
  9. package/dist/deepccc-agent/src/file-tools.js +1208 -0
  10. package/dist/deepccc-agent/src/index.js +571 -0
  11. package/dist/deepccc-agent/src/permissions.js +170 -0
  12. package/dist/deepccc-agent/src/privacy.js +124 -0
  13. package/dist/deepccc-agent/src/proc-tree-kill.js +60 -0
  14. package/dist/deepccc-agent/src/progress/cards-helpers.js +70 -0
  15. package/dist/deepccc-agent/src/progress/reducer.js +102 -0
  16. package/dist/deepccc-agent/src/progress/terminal-renderer.js +264 -0
  17. package/dist/deepccc-agent/src/progress/view.js +30 -0
  18. package/dist/deepccc-agent/src/raw-stream-log.js +106 -0
  19. package/dist/deepccc-agent/src/session-search.js +276 -0
  20. package/dist/deepccc-agent/src/session-select.js +23 -0
  21. package/dist/deepccc-agent/src/sigint.js +26 -0
  22. package/dist/deepccc-agent/src/skills.js +178 -0
  23. package/dist/deepccc-agent/src/web-tools.js +246 -0
  24. package/dist/src/adapters/adapter-interface.js +19 -0
  25. package/dist/src/adapters/ccc-adapter.js +112 -0
  26. package/dist/src/adapters/claude-adapter.js +497 -0
  27. package/dist/src/adapters/claude-session-meta-store.js +92 -0
  28. package/dist/src/adapters/codex-adapter.js +279 -0
  29. package/dist/src/adapters/codex-session-meta-store.js +94 -0
  30. package/dist/src/adapters/cursor-adapter.js +491 -0
  31. package/dist/src/adapters/cursor-session-meta-store.js +116 -0
  32. package/dist/src/adapters/jsonl-stream.js +104 -0
  33. package/{src/adapters/proc-tree-kill.ts → dist/src/adapters/proc-tree-kill.js} +94 -97
  34. package/dist/src/adapters/raw-stream-log.js +106 -0
  35. package/dist/src/adapters/resource-monitor.js +113 -0
  36. package/dist/src/agent-activity.js +133 -0
  37. package/dist/src/agent-delegate-task-rpc.js +129 -0
  38. package/dist/src/agent-delegate-task.js +48 -0
  39. package/dist/src/agent-file-rpc.js +152 -0
  40. package/dist/src/agent-image-rpc.js +148 -0
  41. package/dist/src/agent-platform-routing.js +13 -0
  42. package/dist/src/agent-reload-config-rpc.js +23 -0
  43. package/dist/src/agent-rpc-body.js +87 -0
  44. package/dist/src/agent-stop-stuck.js +110 -0
  45. package/dist/src/card-action-routing.js +7 -0
  46. package/dist/src/card-plain-text.js +101 -0
  47. package/dist/src/cardkit.js +158 -0
  48. package/dist/src/cards.js +573 -0
  49. package/dist/src/chatgpt-subscription-rpc.js +18 -0
  50. package/dist/src/chatgpt-subscription.js +199 -0
  51. package/dist/src/chrome-devtools-guard.js +238 -0
  52. package/dist/src/claude-sdk-installer.js +249 -0
  53. package/dist/src/codex-reset-actions.js +143 -0
  54. package/dist/src/config-utils.js +149 -0
  55. package/dist/src/config.js +804 -0
  56. package/dist/src/cursor-usage.js +77 -0
  57. package/dist/src/exit-banner.js +28 -0
  58. package/dist/src/feishu-api.js +1404 -0
  59. package/dist/src/feishu-message-ingress.js +137 -0
  60. package/dist/src/feishu-platform.js +97 -0
  61. package/dist/src/format-message.js +252 -0
  62. package/dist/src/git-command.js +155 -0
  63. package/dist/src/im-skills.js +121 -0
  64. package/dist/src/index.js +833 -0
  65. package/dist/src/litellm-proxy.js +300 -0
  66. package/dist/src/orchestrator.js +2078 -0
  67. package/dist/src/package-root.js +26 -0
  68. package/dist/src/platform-adapter.js +7 -0
  69. package/dist/src/platform-startup.js +6 -0
  70. package/dist/src/privacy.js +100 -0
  71. package/dist/src/progress/reducer.js +102 -0
  72. package/dist/src/progress/terminal-renderer.js +264 -0
  73. package/dist/src/progress/view.js +30 -0
  74. package/dist/src/response-stall.js +14 -0
  75. package/dist/src/runtime-entry.js +13 -0
  76. package/dist/src/runtime-reload.js +19 -0
  77. package/dist/src/session-chat-binding.js +183 -0
  78. package/dist/src/session-name.js +7 -0
  79. package/dist/src/session.js +2144 -0
  80. package/dist/src/shared-prefix.js +16 -0
  81. package/dist/src/shared.js +493 -0
  82. package/dist/src/sim-agent.js +105 -0
  83. package/dist/src/sim-platform.js +142 -0
  84. package/dist/src/sim-store.js +231 -0
  85. package/dist/src/simplify.js +99 -0
  86. package/dist/src/startup-lifecycle.js +209 -0
  87. package/dist/src/stream-state.js +141 -0
  88. package/dist/src/terminal-error.js +100 -0
  89. package/dist/src/trace.js +50 -0
  90. package/dist/src/turn-cards.js +92 -0
  91. package/dist/src/update-command-guard.js +114 -0
  92. package/{src/web-ui.ts → dist/src/web-ui.js} +749 -823
  93. package/dist/src/wechat-platform.js +545 -0
  94. package/package.json +7 -5
  95. package/deepccc-agent/LICENSE +0 -201
  96. package/deepccc-agent/bin/deepccc.mjs +0 -26
  97. package/deepccc-agent/docs/cache-hit-rate-1.jpg +0 -0
  98. package/deepccc-agent/docs/cache-hit-rate-2.jpg +0 -0
  99. package/deepccc-agent/package-lock.json +0 -2027
  100. package/deepccc-agent/src/__tests__/chat-session.test.ts +0 -852
  101. package/deepccc-agent/src/__tests__/cli-json.test.ts +0 -49
  102. package/deepccc-agent/src/__tests__/config.test.ts +0 -34
  103. package/deepccc-agent/src/__tests__/context.test.ts +0 -341
  104. package/deepccc-agent/src/__tests__/file-tools.test.ts +0 -240
  105. package/deepccc-agent/src/__tests__/permissions.test.ts +0 -199
  106. package/deepccc-agent/src/__tests__/privacy.test.ts +0 -318
  107. package/deepccc-agent/src/__tests__/progress-reducer.test.ts +0 -121
  108. package/deepccc-agent/src/__tests__/session-search.test.ts +0 -262
  109. package/deepccc-agent/src/__tests__/session-select.test.ts +0 -116
  110. package/deepccc-agent/src/__tests__/sigint.test.ts +0 -56
  111. package/deepccc-agent/src/__tests__/skills.test.ts +0 -284
  112. package/deepccc-agent/src/__tests__/terminal-renderer.test.ts +0 -247
  113. package/deepccc-agent/src/__tests__/web-tools.test.ts +0 -220
  114. package/deepccc-agent/src/cli.ts +0 -682
  115. package/deepccc-agent/src/config.ts +0 -101
  116. package/deepccc-agent/src/context.ts +0 -465
  117. package/deepccc-agent/src/file-log.ts +0 -38
  118. package/deepccc-agent/src/file-tools.ts +0 -1493
  119. package/deepccc-agent/src/index.ts +0 -676
  120. package/deepccc-agent/src/permissions.ts +0 -226
  121. package/deepccc-agent/src/privacy.ts +0 -141
  122. package/deepccc-agent/src/proc-tree-kill.ts +0 -61
  123. package/deepccc-agent/src/progress/cards-helpers.ts +0 -76
  124. package/deepccc-agent/src/progress/reducer.ts +0 -113
  125. package/deepccc-agent/src/progress/terminal-renderer.ts +0 -294
  126. package/deepccc-agent/src/progress/view.ts +0 -77
  127. package/deepccc-agent/src/raw-stream-log.ts +0 -124
  128. package/deepccc-agent/src/session-search.ts +0 -370
  129. package/deepccc-agent/src/session-select.ts +0 -48
  130. package/deepccc-agent/src/sigint.ts +0 -50
  131. package/deepccc-agent/src/skills.ts +0 -205
  132. package/deepccc-agent/src/web-tools.ts +0 -313
  133. package/deepccc-agent/tsconfig.build.json +0 -13
  134. package/deepccc-agent/tsconfig.json +0 -13
  135. package/deepccc-agent/vitest.config.ts +0 -7
  136. package/src/__tests__/adapter-interface.test.ts +0 -152
  137. package/src/__tests__/agent-activity.test.ts +0 -86
  138. package/src/__tests__/agent-delegate-task-rpc.test.ts +0 -165
  139. package/src/__tests__/agent-image-rpc.test.ts +0 -34
  140. package/src/__tests__/agent-platform-routing.test.ts +0 -26
  141. package/src/__tests__/agent-reload-config-rpc.test.ts +0 -99
  142. package/src/__tests__/agent-rpc-body.test.ts +0 -42
  143. package/src/__tests__/builtin-chat-session.test.ts +0 -532
  144. package/src/__tests__/builtin-cli-json.test.ts +0 -39
  145. package/src/__tests__/builtin-config.test.ts +0 -26
  146. package/src/__tests__/builtin-context.test.ts +0 -319
  147. package/src/__tests__/builtin-file-tools.test.ts +0 -240
  148. package/src/__tests__/builtin-permissions.test.ts +0 -219
  149. package/src/__tests__/builtin-session-search.test.ts +0 -262
  150. package/src/__tests__/builtin-session-select.test.ts +0 -116
  151. package/src/__tests__/builtin-sigint.test.ts +0 -56
  152. package/src/__tests__/builtin-skills.test.ts +0 -284
  153. package/src/__tests__/builtin-web-tools.test.ts +0 -220
  154. package/src/__tests__/card-action-routing.test.ts +0 -18
  155. package/src/__tests__/card-plain-text.test.ts +0 -45
  156. package/src/__tests__/cardkit.test.ts +0 -60
  157. package/src/__tests__/cards.test.ts +0 -607
  158. package/src/__tests__/ccc-adapter.test.ts +0 -194
  159. package/src/__tests__/chatgpt-subscription-rpc.test.ts +0 -89
  160. package/src/__tests__/chatgpt-subscription.test.ts +0 -135
  161. package/src/__tests__/chrome-devtools-guard.test.ts +0 -165
  162. package/src/__tests__/claude-adapter.test.ts +0 -614
  163. package/src/__tests__/claude-raw-stream-log.test.ts +0 -96
  164. package/src/__tests__/claude-sdk-installer.test.ts +0 -285
  165. package/src/__tests__/codex-adapter.test.ts +0 -331
  166. package/src/__tests__/codex-raw-stream-log.test.ts +0 -170
  167. package/src/__tests__/codex-reset-actions.test.ts +0 -146
  168. package/src/__tests__/config-reload.test.ts +0 -284
  169. package/src/__tests__/config-sample.test.ts +0 -97
  170. package/src/__tests__/config-utils.test.ts +0 -40
  171. package/src/__tests__/config.test.ts +0 -395
  172. package/src/__tests__/crash-logging.test.ts +0 -360
  173. package/src/__tests__/cursor-adapter.test.ts +0 -890
  174. package/src/__tests__/cursor-session-meta-store.test.ts +0 -212
  175. package/src/__tests__/feishu-api.test.ts +0 -60
  176. package/src/__tests__/feishu-avatar.test.ts +0 -504
  177. package/src/__tests__/feishu-message-ingress.test.ts +0 -138
  178. package/src/__tests__/feishu-platform.test.ts +0 -75
  179. package/src/__tests__/fixtures/codex_simple_text.jsonl +0 -4
  180. package/src/__tests__/fixtures/codex_with_tool.jsonl +0 -6
  181. package/src/__tests__/fixtures/cursor_partial_only.jsonl +0 -5
  182. package/src/__tests__/fixtures/cursor_partial_with_final.jsonl +0 -13
  183. package/src/__tests__/fixtures/cursor_with_tool_call.jsonl +0 -12
  184. package/src/__tests__/format-message.test.ts +0 -316
  185. package/src/__tests__/git-command.test.ts +0 -288
  186. package/src/__tests__/im-skills.test.ts +0 -125
  187. package/src/__tests__/jsonl-stream.test.ts +0 -79
  188. package/src/__tests__/orchestrator.test.ts +0 -1268
  189. package/src/__tests__/package-files.test.ts +0 -24
  190. package/src/__tests__/platform-startup.test.ts +0 -19
  191. package/src/__tests__/privacy.test.ts +0 -198
  192. package/src/__tests__/proc-tree-kill.test.ts +0 -108
  193. package/src/__tests__/progress-reducer.test.ts +0 -121
  194. package/src/__tests__/raw-stream-log.test.ts +0 -106
  195. package/src/__tests__/response-stall.test.ts +0 -49
  196. package/src/__tests__/restart.test.ts +0 -232
  197. package/src/__tests__/session-ccc-config.test.ts +0 -66
  198. package/src/__tests__/session.test.ts +0 -3004
  199. package/src/__tests__/shared-prefix.test.ts +0 -36
  200. package/src/__tests__/sim-agent.test.ts +0 -174
  201. package/src/__tests__/sim-platform.test.ts +0 -93
  202. package/src/__tests__/sim-store.test.ts +0 -214
  203. package/src/__tests__/simplify.test.ts +0 -283
  204. package/src/__tests__/startup-lifecycle.test.ts +0 -231
  205. package/src/__tests__/stop-session.test.ts +0 -162
  206. package/src/__tests__/stream-state.test.ts +0 -164
  207. package/src/__tests__/terminal-error.test.ts +0 -54
  208. package/src/__tests__/terminal-renderer.test.ts +0 -247
  209. package/src/__tests__/update-command-guard.test.ts +0 -144
  210. package/src/__tests__/web-ui.test.ts +0 -438
  211. package/src/__tests__/wechat-platform.test.ts +0 -111
  212. package/src/adapters/adapter-interface.ts +0 -217
  213. package/src/adapters/ccc-adapter.ts +0 -150
  214. package/src/adapters/claude-adapter.ts +0 -673
  215. package/src/adapters/claude-session-meta-store.ts +0 -120
  216. package/src/adapters/codex-adapter.ts +0 -426
  217. package/src/adapters/codex-session-meta-store.ts +0 -131
  218. package/src/adapters/cursor-adapter.ts +0 -681
  219. package/src/adapters/cursor-session-meta-store.ts +0 -154
  220. package/src/adapters/jsonl-stream.ts +0 -157
  221. package/src/adapters/raw-stream-log.ts +0 -124
  222. package/src/adapters/resource-monitor.ts +0 -141
  223. package/src/agent-activity.ts +0 -175
  224. package/src/agent-delegate-task-rpc.ts +0 -153
  225. package/src/agent-delegate-task.ts +0 -91
  226. package/src/agent-file-rpc.ts +0 -172
  227. package/src/agent-image-rpc.ts +0 -168
  228. package/src/agent-platform-routing.ts +0 -28
  229. package/src/agent-reload-config-rpc.ts +0 -34
  230. package/src/agent-rpc-body.ts +0 -92
  231. package/src/agent-stop-stuck.ts +0 -129
  232. package/src/card-action-routing.ts +0 -14
  233. package/src/card-plain-text.ts +0 -108
  234. package/src/cardkit.ts +0 -179
  235. package/src/cards.ts +0 -684
  236. package/src/chatgpt-subscription-rpc.ts +0 -27
  237. package/src/chatgpt-subscription.ts +0 -299
  238. package/src/chrome-devtools-guard.ts +0 -318
  239. package/src/claude-sdk-installer.ts +0 -324
  240. package/src/codex-reset-actions.ts +0 -184
  241. package/src/config-utils.ts +0 -211
  242. package/src/config.ts +0 -1063
  243. package/src/cursor-usage.ts +0 -128
  244. package/src/exit-banner.ts +0 -33
  245. package/src/feishu-api.ts +0 -1616
  246. package/src/feishu-message-ingress.ts +0 -195
  247. package/src/feishu-platform.ts +0 -159
  248. package/src/format-message.ts +0 -293
  249. package/src/git-command.ts +0 -202
  250. package/src/im-skills.ts +0 -149
  251. package/src/index.ts +0 -1089
  252. package/src/litellm-proxy.ts +0 -374
  253. package/src/orchestrator.ts +0 -2543
  254. package/src/platform-adapter.ts +0 -70
  255. package/src/platform-startup.ts +0 -16
  256. package/src/privacy.ts +0 -118
  257. package/src/progress/reducer.ts +0 -113
  258. package/src/progress/terminal-renderer.ts +0 -294
  259. package/src/progress/view.ts +0 -77
  260. package/src/response-stall.ts +0 -28
  261. package/src/runtime-reload.ts +0 -34
  262. package/src/session-chat-binding.ts +0 -292
  263. package/src/session-name.ts +0 -8
  264. package/src/session.ts +0 -2659
  265. package/src/shared-prefix.ts +0 -29
  266. package/src/shared.ts +0 -552
  267. package/src/sim-agent.ts +0 -167
  268. package/src/sim-platform.ts +0 -177
  269. package/src/sim-store.ts +0 -317
  270. package/src/simplify.ts +0 -120
  271. package/src/startup-lifecycle.ts +0 -250
  272. package/src/stream-state.ts +0 -177
  273. package/src/terminal-error.ts +0 -129
  274. package/src/trace.ts +0 -51
  275. package/src/turn-cards.ts +0 -118
  276. package/src/update-command-guard.ts +0 -165
  277. package/src/wechat-platform.ts +0 -680
@@ -0,0 +1,2144 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { CLAUDE_API_KEY, CLAUDE_BASE_URL, CLAUDE_MAX_TURN, CLAUDE_MODEL, CLAUDE_SUBAGENT_MODEL, CHATCCC_PORT, PROJECT_ROOT, SESSIONS_FILE, USER_DATA_DIR, addRecentDir, anthropicConfigDisplay, config, fileLog, getDefaultCwd, getDefaultEffortForTool, isAnthropicConfigEmpty, ts, } from "./config.js";
4
+ import { buildProgressCard, getToolEmoji, isCodeBlockOpen, truncateContent } from "./cards.js";
5
+ import { progressView } from "./progress/view.js";
6
+ import { createAgentActivityTracker, formatAgentActivityTitle, updateAgentActivity, } from "./agent-activity.js";
7
+ import { simplifyToolUse, simplifyToolResult } from "./simplify.js";
8
+ import { logTrace } from "./trace.js";
9
+ import { createClaudeAdapter } from "./adapters/claude-adapter.js";
10
+ import { createCursorAdapter } from "./adapters/cursor-adapter.js";
11
+ import { createCodexAdapter } from "./adapters/codex-adapter.js";
12
+ import { createCccAdapter } from "./adapters/ccc-adapter.js";
13
+ import { killProcessTree } from "./adapters/proc-tree-kill.js";
14
+ import { resourceMonitor, registerProcess, unregisterProcess } from "./adapters/resource-monitor.js";
15
+ import { buildImSkillsPromptCached, exportSkillSubDocs } from "./im-skills.js";
16
+ import { hasResponseStalled, observeResponseProgress } from "./response-stall.js";
17
+ import { classifyTerminalError, formatTerminalErrorNotice, formatTerminalErrorReason, } from "./terminal-error.js";
18
+ import { MAX_PROCESSED, clearFeishuMessageLedgerMemory, processedMessages, } from "./feishu-message-ingress.js";
19
+ export { MAX_PROCESSED, processedMessages };
20
+ // 微信显示循环压缩:头5 + ... + 尾5,避免在最后一步 sendText 中压缩指令回复
21
+ function compressWechatDisplayText(text) {
22
+ const lines = text.split("\n");
23
+ if (lines.length <= 10)
24
+ return text;
25
+ return [...lines.slice(0, 5), "...", ...lines.slice(-5)].join("\n");
26
+ }
27
+ import { readStreamState, writeStreamState, createEmptyStreamState, isFinalReplySentForTurn, markFinalReplySent, } from "./stream-state.js";
28
+ import { addCardToTurn, finalizeTurnCards, markCardDone } from "./turn-cards.js";
29
+ import { bindChatToSession, unbindChatFromSession, getChatsForSession, activePrompts, displayCards, unifiedDisplayLoopHandle, setUnifiedDisplayLoopHandle, rebuildSessionChatsFromRegistry, recordLastActiveChat, getLastActiveChat, pickDisplayChat, dequeueMessage, consumeQueuedMessage, cancelQueuedMessage, setQueuePreservedChat, consumeQueuePreservedChat, markSessionFinalizing, clearSessionFinalizing, reserveAutoRecovery, consumeAutoRecoveryReservation, cancelAutoRecoveryReservation, hasAutoRecoveryReservation, } from "./session-chat-binding.js";
30
+ async function sendFinalReplyTextOnce(platform, chatId, sessionId, turnCount, text) {
31
+ const sent = await platform.sendText(chatId, text).then((ok) => ok !== false).catch(() => false);
32
+ if (sent)
33
+ await markFinalReplySent(sessionId, turnCount);
34
+ return sent;
35
+ }
36
+ async function createVisibleProgressCard(platform, chatId, sessionId, turnCount, notifyFailureText, headerTitle = "正在启动 Agent · 0秒") {
37
+ for (let attempt = 1; attempt <= 2; attempt++) {
38
+ let cardId = null;
39
+ try {
40
+ cardId = await platform.cardCreate(buildProgressCard(progressView({ text: "等待 Agent 输出...", showStop: true, headerTitle })));
41
+ if (!cardId)
42
+ throw new Error("empty card id");
43
+ await platform.cardSend(chatId, cardId);
44
+ await addCardToTurn(sessionId, turnCount, cardId);
45
+ return cardId;
46
+ }
47
+ catch (err) {
48
+ console.error(`[${ts()}] [DISPLAY] progress card send attempt ${attempt} failed: chatId=${chatId} cardId=${cardId || "(none)"} ${err.message}`);
49
+ }
50
+ }
51
+ if (notifyFailureText) {
52
+ await platform.sendText(chatId, notifyFailureText).catch(() => { });
53
+ }
54
+ return null;
55
+ }
56
+ // ---------------------------------------------------------------------------
57
+ // Shared state (imported by index.ts)
58
+ // ---------------------------------------------------------------------------
59
+ /** 每个 chatId 上一次已处理消息的时间戳,用于拦截延迟送达的旧消息 */
60
+ export const lastMsgTimestamps = new Map();
61
+ // ---------------------------------------------------------------------------
62
+ // 平台引用 —— session 模块通过此引用访问 IM 平台操作,
63
+ // 避免 import feishu-platform.ts 造成的耦合。
64
+ // 由 index.ts 在启动时调用 setSessionPlatform 注入。
65
+ // ---------------------------------------------------------------------------
66
+ let platformRef = null;
67
+ const chatPlatformMap = new Map();
68
+ /** 注入当前 IM 平台适配器,供 session 模块使用 */
69
+ export function setSessionPlatform(platform) {
70
+ platformRef = platform;
71
+ }
72
+ export function recordChatPlatform(chatId, platform) {
73
+ chatPlatformMap.set(chatId, platform);
74
+ }
75
+ export function forgetChatPlatform(chatId) {
76
+ chatPlatformMap.delete(chatId);
77
+ }
78
+ function platformForChat(chatId) {
79
+ return chatPlatformMap.get(chatId) ?? platformRef;
80
+ }
81
+ const DEFAULT_PROCESS_MONITOR_INTERVAL_MS = 5000;
82
+ const DEFAULT_AVATAR_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
83
+ const DEFAULT_RESPONSE_STALL_TIMEOUT_MS = 3 * 60 * 1000;
84
+ const DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS = 5000;
85
+ const DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS = 10_000;
86
+ export const RESPONSE_STALL_RECOVERY_PROMPT = "完成了吗?如果没完成继续";
87
+ export const RESPONSE_STALL_RECOVERY_NOTICE = `检测到会话停滞,正在自动确认并继续。\n\n${RESPONSE_STALL_RECOVERY_PROMPT}`;
88
+ export const RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE = "⚠️ 自动续跑仍连续 3 分钟没有生成新回复,本次不再自动继续。";
89
+ const RESPONSE_STALL_RECOVERY_DELAY_MS = 200;
90
+ let processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
91
+ let avatarRefreshIntervalMs = DEFAULT_AVATAR_REFRESH_INTERVAL_MS;
92
+ let responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
93
+ let responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
94
+ let finalResponseCloseTimeoutMs = DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS;
95
+ let isProcessAliveImpl = (pid) => {
96
+ try {
97
+ process.kill(pid, 0);
98
+ return true;
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ };
104
+ export function _setProcessAliveForTest(impl) {
105
+ isProcessAliveImpl = impl;
106
+ }
107
+ export function _resetProcessAliveForTest() {
108
+ isProcessAliveImpl = (pid) => {
109
+ try {
110
+ process.kill(pid, 0);
111
+ return true;
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ };
117
+ }
118
+ export function _setProcessMonitorIntervalForTest(ms) {
119
+ processMonitorIntervalMs = ms;
120
+ }
121
+ export function _resetProcessMonitorIntervalForTest() {
122
+ processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
123
+ }
124
+ export function _setAvatarRefreshIntervalForTest(ms) {
125
+ avatarRefreshIntervalMs = ms;
126
+ }
127
+ export function _resetAvatarRefreshIntervalForTest() {
128
+ avatarRefreshIntervalMs = DEFAULT_AVATAR_REFRESH_INTERVAL_MS;
129
+ }
130
+ export function _setResponseStallTimeoutForTest(ms) {
131
+ responseStallTimeoutMs = ms;
132
+ }
133
+ export function _resetResponseStallTimeoutForTest() {
134
+ responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
135
+ }
136
+ export function _setResponseStallCheckIntervalForTest(ms) {
137
+ responseStallCheckIntervalMs = ms;
138
+ }
139
+ export function _resetResponseStallCheckIntervalForTest() {
140
+ responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
141
+ }
142
+ export function _setFinalResponseCloseTimeoutForTest(ms) {
143
+ finalResponseCloseTimeoutMs = ms;
144
+ }
145
+ export function _resetFinalResponseCloseTimeoutForTest() {
146
+ finalResponseCloseTimeoutMs = DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS;
147
+ }
148
+ function clearPromptProcessMonitor(sessionId) {
149
+ const prompt = activePrompts.get(sessionId);
150
+ if (!prompt?.processMonitor)
151
+ return;
152
+ clearInterval(prompt.processMonitor);
153
+ prompt.processMonitor = undefined;
154
+ }
155
+ function clearPromptResponseStallMonitor(sessionId) {
156
+ const prompt = activePrompts.get(sessionId);
157
+ if (!prompt?.responseStallMonitor)
158
+ return;
159
+ clearInterval(prompt.responseStallMonitor);
160
+ prompt.responseStallMonitor = undefined;
161
+ }
162
+ function clearPromptAvatarRefreshTimer(sessionId) {
163
+ const prompt = activePrompts.get(sessionId);
164
+ if (!prompt?.avatarRefreshTimer)
165
+ return;
166
+ clearInterval(prompt.avatarRefreshTimer);
167
+ prompt.avatarRefreshTimer = undefined;
168
+ }
169
+ function clearPromptFinalResponseCloseTimer(sessionId) {
170
+ const prompt = activePrompts.get(sessionId);
171
+ if (!prompt?.finalResponseCloseTimer)
172
+ return;
173
+ clearTimeout(prompt.finalResponseCloseTimer);
174
+ prompt.finalResponseCloseTimer = undefined;
175
+ }
176
+ /**
177
+ * 权威终态只说明 Agent 已完成本轮,不保证 CLI/SDK 的输出流会及时关闭。
178
+ * 给正常清理保留 10 秒;若流仍悬挂,则关闭底层 session 并杀掉当前 CLI 树,
179
+ * 让 runAgentSession 以 done 收尾。这里绝不触发自动续跑,因为答案已完整到达。
180
+ */
181
+ function scheduleFinalResponseCloseGuard(sessionId, runningPrompt) {
182
+ if (runningPrompt.finalResponseCloseTimer)
183
+ return;
184
+ const timeoutMs = finalResponseCloseTimeoutMs;
185
+ const handle = setTimeout(() => {
186
+ const current = activePrompts.get(sessionId);
187
+ if (!current
188
+ || current !== runningPrompt
189
+ || !current.finalResponseObserved
190
+ || current.stopped
191
+ || current.abnormalExit
192
+ || current.resourceStuck
193
+ || current.autoEnded) {
194
+ return;
195
+ }
196
+ current.finalResponseCloseTimer = undefined;
197
+ clearPromptProcessMonitor(sessionId);
198
+ clearPromptResponseStallMonitor(sessionId);
199
+ try {
200
+ current.closeSession?.();
201
+ }
202
+ catch (err) {
203
+ console.warn(`[${ts()}] [FINAL-RESPONSE] closeSession failed for ${sessionId}: ${err.message}`);
204
+ }
205
+ current.controller.abort();
206
+ void killProcessTree(current.processPid);
207
+ console.warn(`[${ts()}] [FINAL-RESPONSE] Session ${sessionId} stream stayed open for ${timeoutMs}ms after its authoritative final event; forced clean shutdown`);
208
+ }, timeoutMs);
209
+ handle.unref?.();
210
+ runningPrompt.finalResponseCloseTimer = handle;
211
+ }
212
+ function formatTerminalHeader(status, terminalError) {
213
+ if (status === "auto_ended")
214
+ return { title: "已自动结束 · 3分钟无新内容", template: "orange" };
215
+ if (status === "stopped")
216
+ return { title: "已停止", template: "red" };
217
+ if (status === "error") {
218
+ return { title: terminalError ? `异常结束 · ${terminalError.title}` : "异常结束", template: "red" };
219
+ }
220
+ return { title: "完成" };
221
+ }
222
+ function turnFinalStatus(status) {
223
+ return status === "stopped" || status === "error" || status === "auto_ended" ? "stopped" : "done";
224
+ }
225
+ function formatAutoEndedReply(finalReply) {
226
+ const reason = "⚠️ 已自动结束:生成回复阶段连续 3 分钟没有字符变化。";
227
+ return finalReply
228
+ ? `${reason}以下回复可能不完整。\n\n${finalReply}`
229
+ : `${reason}本轮没有可发送的回复内容。`;
230
+ }
231
+ /**
232
+ * 只监控明确的回复生成阶段。启动、压缩、思考、工具调用和搜索都有各自的
233
+ * 状态或资源保护,不能把它们消耗的时间算入回复停滞窗口。
234
+ */
235
+ function monitorsOutputProgress(kind) {
236
+ return kind === "responding";
237
+ }
238
+ function formatTerminalReply(status, finalReply, terminalError) {
239
+ if (status === "auto_ended")
240
+ return formatAutoEndedReply(finalReply);
241
+ if (status === "error") {
242
+ const error = terminalError ?? {
243
+ kind: "unknown",
244
+ title: "原因未记录",
245
+ message: "当前状态中没有可用的错误详情,请查看运行日志。",
246
+ occurredAt: Date.now(),
247
+ };
248
+ return formatTerminalErrorNotice(error, finalReply);
249
+ }
250
+ return finalReply || null;
251
+ }
252
+ function formatTerminalCardContent(state) {
253
+ const content = state.accumulatedContent + state.finalReply;
254
+ if (state.status !== "error")
255
+ return content;
256
+ const error = state.terminalError ?? {
257
+ kind: "unknown",
258
+ title: "原因未记录",
259
+ message: "当前状态中没有可用的错误详情,请查看运行日志。",
260
+ occurredAt: Date.now(),
261
+ };
262
+ const reason = formatTerminalErrorReason(error);
263
+ return content.trim()
264
+ ? `${content}\n\n${reason}\n以上内容可能不完整。`
265
+ : reason;
266
+ }
267
+ function isCardKitSequenceConflict(err) {
268
+ return err instanceof Error && err.message.includes("300317");
269
+ }
270
+ function startPromptProcessMonitor(sessionId, info) {
271
+ const prompt = activePrompts.get(sessionId);
272
+ if (!prompt)
273
+ return;
274
+ prompt.processPid = info.pid;
275
+ clearPromptProcessMonitor(sessionId);
276
+ const check = async () => {
277
+ const current = activePrompts.get(sessionId);
278
+ if (!current || current !== prompt) {
279
+ clearPromptProcessMonitor(sessionId);
280
+ return;
281
+ }
282
+ if (current.stopped || current.abnormalExit || current.resourceStuck || current.autoEnded)
283
+ return;
284
+ if (isProcessAliveImpl(info.pid))
285
+ return;
286
+ current.abnormalExit = true;
287
+ clearPromptProcessMonitor(sessionId);
288
+ const state = await readStreamState(sessionId);
289
+ if (state?.status === "running") {
290
+ await writeStreamState({
291
+ ...state,
292
+ status: "error",
293
+ updatedAt: Date.now(),
294
+ });
295
+ }
296
+ const chatId = pickDisplayChat(sessionId) ?? getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
297
+ const p = chatId ? platformForChat(chatId) : null;
298
+ if (chatId && p && !current.abnormalExitNotified) {
299
+ current.abnormalExitNotified = true;
300
+ await p.sendText(chatId, `⚠️ 进程异常结束:session ${sessionId} 对应的 CLI 进程 PID ${info.pid} 已不存在,已按完成处理。若回复不完整,请重新发送上一条指令。`).catch(() => { });
301
+ }
302
+ // 主动关闭 readline,让 runAgentSession 的 finally 落盘 error 终态并清理 activePrompts。
303
+ current.controller.abort();
304
+ };
305
+ const handle = setInterval(() => {
306
+ void check().catch((err) => {
307
+ console.warn(`[${ts()}] [PROCESS-MONITOR] check failed for ${sessionId}: ${err.message}`);
308
+ });
309
+ }, processMonitorIntervalMs);
310
+ handle.unref?.();
311
+ prompt.processMonitor = handle;
312
+ }
313
+ export function _getPlatformForChatForTest(chatId) {
314
+ return platformForChat(chatId);
315
+ }
316
+ export function getPlatformForChat(chatId) {
317
+ return platformForChat(chatId);
318
+ }
319
+ function imSkillNamesForPlatform(platform) {
320
+ if (platform.kind === "wechat") {
321
+ return ["wechat-image-skill", "wechat-file-skill", "wechat-video-skill"];
322
+ }
323
+ return ["feishu-skill"];
324
+ }
325
+ export let sessionGen = 0;
326
+ /** @deprecated 使用 activePrompts (session-chat-binding.ts) + displayCards 替代 */
327
+ export const chatSessionMap = new Map();
328
+ /**
329
+ * sessionInfoMap 记录每个 chatId 当前绑定的会话元数据。
330
+ * 同一 session 可被多个 chatId 共享;model/effort 不在其中(按 tool 动态解析)。
331
+ */
332
+ export const sessionInfoMap = new Map();
333
+ /**
334
+ * 清空所有进程内运行时状态。
335
+ *
336
+ * ⚠️ 红线:**绝对不要**在飞书 SDK 的 onReady / onReconnected 回调里调用本函数。
337
+ * SDK 的 WebSocket 重连只是底层连接抖动,业务层(活跃 prompt、display loop、
338
+ * stream-state 文件、轮数计数)完全不受影响。在重连里调 resetState 会:
339
+ * 1) `activePrompts.clear()` 只是删 Map,**不会** abort 后台 generator。
340
+ * generator 继续跑、继续写 stream-state.json,但 display loop 已被
341
+ * stop,用户群里再也看不到任何更新;最终回复永远不发到群。
342
+ * 2) 该 sessionId 在内存里"看似空闲",下一条用户消息进来会**第二次进入**
343
+ * `runAgentSession`,同一个 cursor/claude session 同时跑两条 prompt,
344
+ * 输出互相串扰、token 计费翻倍。
345
+ * 3) `processedMessages` / `lastMsgTimestamps` 被清,SDK 重连后若服务端
346
+ * 重推已 ack 的消息,去重失效会让同一 prompt 被处理两次。
347
+ * 4) `sessionInfoMap` 清空后,群再发消息时 nextTurnCount 从 1 重新计数。
348
+ *
349
+ * 合法调用点:
350
+ * - 单元测试 setup(清测试间状态)
351
+ * - 进程首次启动(此时 Map 都是空的,调用纯粹是为了打 LOG)
352
+ *
353
+ * SDK 重连场景请改用 `rebuildBindingsFromRegistry()`,它只重建 sessionId →
354
+ * chatId 映射,不动任何运行时状态。
355
+ */
356
+ export function resetState() {
357
+ for (const entry of chatSessionMap.values()) {
358
+ if (entry.spinnerTimer)
359
+ clearInterval(entry.spinnerTimer);
360
+ try {
361
+ entry.close();
362
+ }
363
+ catch { /* ignore */ }
364
+ }
365
+ chatSessionMap.clear();
366
+ sessionInfoMap.clear();
367
+ clearFeishuMessageLedgerMemory();
368
+ lastMsgTimestamps.clear();
369
+ chatPlatformMap.clear();
370
+ for (const prompt of activePrompts.values()) {
371
+ if (prompt.processMonitor)
372
+ clearInterval(prompt.processMonitor);
373
+ if (prompt.responseStallMonitor)
374
+ clearInterval(prompt.responseStallMonitor);
375
+ if (prompt.avatarRefreshTimer)
376
+ clearInterval(prompt.avatarRefreshTimer);
377
+ if (prompt.finalResponseCloseTimer)
378
+ clearTimeout(prompt.finalResponseCloseTimer);
379
+ }
380
+ activePrompts.clear();
381
+ displayCards.clear();
382
+ sessionModelOverrides.clear();
383
+ sessionEffortOverrides.clear();
384
+ sessionFastModeOverrides.clear();
385
+ adapterCache.clear();
386
+ stopUnifiedDisplayLoop();
387
+ console.log(`[${ts()}] [RESET] State cleared (dedup + active sessions + bindings)`);
388
+ }
389
+ // 注:`rebuildBindingsFromRegistry` 定义在下方与 loadSessionRegistry 同区域,
390
+ // 是 onReady/onReconnected 取代 resetState 的正确入口。
391
+ // ---------------------------------------------------------------------------
392
+ // Adapter: 按 tool + effectiveModel 创建并缓存
393
+ // ---------------------------------------------------------------------------
394
+ const adapterCache = new Map();
395
+ // Per-session 模型覆盖(/model 命令设置,不持久化)
396
+ const sessionModelOverrides = new Map();
397
+ const sessionEffortOverrides = new Map();
398
+ const sessionFastModeOverrides = new Map();
399
+ /** 返回 session 的生效模型:优先 per-session 覆盖,其次全局配置(Claude) */
400
+ function getModelForSession(sessionId) {
401
+ if (sessionId) {
402
+ const override = sessionModelOverrides.get(sessionId);
403
+ if (override)
404
+ return override;
405
+ }
406
+ return CLAUDE_MODEL;
407
+ }
408
+ /** 返回指定 tool 的生效模型:优先 per-session 覆盖,其次 tool 默认配置 */
409
+ export function getEffectiveModelForTool(tool, sessionId) {
410
+ if (sessionId) {
411
+ const override = sessionModelOverrides.get(sessionId);
412
+ if (override)
413
+ return override;
414
+ }
415
+ if (tool === "cursor")
416
+ return config.cursor.model;
417
+ if (tool === "codex")
418
+ return config.codex.model;
419
+ if (tool === "ccc")
420
+ return config.ccc.model;
421
+ return CLAUDE_MODEL;
422
+ }
423
+ export function getEffectiveEffortForTool(tool, sessionId) {
424
+ if (sessionId) {
425
+ const override = sessionEffortOverrides.get(sessionId);
426
+ if (override)
427
+ return override;
428
+ }
429
+ if (tool === "claude" || tool === "codex" || tool === "ccc") {
430
+ return getDefaultEffortForTool(tool);
431
+ }
432
+ return "";
433
+ }
434
+ export function getEffectiveFastModeForTool(tool, sessionId) {
435
+ if (tool !== "codex")
436
+ return false;
437
+ if (sessionId && sessionFastModeOverrides.has(sessionId)) {
438
+ return sessionFastModeOverrides.get(sessionId) === true;
439
+ }
440
+ return config.codex.fastMode;
441
+ }
442
+ function setSessionChatAvatar(platform, chatId, tool, status, sessionId) {
443
+ return getEffectiveFastModeForTool(tool, sessionId)
444
+ ? platform.setChatAvatar(chatId, tool, status, { fastMode: true })
445
+ : platform.setChatAvatar(chatId, tool, status);
446
+ }
447
+ async function refreshBusySessionAvatar(sessionId, tool, fallbackPlatform) {
448
+ const chatId = pickDisplayChat(sessionId) ?? getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
449
+ if (!chatId)
450
+ return;
451
+ const platform = platformForChat(chatId) ?? fallbackPlatform;
452
+ await setSessionChatAvatar(platform, chatId, tool, "busy", sessionId);
453
+ }
454
+ function startPromptAvatarRefresh(sessionId, tool, fallbackPlatform, runningPrompt) {
455
+ let refreshInFlight = false;
456
+ const timer = setInterval(() => {
457
+ const current = activePrompts.get(sessionId);
458
+ if (!current || current !== runningPrompt) {
459
+ clearInterval(timer);
460
+ if (runningPrompt.avatarRefreshTimer === timer) {
461
+ runningPrompt.avatarRefreshTimer = undefined;
462
+ }
463
+ return;
464
+ }
465
+ if (refreshInFlight
466
+ || current.stopped
467
+ || current.abnormalExit
468
+ || current.resourceStuck
469
+ || current.autoEnded
470
+ || current.finalResponseObserved) {
471
+ return;
472
+ }
473
+ refreshInFlight = true;
474
+ void refreshBusySessionAvatar(sessionId, tool, fallbackPlatform)
475
+ .catch((err) => {
476
+ console.warn(`[${ts()}] [AVATAR] Periodic refresh failed for ${sessionId}: ${err.message}`);
477
+ })
478
+ .finally(() => {
479
+ refreshInFlight = false;
480
+ });
481
+ }, avatarRefreshIntervalMs);
482
+ timer.unref?.();
483
+ runningPrompt.avatarRefreshTimer = timer;
484
+ }
485
+ /** 为指定 session 设置模型覆盖(/model <name>) */
486
+ export function setSessionModelOverride(sessionId, model) {
487
+ sessionModelOverrides.set(sessionId, model);
488
+ adapterCache.clear();
489
+ }
490
+ /** 清除指定 session 的模型覆盖(/model clear) */
491
+ export function clearSessionModelOverride(sessionId) {
492
+ sessionModelOverrides.delete(sessionId);
493
+ adapterCache.clear();
494
+ }
495
+ export function setSessionEffortOverride(sessionId, effort) {
496
+ sessionEffortOverrides.set(sessionId, effort);
497
+ adapterCache.clear();
498
+ }
499
+ export function clearSessionEffortOverride(sessionId) {
500
+ sessionEffortOverrides.delete(sessionId);
501
+ adapterCache.clear();
502
+ }
503
+ export function setSessionFastModeOverride(sessionId, fastMode) {
504
+ sessionFastModeOverrides.set(sessionId, fastMode);
505
+ adapterCache.clear();
506
+ }
507
+ export function getAdapterForTool(tool, sessionId) {
508
+ const effectiveModel = getEffectiveModelForTool(tool, sessionId);
509
+ const effectiveEffort = getEffectiveEffortForTool(tool, sessionId);
510
+ const effectiveFastMode = getEffectiveFastModeForTool(tool, sessionId);
511
+ const cacheKey = `${tool}:${effectiveModel || ""}:${effectiveEffort || ""}:${effectiveFastMode ? "fast" : "default"}`;
512
+ const cached = adapterCache.get(cacheKey);
513
+ if (cached)
514
+ return cached;
515
+ let adapter;
516
+ if (tool === "cursor") {
517
+ adapter = createCursorAdapter({ model: effectiveModel || undefined });
518
+ }
519
+ else if (tool === "codex") {
520
+ adapter = createCodexAdapter({
521
+ model: effectiveModel || undefined,
522
+ effort: effectiveEffort || undefined,
523
+ fastMode: effectiveFastMode,
524
+ });
525
+ }
526
+ else if (tool === "ccc") {
527
+ adapter = createCccAdapter({
528
+ apiKey: config.ccc.DEEPSEEK_API_KEY,
529
+ baseURL: config.ccc.DEEPSEEK_BASE_URL,
530
+ model: effectiveModel || undefined,
531
+ effort: effectiveEffort || undefined,
532
+ // 留空("")不传 → ChatSession 跟随 DeepCCC 内核配置(~/.deepccc/config.json 或 DEEPCCC_PROVIDER)
533
+ ...(config.ccc.provider ? { provider: config.ccc.provider } : {}),
534
+ });
535
+ }
536
+ else {
537
+ adapter = createClaudeAdapter({
538
+ model: effectiveModel,
539
+ subagentModel: CLAUDE_SUBAGENT_MODEL,
540
+ effort: effectiveEffort,
541
+ apiKey: CLAUDE_API_KEY,
542
+ baseUrl: CLAUDE_BASE_URL,
543
+ isEmpty: isAnthropicConfigEmpty,
544
+ maxTurn: CLAUDE_MAX_TURN,
545
+ });
546
+ }
547
+ adapterCache.set(cacheKey, adapter);
548
+ return adapter;
549
+ }
550
+ let sessionToolsFile = SESSIONS_FILE;
551
+ async function loadSessionTools() {
552
+ try {
553
+ const raw = await readFile(sessionToolsFile, "utf-8");
554
+ return JSON.parse(raw);
555
+ }
556
+ catch {
557
+ return {};
558
+ }
559
+ }
560
+ async function saveSessionTools(data) {
561
+ try {
562
+ await mkdir(dirname(sessionToolsFile), { recursive: true });
563
+ await writeFile(sessionToolsFile, JSON.stringify(data, null, 2), "utf-8");
564
+ }
565
+ catch (err) {
566
+ console.error(`[${ts()}] Failed to save sessions.json: ${err.message}`);
567
+ fileLog.flush();
568
+ }
569
+ }
570
+ export async function saveSessionTool(sessionId, tool, chatName) {
571
+ const data = await loadSessionTools();
572
+ const existing = data[sessionId];
573
+ const mergedChatName = chatName ?? existing?.chatName;
574
+ data[sessionId] = {
575
+ tool,
576
+ createdAt: existing?.createdAt ?? Date.now(),
577
+ ...(mergedChatName ? { chatName: mergedChatName } : {}),
578
+ };
579
+ await saveSessionTools(data);
580
+ }
581
+ export async function getSessionTool(sessionId) {
582
+ const data = await loadSessionTools();
583
+ const record = data[sessionId];
584
+ return record?.tool ?? null;
585
+ }
586
+ export function _setSessionToolsFileForTest(filePath) {
587
+ sessionToolsFile = filePath;
588
+ }
589
+ export function _resetSessionToolsFileForTest() {
590
+ sessionToolsFile = SESSIONS_FILE;
591
+ }
592
+ // ---------------------------------------------------------------------------
593
+ // Conversation session registry for /sessions
594
+ // ---------------------------------------------------------------------------
595
+ export const SESSION_REGISTRY_FILE = join(USER_DATA_DIR, "state", "session-registry.json");
596
+ let sessionRegistryFile = SESSION_REGISTRY_FILE;
597
+ async function loadSessionRegistry() {
598
+ try {
599
+ const raw = await readFile(sessionRegistryFile, "utf-8");
600
+ const parsed = JSON.parse(raw);
601
+ return parsed && typeof parsed === "object" ? parsed : {};
602
+ }
603
+ catch {
604
+ return {};
605
+ }
606
+ }
607
+ /** 供 session-chat-binding.ts 重建映射 */
608
+ export async function loadSessionRegistryForBinding() {
609
+ return loadSessionRegistry();
610
+ }
611
+ /**
612
+ * 从持久化的 registry 重建 sessionId → chatId 映射。
613
+ *
614
+ * 设计契约(替代之前 onReady/onReconnected 误用的 resetState):
615
+ * - **不动** activePrompts:后台 prompt 在 SDK 重连后必须继续被识别为活跃,
616
+ * 否则下条用户消息会绕过 isSessionRunning 检查再开一条 prompt,
617
+ * 导致同一 sessionId 双开 generator
618
+ * - **不动** sessionInfoMap:内存里的轮数/contextTokens 比 registry 更新
619
+ * - **不动** displayCards:正在跑的 prompt 还需要它们继续推卡片
620
+ * - **不动** processedMessages / lastMsgTimestamps:SDK 重连若重推已 ack 消息,
621
+ * 去重 set 还在才能避免同一 prompt 跑两遍
622
+ *
623
+ * 唯一被重建的是 sessionChatsMap(通过调用 rebuildSessionChatsFromRegistry)——
624
+ * 该 Map 是从 registry 派生的纯只读映射,重建是幂等且廉价的。
625
+ */
626
+ export async function rebuildBindingsFromRegistry() {
627
+ const registry = await loadSessionRegistry();
628
+ rebuildSessionChatsFromRegistry(registry);
629
+ }
630
+ async function saveSessionRegistry(data) {
631
+ try {
632
+ await mkdir(dirname(sessionRegistryFile), { recursive: true });
633
+ await writeFile(sessionRegistryFile, JSON.stringify(data, null, 2), "utf-8");
634
+ }
635
+ catch (err) {
636
+ console.error(`[${ts()}] Failed to save session-registry.json: ${err.message}`);
637
+ fileLog.flush();
638
+ }
639
+ }
640
+ export async function recordSessionRegistry(update) {
641
+ const data = await loadSessionRegistry();
642
+ const existing = data[update.chatId];
643
+ const now = update.updatedAt ?? Date.now();
644
+ data[update.chatId] = {
645
+ chatId: update.chatId,
646
+ sessionId: update.sessionId,
647
+ tool: update.tool,
648
+ chatType: update.chatType ?? existing?.chatType,
649
+ chatName: update.chatName ?? existing?.chatName ?? "",
650
+ turnCount: update.turnCount ?? existing?.turnCount ?? 0,
651
+ lastContextTokens: update.lastContextTokens ?? existing?.lastContextTokens ?? 0,
652
+ startTime: update.startTime ?? existing?.startTime ?? now,
653
+ updatedAt: now,
654
+ running: update.running ?? existing?.running ?? false,
655
+ };
656
+ await saveSessionRegistry(data);
657
+ }
658
+ export async function removeSessionRegistryRecord(chatId) {
659
+ const data = await loadSessionRegistry();
660
+ delete data[chatId];
661
+ await saveSessionRegistry(data);
662
+ }
663
+ export function _setSessionRegistryFileForTest(filePath) {
664
+ sessionRegistryFile = filePath;
665
+ }
666
+ export function _resetSessionRegistryFileForTest() {
667
+ sessionRegistryFile = SESSION_REGISTRY_FILE;
668
+ }
669
+ /**
670
+ * 在 partial 累加(finalText)与适配器给出的"完整最终文本"(finalCompleteText)
671
+ * 之间挑选最终回复:
672
+ * - finalCompleteText 非空时永远优先(来自 cursor result.result 等权威源)
673
+ * - 否则回退到 finalText(partial 累加)
674
+ *
675
+ * 不做长度比较:cursor 在工具调用前会发 buffered flush(重复快照),
676
+ * 若按当前 adapter 误把 buffered flush 当 delta 累加,partial 累加可能"虚高",
677
+ * 此时取更长会选错;权威源(result.result)才是正解。
678
+ */
679
+ export function pickFinalReply(state) {
680
+ return state.finalCompleteText || state.finalText;
681
+ }
682
+ export function accumulateBlockContent(block, state, toolCallMap) {
683
+ switch (block.type) {
684
+ case "thinking":
685
+ state.chunkCount++;
686
+ // 用引用块标记思考内容(中文无法斜体,引用块有视觉区分)
687
+ state.accumulatedContent += `\n> ${block.thinking.replace(/\n/g, "\n> ")}\n`;
688
+ break;
689
+ case "tool_use": {
690
+ // 记录 tool_use 信息供后续 tool_result 使用
691
+ if (toolCallMap && block.id) {
692
+ toolCallMap.set(block.id, { name: block.name, input: block.input });
693
+ }
694
+ const simplified = simplifyToolUse(block.name, block.input);
695
+ if (simplified !== null) {
696
+ state.accumulatedContent += `\n\n${simplified}\n`;
697
+ }
698
+ else {
699
+ const inputStr = typeof block.input === "object"
700
+ ? JSON.stringify(block.input)
701
+ : String(block.input ?? "");
702
+ const shortInput = inputStr.length > 300 ? inputStr.slice(0, 300) + "..." : inputStr;
703
+ state.accumulatedContent +=
704
+ `\n\n${getToolEmoji(block.name)} **${block.name}**\n\`${shortInput}\`\n`;
705
+ }
706
+ break;
707
+ }
708
+ case "tool_result": {
709
+ const toolUseId = block.tool_use_id;
710
+ const isError = block.is_error;
711
+ // 查找对应的 tool_use 以获取工具名和输入
712
+ const toolCall = toolCallMap?.get(toolUseId);
713
+ const toolName = toolCall?.name;
714
+ const toolInput = toolCall?.input;
715
+ const simplified = toolName
716
+ ? simplifyToolResult(toolName, toolUseId, !!isError, toolInput)
717
+ : null;
718
+ if (simplified !== null) {
719
+ state.accumulatedContent += `${simplified}\n`;
720
+ }
721
+ else {
722
+ const resultContent = block.content;
723
+ let resultStr = "";
724
+ if (typeof resultContent === "string") {
725
+ resultStr = resultContent;
726
+ }
727
+ else if (Array.isArray(resultContent)) {
728
+ resultStr = resultContent
729
+ .map((c) => c.text ?? "")
730
+ .join("");
731
+ }
732
+ else if (resultContent) {
733
+ resultStr = JSON.stringify(resultContent);
734
+ }
735
+ const shortResult = resultStr.length > 200 ? resultStr.slice(0, 200) + "..." : resultStr;
736
+ const icon = isError ? "❌" : "✅"; // ❌ : ✅
737
+ state.accumulatedContent +=
738
+ `${icon} *${toolUseId.slice(-6)}*: ${shortResult}\n`;
739
+ }
740
+ break;
741
+ }
742
+ case "redacted_thinking":
743
+ state.accumulatedContent += "\n\n⚠️ 内容被安全过滤\n"; // ⚠️
744
+ break;
745
+ case "search_result":
746
+ state.accumulatedContent +=
747
+ `\n\n🔍 联网搜索: **${block.query}**\n`; // 🔍
748
+ break;
749
+ case "text":
750
+ state.finalText += block.text;
751
+ // 新的增量文本到达时清空 finalCompleteText,确保 pickFinalReply 回退到
752
+ // finalText(累积文本)。否则 Cursor buffered flush 设置的旧
753
+ // finalCompleteText 会"吞掉"工具调用后新到达的增量文本。
754
+ state.finalCompleteText = "";
755
+ break;
756
+ case "text_final":
757
+ // 覆盖而非追加:适配器已保证这是一段完整最终文本(如 Cursor 流末快照)
758
+ state.finalCompleteText = block.text;
759
+ break;
760
+ case "compact_boundary": {
761
+ const triggerLabel = block.trigger === "manual" ? "手动" : "自动"; // 手动 / 自动
762
+ state.accumulatedContent +=
763
+ `\n\n🔄 上下文压缩(${triggerLabel}): **${block.pre_tokens}** → **${block.post_tokens}** tokens\n`; // 🔄 / →
764
+ break;
765
+ }
766
+ case "agent_status":
767
+ break;
768
+ }
769
+ }
770
+ export async function switchChatBinding(args) {
771
+ const { chatId, chatType, oldSessionId, newSessionId, tool, chatName, newDescription, initialTurnCount = 0, initialContextTokens = 0, updateChatInfoFn, } = args;
772
+ // Step 1: 群聊场景先调用飞书 API(不可逆操作放最前)。
773
+ // 私聊跳过——p2p chatId 调 updateChatInfo 必然失败。
774
+ if (chatType !== "p2p") {
775
+ try {
776
+ await updateChatInfoFn(chatId, chatName, newDescription);
777
+ }
778
+ catch (err) {
779
+ // API 失败:完全不动内存,调用方负责回报用户。
780
+ return { ok: false, error: err };
781
+ }
782
+ }
783
+ // Step 2: API 成功(或私聊跳过)后,原子地切换内存绑定。
784
+ // 这一段全是同步 Map 操作,不会失败。
785
+ if (oldSessionId) {
786
+ unbindChatFromSession(oldSessionId, chatId);
787
+ displayCards.delete(chatId);
788
+ cancelQueuedMessage(oldSessionId);
789
+ }
790
+ bindChatToSession(newSessionId, chatId);
791
+ recordLastActiveChat(newSessionId, chatId);
792
+ const now = Date.now();
793
+ sessionInfoMap.set(chatId, {
794
+ sessionId: newSessionId,
795
+ turnCount: initialTurnCount,
796
+ lastContextTokens: initialContextTokens,
797
+ startTime: now,
798
+ tool,
799
+ });
800
+ // Step 3: 持久化(registry + sessions.json)。
801
+ // 这两步即使失败也不影响内存正确性,下次 prompt 会再写一次。
802
+ await recordSessionRegistry({
803
+ chatId,
804
+ sessionId: newSessionId,
805
+ tool,
806
+ chatType,
807
+ chatName,
808
+ turnCount: initialTurnCount,
809
+ lastContextTokens: initialContextTokens,
810
+ startTime: now,
811
+ running: false,
812
+ });
813
+ await saveSessionTool(newSessionId, tool, chatName);
814
+ return { ok: true };
815
+ }
816
+ // ---------------------------------------------------------------------------
817
+ // AI tool session management
818
+ // ---------------------------------------------------------------------------
819
+ /**
820
+ * 日志用:把 tool 对应的"配置摘要"格式化为单行字符串。
821
+ * Claude 显示 model/effort(来自环境变量);Cursor 显示 model(运行时由
822
+ * cursor-agent 决定,初次创建时尚未学习到,故显示占位)。
823
+ */
824
+ function formatToolConfigForLog(tool, sessionModel, sessionId) {
825
+ if (tool === "cursor") {
826
+ return `model=${sessionModel ?? "(由 cursor-agent 决定,init 事件后学习)"}`;
827
+ }
828
+ if (tool === "codex") {
829
+ const m = getEffectiveModelForTool(tool, sessionId);
830
+ const e = getEffectiveEffortForTool(tool, sessionId);
831
+ const modelStr = m.trim() !== "" ? m : "(由 codex config.toml 决定)";
832
+ const effortStr = e.trim() !== ""
833
+ ? `effort=${e}`
834
+ : "effort=(由 codex config.toml 决定)";
835
+ return `model=${modelStr}, ${effortStr}, fast=${getEffectiveFastModeForTool(tool, sessionId) ? "on" : "off"}`;
836
+ }
837
+ if (tool === "ccc") {
838
+ const m = getEffectiveModelForTool(tool, sessionId);
839
+ const modelStr = m.trim() !== "" ? m : "(not configured)";
840
+ return `model=${modelStr}, baseURL=${config.ccc.DEEPSEEK_BASE_URL}`;
841
+ }
842
+ return `model=${anthropicConfigDisplay(getModelForSession(sessionId))}, subagentModel=${anthropicConfigDisplay(CLAUDE_SUBAGENT_MODEL)}, effort=${anthropicConfigDisplay(getEffectiveEffortForTool(tool, sessionId))}`;
843
+ }
844
+ export async function initClaudeSession(tool, overrideCwd, chatId) {
845
+ const cwd = overrideCwd ?? (await getDefaultCwd(chatId));
846
+ const adapter = getAdapterForTool(tool);
847
+ console.log(`[${ts()}] [STEP 1/5] Creating ${adapter.displayName} session (${formatToolConfigForLog(tool)}, cwd=${cwd})`);
848
+ // Claude/Cursor 创建会话时需要先等待 SDK/CLI 的 init 事件。它们若在首个
849
+ // 事件前卡死,正式 turn 尚未建立,runAgentSession 的看门狗无法介入。
850
+ // 因此创建入口也使用相同的三分钟阈值,并通过 AbortSignal 释放底层资源。
851
+ const createController = new AbortController();
852
+ let createTimeout;
853
+ const timeoutError = new Error(`${adapter.displayName} session creation timed out after 3 minutes without an init event`);
854
+ const timeoutPromise = new Promise((_resolve, reject) => {
855
+ createTimeout = setTimeout(() => {
856
+ // 先固定对外错误,再 abort 适配器,避免适配器自己的 abort 错误赢得竞态。
857
+ reject(timeoutError);
858
+ createController.abort();
859
+ }, responseStallTimeoutMs);
860
+ createTimeout.unref?.();
861
+ });
862
+ let result;
863
+ try {
864
+ result = await Promise.race([
865
+ adapter.createSession(cwd, createController.signal),
866
+ timeoutPromise,
867
+ ]);
868
+ }
869
+ finally {
870
+ if (createTimeout)
871
+ clearTimeout(createTimeout);
872
+ }
873
+ const sessionId = result.sessionId;
874
+ console.log(`[${ts()}] → sessionId: ${sessionId}`);
875
+ await saveSessionTool(sessionId, tool);
876
+ await addRecentDir(cwd);
877
+ return { sessionId, cwd };
878
+ }
879
+ export async function resumeAndPrompt(sessionId, userText, platform, chatId, msgTimestamp, tool, traceId) {
880
+ return runAgentSession(sessionId, userText, platform, chatId, msgTimestamp, tool, traceId);
881
+ }
882
+ export async function runAgentSession(sessionId, userText, platform, _chatId, msgTimestamp, tool, traceId, options = {}) {
883
+ const tid = traceId ?? "";
884
+ // runAgentSession 是飞书用户消息、队列消息与内部自动恢复共同使用的唯一
885
+ // prompt 执行入口。即使冷启动后的历史群只靠群描述解析出 sessionId、registry
886
+ // 尚未重建出内存映射,也必须在任何异步操作前补齐绑定,确保三种来源都有完全
887
+ // 相同的卡片、状态和收尾行为。
888
+ const previousSessionId = sessionInfoMap.get(_chatId)?.sessionId;
889
+ if (previousSessionId && previousSessionId !== sessionId) {
890
+ unbindChatFromSession(previousSessionId, _chatId);
891
+ }
892
+ bindChatToSession(sessionId, _chatId);
893
+ // 记录用户最后发送消息的群(display loop 只推送到该群)
894
+ // 如果是从队列消费且队列消息来自其他群,保留原来的 display chat
895
+ recordChatPlatform(_chatId, platform);
896
+ recordLastActiveChat(sessionId, consumeQueuePreservedChat(sessionId) ?? _chatId);
897
+ // 并发检查:同一 session 只能有一个活跃 prompt
898
+ if (activePrompts.has(sessionId)) {
899
+ if (tid)
900
+ logTrace(tid, "BLOCKED", { outcome: "session_busy", sessionId });
901
+ console.log(`[${ts()}] [BLOCKED] Session ${sessionId} is already generating`);
902
+ const isWechatBusy = platform.kind === "wechat";
903
+ const busyMsg = isWechatBusy
904
+ ? "当前正在生成回复中,请等待完成后再发送消息。也可以发送 /stop 结束,已完成的步骤不会丢失。"
905
+ : "该会话正在生成回复中,请等待完成后再发送消息。也可以发送 /stop 结束,已完成的步骤不会丢失。";
906
+ await platform.sendText(_chatId, busyMsg).catch(() => { });
907
+ return "busy";
908
+ }
909
+ // 立即标记活跃,确保 /sessions、isSessionRunning 等查询在异步准备阶段就能看到运行状态。
910
+ // 注意:下面的 try/catch 在准备失败时会清理 activePrompts。
911
+ const controller = new AbortController();
912
+ const now = Date.now();
913
+ activePrompts.set(sessionId, {
914
+ controller,
915
+ stopped: false,
916
+ startTime: now,
917
+ autoRecovery: options.autoRecovery === true,
918
+ finalResponseObserved: false,
919
+ });
920
+ // 资源监控僵死检测:CPU + 内存连续 3 分钟无变化 → 强制停止
921
+ const onResourceStuck = (data) => {
922
+ if (data.sessionId !== sessionId)
923
+ return;
924
+ const prompt = activePrompts.get(sessionId);
925
+ if (!prompt || prompt.stopped || prompt.abnormalExit || prompt.resourceStuck || prompt.autoEnded)
926
+ return;
927
+ prompt.resourceStuck = true;
928
+ const chatId = pickDisplayChat(sessionId) ?? getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
929
+ const p = chatId ? platformForChat(chatId) : null;
930
+ if (chatId && p) {
931
+ p.sendText(chatId, `⚠️ 会话僵死:session ${sessionId.slice(0, 8)} 对应的 CLI 进程 PID ${data.pid} CPU 和内存连续 ${data.idleMinutes} 分钟无变化,已强制停止。若回复不完整,请重新发送上一条指令。`).catch(() => { });
932
+ }
933
+ controller.abort();
934
+ };
935
+ resourceMonitor.on("stuck", onResourceStuck);
936
+ // 异步准备工作(session info、IM skills prompt 等)
937
+ let adapter;
938
+ let info;
939
+ let cwd;
940
+ try {
941
+ adapter = getAdapterForTool(tool, sessionId);
942
+ info = await adapter.getSessionInfo(sessionId);
943
+ cwd = info?.cwd ?? (await getDefaultCwd(_chatId));
944
+ if (tid)
945
+ logTrace(tid, "SESSION_START", { sessionId, tool, cwd, turn: (sessionInfoMap.get(_chatId)?.turnCount ?? 0) + 1 });
946
+ console.log(`[${ts()}] Running ${adapter.displayName} session: ${sessionId} (${formatToolConfigForLog(tool, info?.model, sessionId)}, cwd=${cwd})`);
947
+ // 构建 IM skills prompt(sessionId 方式,无 token)
948
+ const feishuSkillDir = join(PROJECT_ROOT, "im-skills", "feishu-skill");
949
+ const wechatImageSkillDir = join(PROJECT_ROOT, "im-skills", "wechat-image-skill");
950
+ const wechatFileSkillDir = join(PROJECT_ROOT, "im-skills", "wechat-file-skill");
951
+ const wechatVideoSkillDir = join(PROJECT_ROOT, "im-skills", "wechat-video-skill");
952
+ const imSkillsCacheDir = join(USER_DATA_DIR, "im-skills");
953
+ const skillVariables = {
954
+ cwd,
955
+ session_id: sessionId,
956
+ im_skills_cache_dir: imSkillsCacheDir,
957
+ delegate_task_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/delegate-task`,
958
+ send_image_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-image`,
959
+ send_file_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-file`,
960
+ send_image_script: join(feishuSkillDir, "send-image.mjs"),
961
+ send_file_script: join(feishuSkillDir, "send-file.mjs"),
962
+ download_video_script: join(feishuSkillDir, "download-video.mjs"),
963
+ wechat_send_image_script: join(wechatImageSkillDir, "send-image.mjs"),
964
+ wechat_send_file_script: join(wechatFileSkillDir, "send-file.mjs"),
965
+ wechat_send_video_script: join(wechatVideoSkillDir, "send-video.mjs"),
966
+ };
967
+ const enabledSkillNames = imSkillNamesForPlatform(platform);
968
+ var imSkillsPrompt = await buildImSkillsPromptCached({ variables: skillVariables, enabledSkillNames });
969
+ await exportSkillSubDocs({ variables: skillVariables, enabledSkillNames }, imSkillsCacheDir);
970
+ var userTextWithCapabilities = [
971
+ ...(imSkillsPrompt ? [imSkillsPrompt, ""] : []),
972
+ "[User message]",
973
+ userText,
974
+ "[/User message]",
975
+ ].join("\n");
976
+ }
977
+ catch (preambleErr) {
978
+ // 准备工作失败,清理活跃标记,避免"僵尸"活跃状态阻塞后续消息
979
+ activePrompts.delete(sessionId);
980
+ throw preambleErr;
981
+ }
982
+ // 更新 sessionInfoMap(所有绑定群共用)
983
+ const existingInfo = sessionInfoMap.get(_chatId);
984
+ const nextTurnCount = (existingInfo?.turnCount ?? 0) + 1;
985
+ const nextContextTokens = existingInfo?.lastContextTokens ?? 0;
986
+ // 对所有绑定的 chatId 更新 sessionInfoMap
987
+ for (const cid of getChatsForSession(sessionId)) {
988
+ const ei = sessionInfoMap.get(cid);
989
+ sessionInfoMap.set(cid, {
990
+ sessionId,
991
+ turnCount: nextTurnCount,
992
+ lastContextTokens: nextContextTokens,
993
+ startTime: now,
994
+ tool,
995
+ });
996
+ }
997
+ // 确保触发群也在 map 中
998
+ if (!sessionInfoMap.has(_chatId)) {
999
+ sessionInfoMap.set(_chatId, {
1000
+ sessionId,
1001
+ turnCount: nextTurnCount,
1002
+ lastContextTokens: nextContextTokens,
1003
+ startTime: now,
1004
+ tool,
1005
+ });
1006
+ }
1007
+ await recordSessionRegistry({
1008
+ chatId: _chatId,
1009
+ sessionId,
1010
+ tool,
1011
+ turnCount: nextTurnCount,
1012
+ lastContextTokens: nextContextTokens,
1013
+ startTime: now,
1014
+ running: true,
1015
+ });
1016
+ // 在覆盖 stream state 前,先终结上一轮的展示卡片并发送最终回复。
1017
+ // 竞态根因:上一轮 finally 写入 "done" 状态后,200ms setTimeout 即启动
1018
+ // 新一轮 runAgentSession,立即覆盖为 "running" 状态并 kill 旧 display
1019
+ // loop。旧 loop 的 3s 间隔 tick 只有约 6.7% 概率在 200ms 窗口内命中,
1020
+ // 导致 finalReply 丢失、完成卡片空白。此处主动读取上一轮终端状态完成
1021
+ // 卡片终结和回复发送,不依赖 display loop 时序,保证"先发完上一个回答
1022
+ // 再开始缓存问题对应的任务"。
1023
+ const prevState = await readStreamState(sessionId);
1024
+ if (prevState && prevState.status !== "running") {
1025
+ const prevTerminalReply = formatTerminalReply(prevState.status, prevState.finalReply, prevState.terminalError);
1026
+ const displayChatId = pickDisplayChat(sessionId);
1027
+ if (displayChatId) {
1028
+ const pp = platformForChat(displayChatId);
1029
+ const display = displayCards.get(displayChatId);
1030
+ if (display && pp) {
1031
+ // 统一 display loop 被 cardBusy 挡住或尚未 tick → 现在终结卡片
1032
+ while (display.cardBusy)
1033
+ await new Promise(r => setTimeout(r, 20));
1034
+ // 竞态防护:等待期间统一 display loop 的 tick 可能也已读到同一个
1035
+ // terminal state,发送了 finalReply 并删除/替换了 displayCards 条目。
1036
+ // 通过引用比较检测——若统一 loop 已处理则只补持久化和头像,不重复发。
1037
+ if (displayCards.get(displayChatId) !== display) {
1038
+ const finalStatus = turnFinalStatus(prevState.status);
1039
+ finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => { });
1040
+ setSessionChatAvatar(pp, displayChatId, prevState.tool, "idle", sessionId).catch(() => { });
1041
+ }
1042
+ else {
1043
+ const nextSeq = display.sequence + 1;
1044
+ const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(prevState.status, prevState.terminalError);
1045
+ const cardContent = truncateContent(formatTerminalCardContent(prevState)) || " ";
1046
+ const doneCard = buildProgressCard(progressView({ text: cardContent, status: "done", showStop: false, headerTitle, headerTemplate }));
1047
+ await pp.cardUpdate(display.cardId, doneCard, nextSeq).catch(err => {
1048
+ console.error(`[${ts()}] [DISPLAY] prevState final cardUpdate failed: ${err.message}`);
1049
+ });
1050
+ // cardUpdate IO 期间统一 loop 可能也已处理此 display → 删前检查引用
1051
+ const stillOursAfterUpdate = displayCards.get(displayChatId) === display;
1052
+ displayCards.delete(displayChatId);
1053
+ // 持久化:标记上一轮所有卡片为终态
1054
+ const finalStatus = turnFinalStatus(prevState.status);
1055
+ finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => { });
1056
+ if (prevTerminalReply && stillOursAfterUpdate && !isFinalReplySentForTurn(prevState)) {
1057
+ await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevTerminalReply);
1058
+ }
1059
+ setSessionChatAvatar(pp, displayChatId, prevState.tool, "idle", sessionId).catch(() => { });
1060
+ }
1061
+ }
1062
+ else if (pp && prevTerminalReply && !isFinalReplySentForTurn(prevState)) {
1063
+ // 无 display 记录但上一轮有 finalReply(极快轮次),至少发送
1064
+ const finalStatus = turnFinalStatus(prevState.status);
1065
+ finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => { });
1066
+ await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevTerminalReply);
1067
+ }
1068
+ // else: displayCards 无记录且无 finalReply → 无需处理
1069
+ }
1070
+ }
1071
+ // 初始化 stream-state.json
1072
+ const initialState = createEmptyStreamState(sessionId, cwd, tool, nextTurnCount);
1073
+ const activityTracker = createAgentActivityTracker(initialState.activity?.startedAt ?? Date.now());
1074
+ await writeStreamState(initialState);
1075
+ // 为新 turn 创建第一张展示卡片,同时注册到 turn-cards 持久化。
1076
+ // 统一 display loop 始终运行,卡片创建后下一个 tick 即自动开始更新。
1077
+ const displayChatIdForNew = pickDisplayChat(sessionId);
1078
+ if (displayChatIdForNew) {
1079
+ const ppNew = platformForChat(displayChatIdForNew);
1080
+ if (ppNew && ppNew.kind !== "wechat") {
1081
+ const initialHeaderTitle = formatAgentActivityTitle(activityTracker.activity);
1082
+ const cardId = await createVisibleProgressCard(ppNew, displayChatIdForNew, sessionId, nextTurnCount, "生成中卡片发送失败,结果将以文本形式发送。", initialHeaderTitle);
1083
+ if (cardId) {
1084
+ displayCards.set(displayChatIdForNew, {
1085
+ cardId,
1086
+ sequence: 1,
1087
+ cardBusy: false,
1088
+ cardCreatedAt: Date.now(),
1089
+ lastSentContent: "",
1090
+ lastSentHeaderTitle: initialHeaderTitle,
1091
+ streamErrorNotified: false,
1092
+ sessionId,
1093
+ turnCount: nextTurnCount,
1094
+ dotCount: 0,
1095
+ });
1096
+ }
1097
+ }
1098
+ else if (ppNew && ppNew.kind === "wechat") {
1099
+ // WeChat: 无卡片,但需要 display entry 追踪已发送内容
1100
+ displayCards.set(displayChatIdForNew, {
1101
+ cardId: "",
1102
+ sequence: 0,
1103
+ cardBusy: false,
1104
+ cardCreatedAt: Date.now(),
1105
+ lastSentContent: "",
1106
+ streamErrorNotified: false,
1107
+ sessionId,
1108
+ turnCount: nextTurnCount,
1109
+ dotCount: 0,
1110
+ });
1111
+ }
1112
+ }
1113
+ // 设置最后活跃群头像为 busy
1114
+ refreshBusySessionAvatar(sessionId, tool, platform).catch(() => { });
1115
+ const state = {
1116
+ accumulatedContent: "",
1117
+ finalText: "",
1118
+ finalCompleteText: "",
1119
+ chunkCount: 0,
1120
+ };
1121
+ let lastFileWrite = Date.now();
1122
+ const FILE_WRITE_INTERVAL_MS = 2000;
1123
+ const toolCallMap = new Map();
1124
+ let streamErrored = false;
1125
+ let streamTerminalError;
1126
+ let runOutcome = "error";
1127
+ const responseStallDetectionEnabled = adapter.responseStallDetectionEnabled !== false;
1128
+ const runningPrompt = activePrompts.get(sessionId);
1129
+ if (runningPrompt) {
1130
+ startPromptAvatarRefresh(sessionId, tool, platform, runningPrompt);
1131
+ // 在消费第一个事件前建立阶段感知的零字符基线;启动阶段不计时,只有后续
1132
+ // 收到明确的 responding 状态后才会启动三分钟回复停滞保护。
1133
+ runningPrompt.responseProgress = observeResponseProgress(undefined, monitorsOutputProgress(activityTracker.activity.kind), 0, activityTracker.activity.startedAt);
1134
+ const checkResponseStall = async () => {
1135
+ const current = activePrompts.get(sessionId);
1136
+ if (!current || current !== runningPrompt) {
1137
+ clearPromptResponseStallMonitor(sessionId);
1138
+ return;
1139
+ }
1140
+ if (current.stopped
1141
+ || current.abnormalExit
1142
+ || current.resourceStuck
1143
+ || current.autoEnded
1144
+ || current.finalResponseObserved
1145
+ || !monitorsOutputProgress(activityTracker.activity.kind)
1146
+ || !hasResponseStalled(current.responseProgress, Date.now(), responseStallTimeoutMs)) {
1147
+ return;
1148
+ }
1149
+ const autoEndedAt = Date.now();
1150
+ // 普通轮第一次因回复停滞结束时,立即预约同 session 的内部续跑。
1151
+ // 预约先于 abort/收尾建立,isSessionRunning 会在整个交接窗口保持 true,
1152
+ // 因此恰好到达的用户消息只能排队,绝不可能抢在恢复 prompt 前。
1153
+ // 若当前已经是恢复轮,则不再预约第三轮。
1154
+ if (!current.autoRecovery) {
1155
+ reserveAutoRecovery(sessionId);
1156
+ }
1157
+ current.autoEnded = true;
1158
+ current.autoEndedAt = autoEndedAt;
1159
+ clearPromptResponseStallMonitor(sessionId);
1160
+ clearPromptProcessMonitor(sessionId);
1161
+ // First publish an atomic terminal state so the card cannot keep claiming the
1162
+ // Agent is running while process cleanup is underway.
1163
+ await writeStreamState({
1164
+ sessionId,
1165
+ status: "auto_ended",
1166
+ accumulatedContent: state.accumulatedContent,
1167
+ finalReply: pickFinalReply(state).trim(),
1168
+ activity: activityTracker.activity,
1169
+ chunkCount: state.chunkCount,
1170
+ turnCount: nextTurnCount,
1171
+ contextTokens: existingInfo?.lastContextTokens ?? 0,
1172
+ updatedAt: autoEndedAt,
1173
+ cwd,
1174
+ tool,
1175
+ autoEndedAt,
1176
+ });
1177
+ // 最终事件可能在上面的落盘 I/O 期间到达。只有适配器明确标记的完整
1178
+ // final response 才能赢得这场竞态;普通文本片段绝不能取消超时。
1179
+ if (current.finalResponseObserved) {
1180
+ current.autoEnded = false;
1181
+ current.autoEndedAt = undefined;
1182
+ cancelAutoRecoveryReservation(sessionId);
1183
+ console.log(`[${ts()}] [RESPONSE-STALL] Authoritative final response won timeout race for ${sessionId}`);
1184
+ return;
1185
+ }
1186
+ try {
1187
+ current.closeSession?.();
1188
+ }
1189
+ catch (err) {
1190
+ console.warn(`[${ts()}] [RESPONSE-STALL] closeSession failed for ${sessionId}: ${err.message}`);
1191
+ }
1192
+ current.controller.abort();
1193
+ await killProcessTree(current.processPid);
1194
+ console.warn(`[${ts()}] [RESPONSE-STALL] Session ${sessionId} auto-ended after 3 minutes without reply progress`);
1195
+ };
1196
+ if (responseStallDetectionEnabled) {
1197
+ const responseStallMonitor = setInterval(() => {
1198
+ void checkResponseStall().catch((err) => {
1199
+ console.warn(`[${ts()}] [RESPONSE-STALL] check failed for ${sessionId}: ${err.message}`);
1200
+ });
1201
+ }, responseStallCheckIntervalMs);
1202
+ responseStallMonitor.unref?.();
1203
+ runningPrompt.responseStallMonitor = responseStallMonitor;
1204
+ }
1205
+ }
1206
+ try {
1207
+ for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal, {
1208
+ onProcessStart: (processInfo) => {
1209
+ startPromptProcessMonitor(sessionId, processInfo);
1210
+ if (processInfo.pid !== undefined)
1211
+ registerProcess(processInfo.pid, sessionId);
1212
+ },
1213
+ onProcessExit: (exitInfo) => {
1214
+ clearPromptProcessMonitor(sessionId);
1215
+ if (exitInfo.pid !== undefined)
1216
+ unregisterProcess(exitInfo.pid);
1217
+ },
1218
+ onSessionCreated: (closeSession) => {
1219
+ const prompt = activePrompts.get(sessionId);
1220
+ if (prompt)
1221
+ prompt.closeSession = closeSession;
1222
+ },
1223
+ })) {
1224
+ if (unifiedMsg.isFinalResponse) {
1225
+ const prompt = activePrompts.get(sessionId);
1226
+ if (prompt && prompt === runningPrompt) {
1227
+ // 同步标记必须发生在任何 await 之前,让 watchdog 无法在已收到完整
1228
+ // 最终事件后仍把本轮判为停滞。
1229
+ if (!prompt.finalResponseObserved) {
1230
+ prompt.finalResponseObserved = true;
1231
+ scheduleFinalResponseCloseGuard(sessionId, prompt);
1232
+ }
1233
+ }
1234
+ }
1235
+ let activityChanged = false;
1236
+ for (const block of unifiedMsg.blocks) {
1237
+ if (updateAgentActivity(activityTracker, block))
1238
+ activityChanged = true;
1239
+ accumulateBlockContent(block, state, toolCallMap);
1240
+ if (block.type === "compact_boundary" && block.post_tokens) {
1241
+ for (const cid of getChatsForSession(sessionId)) {
1242
+ const sinfo = sessionInfoMap.get(cid);
1243
+ if (sinfo)
1244
+ sinfo.lastContextTokens = block.post_tokens;
1245
+ }
1246
+ await recordSessionRegistry({
1247
+ chatId: _chatId,
1248
+ sessionId,
1249
+ tool,
1250
+ lastContextTokens: block.post_tokens,
1251
+ running: true,
1252
+ });
1253
+ }
1254
+ }
1255
+ const prompt = activePrompts.get(sessionId);
1256
+ if (prompt && !prompt.autoEnded) {
1257
+ const totalChars = state.accumulatedContent.length + pickFinalReply(state).length;
1258
+ prompt.responseProgress = observeResponseProgress(
1259
+ // starting → responding 本身是一次有效进展,即便首个文本块仍为空也应
1260
+ // 重新计时;其它活动阶段会清空观察窗口。
1261
+ activityChanged ? undefined : prompt.responseProgress, monitorsOutputProgress(activityTracker.activity.kind), totalChars, Date.now());
1262
+ }
1263
+ // 定时写入文件
1264
+ const now2 = Date.now();
1265
+ if (activityChanged || now2 - lastFileWrite >= FILE_WRITE_INTERVAL_MS) {
1266
+ lastFileWrite = now2;
1267
+ await writeStreamState({
1268
+ sessionId,
1269
+ status: "running",
1270
+ accumulatedContent: state.accumulatedContent,
1271
+ finalReply: pickFinalReply(state),
1272
+ activity: activityTracker.activity,
1273
+ chunkCount: state.chunkCount,
1274
+ turnCount: nextTurnCount,
1275
+ contextTokens: existingInfo?.lastContextTokens ?? 0,
1276
+ updatedAt: now2,
1277
+ cwd,
1278
+ tool,
1279
+ });
1280
+ }
1281
+ }
1282
+ }
1283
+ catch (streamErr) {
1284
+ streamErrored = true;
1285
+ streamTerminalError = classifyTerminalError(streamErr);
1286
+ console.error(`[${ts()}] [STREAM] Error in stream loop for ${sessionId}: ${streamErr.message}`);
1287
+ }
1288
+ finally {
1289
+ // 标记 prompt 结束
1290
+ resourceMonitor.off("stuck", onResourceStuck);
1291
+ const prompt = activePrompts.get(sessionId);
1292
+ const wasStopped = prompt?.stopped ?? false;
1293
+ const wasAbnormalExit = prompt?.abnormalExit ?? false;
1294
+ const wasResourceStuck = prompt?.resourceStuck ?? false;
1295
+ const timeoutTriggered = prompt?.autoEnded ?? false;
1296
+ const completedAtTimeoutBoundary = timeoutTriggered && (prompt?.finalResponseObserved ?? false);
1297
+ const wasAutoEnded = timeoutTriggered && !completedAtTimeoutBoundary;
1298
+ const wasAutoRecovery = prompt?.autoRecovery ?? false;
1299
+ const autoEndedAt = wasAutoEnded ? prompt?.autoEndedAt : undefined;
1300
+ clearPromptResponseStallMonitor(sessionId);
1301
+ clearPromptProcessMonitor(sessionId);
1302
+ clearPromptAvatarRefreshTimer(sessionId);
1303
+ clearPromptFinalResponseCloseTimer(sessionId);
1304
+ markSessionFinalizing(sessionId);
1305
+ activePrompts.delete(sessionId);
1306
+ try {
1307
+ if (completedAtTimeoutBoundary) {
1308
+ // reservation 可能在 watchdog 开始终止普通轮时已经建立。最终回复若在
1309
+ // abort/kill 清理边界到达,本轮按完成处理,并原子取消尚未启动的恢复轮。
1310
+ cancelAutoRecoveryReservation(sessionId);
1311
+ console.log(`[${ts()}] [RESPONSE-STALL] Session ${sessionId} completed with an authoritative final response during timeout cleanup`);
1312
+ }
1313
+ // 即使运行期间映射被异常清空,也必须更新本次实际触发 chat,避免 registry
1314
+ // 永久残留 running=true。
1315
+ const finalizationChatIds = [...new Set([
1316
+ ...getChatsForSession(sessionId),
1317
+ _chatId,
1318
+ ])];
1319
+ // 先写最终状态(done/stopped),确保 display loop 在下一轮消费前
1320
+ // 读到新状态并终结旧卡片。否则 setImmediate 在 CHECK 阶段先于
1321
+ // writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
1322
+ // 运行中并更新旧卡片,而不是新建卡片。
1323
+ const finalStatus = completedAtTimeoutBoundary
1324
+ ? "done"
1325
+ : wasAutoEnded
1326
+ ? "auto_ended"
1327
+ : (streamErrored || wasAbnormalExit || wasResourceStuck)
1328
+ ? "error"
1329
+ : wasStopped
1330
+ ? "stopped"
1331
+ : "done";
1332
+ const finalReply = pickFinalReply(state).trim();
1333
+ const terminalError = streamTerminalError
1334
+ ?? (wasAbnormalExit
1335
+ ? {
1336
+ kind: "process",
1337
+ title: "Agent 进程意外退出",
1338
+ message: "Agent CLI 进程已退出。若回复不完整,请重新发送上一条指令。",
1339
+ occurredAt: Date.now(),
1340
+ }
1341
+ : wasResourceStuck
1342
+ ? {
1343
+ kind: "resource",
1344
+ title: "Agent 进程失去响应",
1345
+ message: "Agent 进程长时间没有 CPU 或内存变化,已被强制停止。若回复不完整,请重新发送上一条指令。",
1346
+ occurredAt: Date.now(),
1347
+ }
1348
+ : undefined);
1349
+ runOutcome = finalStatus;
1350
+ // stop-stuck-loop 接口可能在 fire-and-forget 中已写入带 final_reply 的
1351
+ // stream state,finally 不应覆盖它。同时保留 stuckAt 标记,防止
1352
+ // stop-stuck-loop 结束后 session 被错误恢复。
1353
+ let finalReplyToWrite = finalReply;
1354
+ let preserveStuckAt;
1355
+ try {
1356
+ const existing = await readStreamState(sessionId);
1357
+ if (existing) {
1358
+ if (existing.finalReply.length > finalReply.length) {
1359
+ finalReplyToWrite = existing.finalReply;
1360
+ }
1361
+ preserveStuckAt = existing.stuckAt;
1362
+ }
1363
+ }
1364
+ catch { }
1365
+ await writeStreamState({
1366
+ sessionId,
1367
+ status: finalStatus,
1368
+ accumulatedContent: state.accumulatedContent,
1369
+ finalReply: finalReplyToWrite,
1370
+ activity: activityTracker.activity,
1371
+ chunkCount: state.chunkCount,
1372
+ turnCount: nextTurnCount,
1373
+ contextTokens: existingInfo?.lastContextTokens ?? 0,
1374
+ updatedAt: Date.now(),
1375
+ cwd,
1376
+ tool,
1377
+ ...(preserveStuckAt ? { stuckAt: preserveStuckAt } : {}),
1378
+ ...(autoEndedAt !== undefined ? { autoEndedAt } : {}),
1379
+ ...(terminalError ? { terminalError } : {}),
1380
+ });
1381
+ // display loop 下一轮会读到最终状态并发送消息
1382
+ let autoRecoveryTarget;
1383
+ if (wasStopped) {
1384
+ for (const cid of finalizationChatIds) {
1385
+ const finfo = sessionInfoMap.get(cid);
1386
+ await recordSessionRegistry({
1387
+ chatId: cid,
1388
+ sessionId,
1389
+ tool,
1390
+ turnCount: finfo?.turnCount ?? nextTurnCount,
1391
+ lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
1392
+ startTime: finfo?.startTime ?? now,
1393
+ running: false,
1394
+ });
1395
+ }
1396
+ const active1 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1397
+ if (active1) {
1398
+ await platform.sendText(active1, "会话已停止。").catch(() => { });
1399
+ setSessionChatAvatar(platform, active1, tool, "idle", sessionId).catch(() => { });
1400
+ }
1401
+ console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
1402
+ if (tid)
1403
+ logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
1404
+ }
1405
+ else if (wasAutoEnded) {
1406
+ for (const cid of finalizationChatIds) {
1407
+ const finfo = sessionInfoMap.get(cid);
1408
+ await recordSessionRegistry({
1409
+ chatId: cid,
1410
+ sessionId,
1411
+ tool,
1412
+ turnCount: finfo?.turnCount ?? nextTurnCount,
1413
+ lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
1414
+ startTime: finfo?.startTime ?? now,
1415
+ running: false,
1416
+ });
1417
+ }
1418
+ const activeAutoEnded = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1419
+ if (activeAutoEnded) {
1420
+ const pp = platformForChat(activeAutoEnded) ?? platform;
1421
+ const terminalState = await readStreamState(sessionId);
1422
+ if (!displayCards.has(activeAutoEnded) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
1423
+ await sendFinalReplyTextOnce(pp, activeAutoEnded, sessionId, nextTurnCount, formatAutoEndedReply(finalReplyToWrite));
1424
+ }
1425
+ setSessionChatAvatar(pp, activeAutoEnded, tool, "idle", sessionId).catch(() => { });
1426
+ if (wasAutoRecovery) {
1427
+ // 这是紧接第一次停滞而启动的恢复轮;再次发生相同停滞即终止
1428
+ // 自动链,避免第三轮及之后的无限续跑。
1429
+ await pp.sendText(activeAutoEnded, RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE).catch(() => { });
1430
+ }
1431
+ else if (hasAutoRecoveryReservation(sessionId)) {
1432
+ // 用户可见提示与内部恢复 prompt 分离:提示发送失败不影响恢复,
1433
+ // 内部恢复仍由 reservation 保证先于普通缓存消息。
1434
+ await pp.sendText(activeAutoEnded, RESPONSE_STALL_RECOVERY_NOTICE).catch(() => { });
1435
+ autoRecoveryTarget = { chatId: activeAutoEnded, platform: pp };
1436
+ }
1437
+ }
1438
+ console.warn(`[${ts()}] Session ${sessionId} auto-ended after stalled response output (content chunks: ${state.chunkCount})`);
1439
+ if (tid)
1440
+ logTrace(tid, "SESSION_END", { sessionId, outcome: "response_stall", chunks: state.chunkCount });
1441
+ }
1442
+ else if (wasAbnormalExit) {
1443
+ for (const cid of finalizationChatIds) {
1444
+ const finfo = sessionInfoMap.get(cid);
1445
+ await recordSessionRegistry({
1446
+ chatId: cid,
1447
+ sessionId,
1448
+ tool,
1449
+ turnCount: finfo?.turnCount ?? nextTurnCount,
1450
+ lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
1451
+ startTime: finfo?.startTime ?? now,
1452
+ running: false,
1453
+ });
1454
+ }
1455
+ const activeErr = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1456
+ if (activeErr)
1457
+ setSessionChatAvatar(platform, activeErr, tool, "idle", sessionId).catch(() => { });
1458
+ console.log(`[${ts()}] Session ${sessionId} process exited unexpectedly (content chunks: ${state.chunkCount})`);
1459
+ if (tid)
1460
+ logTrace(tid, "SESSION_END", { sessionId, outcome: "process_missing", chunks: state.chunkCount });
1461
+ }
1462
+ else if (streamErrored || wasResourceStuck) {
1463
+ for (const cid of finalizationChatIds) {
1464
+ const finfo = sessionInfoMap.get(cid);
1465
+ await recordSessionRegistry({
1466
+ chatId: cid,
1467
+ sessionId,
1468
+ tool,
1469
+ turnCount: finfo?.turnCount ?? nextTurnCount,
1470
+ lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
1471
+ startTime: finfo?.startTime ?? now,
1472
+ running: false,
1473
+ });
1474
+ }
1475
+ const activeErr = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1476
+ if (activeErr) {
1477
+ const pp = platformForChat(activeErr) ?? platform;
1478
+ const terminalState = await readStreamState(sessionId);
1479
+ if (terminalError
1480
+ && !displayCards.has(activeErr)
1481
+ && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
1482
+ await sendFinalReplyTextOnce(pp, activeErr, sessionId, nextTurnCount, formatTerminalErrorNotice(terminalError, finalReplyToWrite));
1483
+ }
1484
+ setSessionChatAvatar(pp, activeErr, tool, "idle", sessionId).catch(() => { });
1485
+ }
1486
+ const errorOutcome = wasResourceStuck ? "resource_stuck" : "stream_error";
1487
+ console.error(`[${ts()}] Session ${sessionId} ended with ${errorOutcome}` +
1488
+ `${terminalError ? ` (${terminalError.kind}: ${terminalError.title})` : ""} (content chunks: ${state.chunkCount})`);
1489
+ if (tid) {
1490
+ logTrace(tid, "SESSION_END", {
1491
+ sessionId,
1492
+ outcome: errorOutcome,
1493
+ errorKind: terminalError?.kind,
1494
+ errorTitle: terminalError?.title,
1495
+ chunks: state.chunkCount,
1496
+ });
1497
+ }
1498
+ }
1499
+ else {
1500
+ for (const cid of finalizationChatIds) {
1501
+ const finfo = sessionInfoMap.get(cid);
1502
+ await recordSessionRegistry({
1503
+ chatId: cid,
1504
+ sessionId,
1505
+ tool,
1506
+ turnCount: finfo?.turnCount ?? nextTurnCount,
1507
+ lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
1508
+ startTime: finfo?.startTime ?? now,
1509
+ running: false,
1510
+ });
1511
+ }
1512
+ const active2 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1513
+ if (active2) {
1514
+ const terminalState = await readStreamState(sessionId);
1515
+ if (finalReply && !displayCards.has(active2) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
1516
+ const pp = platformForChat(active2) ?? platform;
1517
+ await sendFinalReplyTextOnce(pp, active2, sessionId, nextTurnCount, finalReply);
1518
+ }
1519
+ setSessionChatAvatar(platform, active2, tool, "idle", sessionId).catch(() => { });
1520
+ }
1521
+ console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
1522
+ if (tid)
1523
+ logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
1524
+ }
1525
+ // 失去聊天绑定时无法安全选择恢复轮的展示目标,取消本次进程内预约。
1526
+ if (wasAutoEnded
1527
+ && hasAutoRecoveryReservation(sessionId)
1528
+ && !autoRecoveryTarget) {
1529
+ cancelAutoRecoveryReservation(sessionId);
1530
+ }
1531
+ const shouldScheduleAutoRecovery = autoRecoveryTarget !== undefined
1532
+ && hasAutoRecoveryReservation(sessionId);
1533
+ // 必须等本轮最终卡片、registry 和头像全部收尾后再取出并调度队列消息。
1534
+ // 在收尾期间新到达的消息也会因 finalizingSessions 被正确排入这里。
1535
+ let queuedForConsumption = undefined;
1536
+ if (wasStopped) {
1537
+ const discarded = dequeueMessage(sessionId);
1538
+ if (discarded) {
1539
+ console.log(`[${ts()}] [QUEUE] Discarding queued message for stopped session ${sessionId}`);
1540
+ }
1541
+ }
1542
+ else if (!shouldScheduleAutoRecovery) {
1543
+ // 第一次 response-stall 后保留普通缓存;恢复轮结束后由恢复轮的
1544
+ // finally 再消费,顺序固定为“自动恢复 → 用户缓存”。
1545
+ queuedForConsumption = dequeueMessage(sessionId);
1546
+ }
1547
+ if (queuedForConsumption) {
1548
+ const queued = queuedForConsumption;
1549
+ // 队列消息可能来自其他群,保存当前 display chat 避免 display loop 被
1550
+ // 错误重定向(runAgentSession 会 consumeQueuePreservedChat 并在存在时
1551
+ // 用保存的 chat 替代 queued.chatId 作为 display 目标)。
1552
+ const preservedChat = getLastActiveChat(sessionId);
1553
+ if (preservedChat && preservedChat !== queued.chatId) {
1554
+ setQueuePreservedChat(sessionId, preservedChat);
1555
+ }
1556
+ console.log(`[${ts()}] [QUEUE] Consuming queued message for session ${sessionId}: "${queued.text.slice(0, 50)}"`);
1557
+ // setTimeout 而非 setImmediate:给 display loop 的 setInterval
1558
+ // 足够时间读到最终状态并终结旧卡片,避免新轮更新旧卡片。
1559
+ setTimeout(() => {
1560
+ consumeQueuedMessage(platform, queued);
1561
+ }, RESPONSE_STALL_RECOVERY_DELAY_MS);
1562
+ }
1563
+ if (shouldScheduleAutoRecovery && autoRecoveryTarget) {
1564
+ const target = autoRecoveryTarget;
1565
+ console.log(`[${ts()}] [RESPONSE-STALL] Reserved automatic recovery for session ${sessionId}`);
1566
+ // 延迟与普通队列原有策略一致,让上一轮终态先完成展示。reservation
1567
+ // 在定时器等待期间仍令 isSessionRunning=true;调用 runAgentSession
1568
+ // 时会在首次 await 前同步写入 activePrompts,然后才消费 reservation,
1569
+ // 因而不存在普通用户消息可插入的事件循环空窗。
1570
+ setTimeout(() => {
1571
+ if (!hasAutoRecoveryReservation(sessionId))
1572
+ return;
1573
+ const recoveryRun = runAgentSession(sessionId, RESPONSE_STALL_RECOVERY_PROMPT, target.platform, target.chatId, Date.now(), tool, undefined, { autoRecovery: true });
1574
+ consumeAutoRecoveryReservation(sessionId);
1575
+ void recoveryRun.catch((err) => {
1576
+ console.error(`[${ts()}] [RESPONSE-STALL] Automatic recovery failed for ${sessionId}: ${err.message}`);
1577
+ target.platform.sendText(target.chatId, `⚠️ 自动续跑启动失败:${err.message}`).catch(() => { });
1578
+ // 若恢复轮在进入主 stream try/finally 前即准备失败,它不会自然
1579
+ // 消费此前保留的用户缓存;在错误回调中补做一次,避免队列悬挂。
1580
+ const queued = dequeueMessage(sessionId);
1581
+ if (queued) {
1582
+ consumeQueuedMessage(target.platform, queued);
1583
+ }
1584
+ });
1585
+ }, RESPONSE_STALL_RECOVERY_DELAY_MS);
1586
+ }
1587
+ }
1588
+ finally {
1589
+ clearSessionFinalizing(sessionId);
1590
+ }
1591
+ }
1592
+ return runOutcome;
1593
+ }
1594
+ // ---------------------------------------------------------------------------
1595
+ // startUnifiedDisplayLoop — 全局统一 display 循环,遍历 displayCards 更新卡片
1596
+ // ---------------------------------------------------------------------------
1597
+ // 替代旧的 per-session ensureDisplayLoop,消除 kill/restart 竞态条件。
1598
+ // 单一定时器遍历所有 displayCards 条目,通过条目内的 sessionId 查找 stream state。
1599
+ // ---------------------------------------------------------------------------
1600
+ const CARD_ROTATE_MS = 9 * 60 * 1000;
1601
+ export function startUnifiedDisplayLoop() {
1602
+ if (unifiedDisplayLoopHandle !== null)
1603
+ return;
1604
+ let tickRunning = false;
1605
+ const interval = setInterval(() => {
1606
+ void (async () => {
1607
+ if (tickRunning)
1608
+ return;
1609
+ tickRunning = true;
1610
+ try {
1611
+ for (const [chatId, display] of displayCards) {
1612
+ if (display.cardBusy)
1613
+ continue;
1614
+ const sessionId = display.sessionId;
1615
+ const state = await readStreamState(sessionId);
1616
+ if (!state) {
1617
+ displayCards.delete(chatId);
1618
+ continue;
1619
+ }
1620
+ // 交叉验证:chat 当前绑定的 session 是否仍是 display 记录的 session。
1621
+ // 若 chat 已被切换到其他 session(如 /newh),旧 display 必须停推。
1622
+ const currentSessionForChat = sessionInfoMap.get(chatId)?.sessionId;
1623
+ if (currentSessionForChat && currentSessionForChat !== sessionId) {
1624
+ if (state.status !== "running") {
1625
+ displayCards.delete(chatId);
1626
+ }
1627
+ continue;
1628
+ }
1629
+ // 验证 chat 仍是该 session 的最后活跃群
1630
+ const lastActive = getLastActiveChat(sessionId);
1631
+ if (lastActive !== chatId) {
1632
+ if (state.status !== "running") {
1633
+ displayCards.delete(chatId);
1634
+ }
1635
+ continue;
1636
+ }
1637
+ const isTerminal = state.status !== "running";
1638
+ try {
1639
+ const p = platformForChat(chatId);
1640
+ if (!p)
1641
+ continue;
1642
+ const isWechat = p.kind === "wechat";
1643
+ if (isTerminal) {
1644
+ if (isWechat) {
1645
+ const prevAccLen = display.lastSentAccLen ?? 0;
1646
+ const prevFinalReply = display.lastSentFinalReply ?? "";
1647
+ const accDelta = state.accumulatedContent.slice(prevAccLen);
1648
+ let replyDelta;
1649
+ if (prevFinalReply && state.finalReply.startsWith(prevFinalReply)) {
1650
+ replyDelta = state.finalReply.slice(prevFinalReply.length);
1651
+ }
1652
+ else {
1653
+ replyDelta = state.finalReply;
1654
+ }
1655
+ const remaining = (accDelta + replyDelta).trim();
1656
+ // 若 session 仍在 activePrompts 中,说明 runAgentSession 的 finally
1657
+ // 还没执行,当前 stream state 可能是 stopSession fire-and-forget
1658
+ // 写入的,finalReply 滞后于内存态。跳过发送,等 finally 落盘后
1659
+ // 下一次 tick 再处理,避免发送过期内容或与后续发送重复。
1660
+ if (activePrompts.has(sessionId))
1661
+ continue;
1662
+ const tail = "━━━ 回答结束 ━━━";
1663
+ const finalMsg = state.status === "auto_ended"
1664
+ ? formatAutoEndedReply(remaining)
1665
+ : state.status === "error"
1666
+ ? formatTerminalReply(state.status, remaining, state.terminalError) ?? tail
1667
+ : remaining
1668
+ ? remaining + "\n" + tail
1669
+ : tail;
1670
+ if (!isFinalReplySentForTurn(state)) {
1671
+ await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, finalMsg);
1672
+ }
1673
+ displayCards.delete(chatId);
1674
+ }
1675
+ else {
1676
+ // 发送最终结果(卡片平台)
1677
+ while (display.cardBusy)
1678
+ await new Promise(r => setTimeout(r, 20));
1679
+ const promptStillActive = activePrompts.has(sessionId);
1680
+ if (promptStillActive &&
1681
+ display.lastSentAccLen === state.accumulatedContent.length &&
1682
+ display.lastSentFinalReply === state.finalReply) {
1683
+ continue;
1684
+ }
1685
+ const terminalCardAlreadyUpdated = display.lastSentAccLen === state.accumulatedContent.length &&
1686
+ display.lastSentFinalReply === state.finalReply;
1687
+ let terminalCardUpdateAccepted = terminalCardAlreadyUpdated;
1688
+ if (!terminalCardAlreadyUpdated) {
1689
+ const nextSeq = display.sequence + 1;
1690
+ const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(state.status, state.terminalError);
1691
+ const cardContent = truncateContent(formatTerminalCardContent(state)) || " ";
1692
+ const doneCard = buildProgressCard(progressView({ text: cardContent, status: "done", showStop: false, headerTitle, headerTemplate }));
1693
+ await p.cardUpdate(display.cardId, doneCard, nextSeq).then(() => {
1694
+ display.sequence = nextSeq;
1695
+ terminalCardUpdateAccepted = true;
1696
+ }).catch(err => {
1697
+ console.error(`[${ts()}] [DISPLAY] terminal cardUpdate failed: ${err.message}`);
1698
+ if (isCardKitSequenceConflict(err)) {
1699
+ display.sequence = nextSeq;
1700
+ terminalCardUpdateAccepted = true;
1701
+ }
1702
+ });
1703
+ if (terminalCardUpdateAccepted) {
1704
+ display.lastSentAccLen = state.accumulatedContent.length;
1705
+ display.lastSentFinalReply = state.finalReply;
1706
+ }
1707
+ }
1708
+ // 若 session 仍在 activePrompts 中,说明 runAgentSession 的 finally
1709
+ // 还没执行,当前 stream state 可能是 stopSession fire-and-forget
1710
+ // 写入的,finalReply 滞后于内存态。卡片已更新为终态外观,但不发送
1711
+ // 文本、不删除 display 条目,留给 finally 落盘后的下一次 tick 处理。
1712
+ if (promptStillActive) {
1713
+ continue;
1714
+ }
1715
+ let terminalTextDelivered = true;
1716
+ const terminalReply = formatTerminalReply(state.status, state.finalReply, state.terminalError);
1717
+ const errorWasDeliveredByCard = state.status === "error"
1718
+ && !state.finalReply.trim()
1719
+ && terminalCardUpdateAccepted;
1720
+ if (errorWasDeliveredByCard) {
1721
+ if (!isFinalReplySentForTurn(state)) {
1722
+ await markFinalReplySent(sessionId, state.turnCount);
1723
+ }
1724
+ }
1725
+ else if (terminalReply) {
1726
+ if (!isFinalReplySentForTurn(state)) {
1727
+ terminalTextDelivered = await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, terminalReply);
1728
+ }
1729
+ }
1730
+ else if (state.accumulatedContent.trim()) {
1731
+ const short = truncateContent(state.accumulatedContent, 30, 4000);
1732
+ terminalTextDelivered = await p.sendText(chatId, `[生成过程]\n${short}`).then((ok) => ok !== false).catch(() => false);
1733
+ }
1734
+ if (!terminalTextDelivered) {
1735
+ console.error(`[${ts()}] [DISPLAY] terminal text send failed, keep display for retry: chatId=${chatId} session=${sessionId} turn=${state.turnCount}`);
1736
+ continue;
1737
+ }
1738
+ const finalSt = turnFinalStatus(state.status);
1739
+ finalizeTurnCards(sessionId, state.turnCount, finalSt).catch(() => { });
1740
+ displayCards.delete(chatId);
1741
+ }
1742
+ setSessionChatAvatar(p, chatId, state.tool, "idle", sessionId).catch(() => { });
1743
+ console.log(`[${ts()}] [DISPLAY] unified loop deleted display for ${chatId} (terminal: ${state.status})`);
1744
+ }
1745
+ else {
1746
+ // running: 创建或更新展示
1747
+ if (isWechat) {
1748
+ // WeChat: 不使用卡片,基于 agent 真实 delta 推送 raw content
1749
+ const prevAccLen = display.lastSentAccLen ?? 0;
1750
+ const prevFinalReply = display.lastSentFinalReply ?? "";
1751
+ const accDelta = state.accumulatedContent.slice(prevAccLen);
1752
+ let replyDelta;
1753
+ if (prevFinalReply && state.finalReply.startsWith(prevFinalReply)) {
1754
+ replyDelta = state.finalReply.slice(prevFinalReply.length);
1755
+ }
1756
+ else {
1757
+ replyDelta = state.finalReply;
1758
+ }
1759
+ const delta = (accDelta + replyDelta).trim();
1760
+ if (!delta)
1761
+ continue;
1762
+ display.cardBusy = true;
1763
+ try {
1764
+ const ok = await p.sendText(chatId, compressWechatDisplayText(delta));
1765
+ if (ok) {
1766
+ display.lastSentAccLen = state.accumulatedContent.length;
1767
+ display.lastSentFinalReply = state.finalReply;
1768
+ display.lastSentContent = delta;
1769
+ }
1770
+ }
1771
+ catch (err) {
1772
+ console.error(`[${ts()}] WeChat sendText error: chatId=${chatId} ${err.message}`);
1773
+ if (!display.streamErrorNotified) {
1774
+ display.streamErrorNotified = true;
1775
+ p.sendText(chatId, "文本发送失败,请稍后查看结果。").catch(() => { });
1776
+ }
1777
+ }
1778
+ finally {
1779
+ display.cardBusy = false;
1780
+ }
1781
+ }
1782
+ else {
1783
+ // 非 WeChat: 卡片流程
1784
+ if (display.turnCount !== state.turnCount) {
1785
+ console.log(`[${ts()}] [DISPLAY] turn mismatch for ${chatId}: display.turnCount=${display.turnCount} state.turnCount=${state.turnCount}, resetting`);
1786
+ finalizeTurnCards(sessionId, display.turnCount, "done").catch(() => { });
1787
+ displayCards.delete(chatId);
1788
+ continue;
1789
+ }
1790
+ const activityHeaderTitle = formatAgentActivityTitle(state.activity, Date.now());
1791
+ // 卡片轮转
1792
+ if (Date.now() - display.cardCreatedAt > CARD_ROTATE_MS) {
1793
+ display.cardBusy = true;
1794
+ try {
1795
+ const newCardId = await createVisibleProgressCard(p, chatId, sessionId, display.turnCount, display.streamErrorNotified ? undefined : "生成中卡片发送失败,结果将继续更新在上一张卡片中。", activityHeaderTitle);
1796
+ if (!newCardId) {
1797
+ display.streamErrorNotified = true;
1798
+ continue;
1799
+ }
1800
+ const oldSeqBase = display.sequence;
1801
+ const oldContent = state.accumulatedContent + state.finalReply;
1802
+ const oldCard = buildProgressCard(progressView({ text: truncateContent(oldContent) || " ", status: "done", showStop: false, headerTitle: "上一阶段记录" }));
1803
+ await p.cardUpdate(display.cardId, oldCard, oldSeqBase + 1).then(() => {
1804
+ display.sequence = oldSeqBase + 1;
1805
+ }).catch(err => {
1806
+ console.error(`[${ts()}] [DISPLAY] rotation old cardUpdate failed: ${err.message}`);
1807
+ });
1808
+ markCardDone(sessionId, display.turnCount, display.cardId).catch(() => { });
1809
+ display.cardId = newCardId;
1810
+ display.sequence = 1;
1811
+ display.cardCreatedAt = Date.now();
1812
+ display.rotationAccLen = state.accumulatedContent.length;
1813
+ display.rotationFinalReply = state.finalReply;
1814
+ display.lastSentContent = "";
1815
+ display.lastSentHeaderTitle = activityHeaderTitle;
1816
+ display.streamErrorNotified = false;
1817
+ }
1818
+ catch (err) {
1819
+ console.error(`[${ts()}] [CARDIKT] rotation FAIL for ${chatId}: ${err.message}`);
1820
+ }
1821
+ finally {
1822
+ display.cardBusy = false;
1823
+ }
1824
+ continue;
1825
+ }
1826
+ // 轮转后:分开追踪 accumulatedContent 和 finalReply 增量
1827
+ if (display.rotationAccLen !== undefined) {
1828
+ const accDelta = state.accumulatedContent.slice(display.rotationAccLen);
1829
+ const rotReply = display.rotationFinalReply ?? "";
1830
+ let replyDelta;
1831
+ if (rotReply && state.finalReply.startsWith(rotReply)) {
1832
+ replyDelta = state.finalReply.slice(rotReply.length);
1833
+ }
1834
+ else {
1835
+ replyDelta = state.finalReply;
1836
+ }
1837
+ const delta = (accDelta + replyDelta).trim();
1838
+ display.dotCount = (display.dotCount % 9) + 1;
1839
+ let deltaBase = delta;
1840
+ if (isCodeBlockOpen(deltaBase))
1841
+ deltaBase += "\n```";
1842
+ const displayContent = deltaBase + "\n" + "。".repeat(display.dotCount);
1843
+ if (displayContent === display.lastSentContent
1844
+ && activityHeaderTitle === display.lastSentHeaderTitle)
1845
+ continue;
1846
+ display.lastSentContent = displayContent;
1847
+ display.lastSentHeaderTitle = activityHeaderTitle;
1848
+ const deltaCard = buildProgressCard(progressView({ text: truncateContent(displayContent) || "等待 Agent 输出...", showStop: true, headerTitle: activityHeaderTitle }));
1849
+ display.cardBusy = true;
1850
+ const mySeq = display.sequence + 1;
1851
+ try {
1852
+ await p.cardUpdate(display.cardId, deltaCard, mySeq);
1853
+ display.sequence = mySeq;
1854
+ }
1855
+ catch (err) {
1856
+ const errMsg = err.message;
1857
+ console.error(`[${ts()}] CardKit update error: chatId=${chatId} ${errMsg}`);
1858
+ if (errMsg.includes("300317")) {
1859
+ display.sequence = mySeq;
1860
+ }
1861
+ else if (!display.streamErrorNotified) {
1862
+ display.streamErrorNotified = true;
1863
+ p.sendText(chatId, "卡片更新失败,结果将以文本形式发送。").catch(() => { });
1864
+ }
1865
+ }
1866
+ finally {
1867
+ display.cardBusy = false;
1868
+ }
1869
+ continue;
1870
+ }
1871
+ display.dotCount = (display.dotCount % 9) + 1;
1872
+ let contentBase = state.accumulatedContent + state.finalReply;
1873
+ if (isCodeBlockOpen(contentBase))
1874
+ contentBase += "\n```";
1875
+ const fullContent = contentBase + "\n" + "。".repeat(display.dotCount);
1876
+ if (fullContent === display.lastSentContent
1877
+ && activityHeaderTitle === display.lastSentHeaderTitle)
1878
+ continue;
1879
+ display.lastSentContent = fullContent;
1880
+ display.lastSentHeaderTitle = activityHeaderTitle;
1881
+ const cardContent = truncateContent(fullContent) || "等待 Agent 输出...";
1882
+ display.cardBusy = true;
1883
+ const mySeq = display.sequence + 1;
1884
+ try {
1885
+ const card = buildProgressCard(progressView({ text: cardContent, showStop: true, headerTitle: activityHeaderTitle }));
1886
+ await p.cardUpdate(display.cardId, card, mySeq);
1887
+ display.sequence = mySeq;
1888
+ }
1889
+ catch (err) {
1890
+ const errMsg = err.message;
1891
+ console.error(`[${ts()}] CardKit update error: chatId=${chatId} ${errMsg}`);
1892
+ if (errMsg.includes("300317")) {
1893
+ display.sequence = mySeq;
1894
+ }
1895
+ else if (!display.streamErrorNotified) {
1896
+ display.streamErrorNotified = true;
1897
+ p.sendText(chatId, "卡片更新失败,结果将以文本形式发送。").catch(() => { });
1898
+ }
1899
+ }
1900
+ finally {
1901
+ display.cardBusy = false;
1902
+ }
1903
+ }
1904
+ }
1905
+ }
1906
+ catch (err) {
1907
+ console.error(`[${ts()}] Display loop error for ${chatId}: ${err.message}`);
1908
+ }
1909
+ }
1910
+ }
1911
+ finally {
1912
+ tickRunning = false;
1913
+ }
1914
+ })().catch((err) => {
1915
+ const e = err instanceof Error ? err : new Error(String(err));
1916
+ console.error(`[${ts()}] Unified display loop uncaught: ${e.message}`);
1917
+ });
1918
+ }, 3000);
1919
+ setUnifiedDisplayLoopHandle(interval);
1920
+ console.log(`[${ts()}] [DISPLAY] Unified display loop started`);
1921
+ }
1922
+ export function stopUnifiedDisplayLoop() {
1923
+ if (unifiedDisplayLoopHandle !== null) {
1924
+ clearInterval(unifiedDisplayLoopHandle);
1925
+ setUnifiedDisplayLoopHandle(null);
1926
+ console.log(`[${ts()}] [DISPLAY] Unified display loop stopped`);
1927
+ }
1928
+ }
1929
+ // ---------------------------------------------------------------------------
1930
+ // stopSession — 停止指定 session 的活跃 prompt
1931
+ // ---------------------------------------------------------------------------
1932
+ //
1933
+ // 设计要点:
1934
+ // 1) controller.abort() 触发 adapter finally 里的 killProcessTree(proc.pid),
1935
+ // 后者负责把整棵 CLI 进程树(cmd.exe 壳 + node CLI 入口 + 真二进制)一起
1936
+ // 收尸;之前用 proc.kill() 在 Windows + shell:true 下只能杀第一层 cmd.exe,
1937
+ // 会留下"幽灵 CLI 子进程"继续跑、stream-state 永远停在 running。
1938
+ //
1939
+ // 2) 立刻 fire-and-forget 把 stream-state 标 stopped,不依赖 runAgentSession
1940
+ // 的 finally。原因:generator 自然结束依赖子进程 stdout 关闭,killProcessTree
1941
+ // 虽然很快但仍是异步,期间 display loop 可能多读到 1–2 帧 "running",
1942
+ // 用户体验上"按下停止后还要等几秒卡片才变成已停止"。先把状态标好,
1943
+ // finally 后续再写一次也不冲突——status 最终值仍然是 stopped。
1944
+ export function stopSession(sessionId) {
1945
+ // /stop 拥有高于内部自动恢复的优先级。旧轮已经完成、恢复轮尚在 200ms
1946
+ // 预约窗口时 activePrompts 为空,因此必须单独取消 reservation。
1947
+ const cancelledRecovery = cancelAutoRecoveryReservation(sessionId);
1948
+ const prompt = activePrompts.get(sessionId);
1949
+ if (!prompt) {
1950
+ if (cancelledRecovery) {
1951
+ cancelQueuedMessage(sessionId);
1952
+ console.log(`[${ts()}] [STOP] Reserved automatic recovery for ${sessionId} cancelled`);
1953
+ return true;
1954
+ }
1955
+ return false;
1956
+ }
1957
+ prompt.stopped = true;
1958
+ clearPromptResponseStallMonitor(sessionId);
1959
+ clearPromptProcessMonitor(sessionId);
1960
+ clearPromptAvatarRefreshTimer(sessionId);
1961
+ clearPromptFinalResponseCloseTimer(sessionId);
1962
+ cancelQueuedMessage(sessionId);
1963
+ // 先发起整棵进程树清理,再触发 close/abort。Windows 上 CLI 由
1964
+ // cmd.exe → node → 实际二进制组成;若先 process.kill(cmd.exe),taskkill
1965
+ // 随后便无法从已消失的根 PID 找到后代,正是幽灵 Codex/Cursor 的来源。
1966
+ // killProcessTree 在返回 Promise 前已启动 taskkill,因此这里无需阻塞。
1967
+ void killProcessTree(prompt.processPid);
1968
+ try {
1969
+ prompt.closeSession?.();
1970
+ }
1971
+ catch (err) {
1972
+ console.warn(`[${ts()}] [STOP] closeSession failed for ${sessionId}: ${err.message}`);
1973
+ }
1974
+ prompt.controller.abort();
1975
+ console.log(`[${ts()}] [STOP] Session ${sessionId} aborted`);
1976
+ // fire-and-forget:立刻把 stream-state.status 改成 stopped,
1977
+ // 让 display loop 下一次扫到立刻渲染"已停止"卡片,不必再等几秒。
1978
+ void (async () => {
1979
+ try {
1980
+ const current = await readStreamState(sessionId);
1981
+ if (!current)
1982
+ return;
1983
+ // 已经是终态就别再覆盖,避免把 done/error 误改成 stopped
1984
+ if (current.status !== "running")
1985
+ return;
1986
+ await writeStreamState({
1987
+ ...current,
1988
+ status: "stopped",
1989
+ updatedAt: Date.now(),
1990
+ });
1991
+ }
1992
+ catch (err) {
1993
+ console.warn(`[${ts()}] [STOP] writeStreamState(stopped) failed for ${sessionId}: ${err.message}`);
1994
+ }
1995
+ })();
1996
+ return true;
1997
+ }
1998
+ // ---------------------------------------------------------------------------
1999
+ // Session status query (供 /state、/sessions 命令使用)
2000
+ // ---------------------------------------------------------------------------
2001
+ //
2002
+ // model / effort 的来源策略(按 tool 区分,避免硬塞 ChatCCC 全局配置导致显示
2003
+ // 与实际不符):
2004
+ // - tool === "cursor"
2005
+ // model:调用 cursor-adapter.getSessionInfo 取持久化的真实模型,
2006
+ // 未学习到时显示占位符 "—"
2007
+ // effort:cursor-agent 没有 effort 概念,恒为 null(卡片渲染时隐藏该行)
2008
+ // - tool === "claude"(默认)
2009
+ // model:anthropicConfigDisplay(CLAUDE_MODEL)
2010
+ // effort:anthropicConfigDisplay(CLAUDE_EFFORT)
2011
+ // ---------------------------------------------------------------------------
2012
+ /** 未知/未学习到时的 model 占位符(卡片可视提示,避免在 UI 上显示空字符串) */
2013
+ export const UNKNOWN_MODEL_PLACEHOLDER = "—";
2014
+ async function resolveModelEffort(tool, sessionId) {
2015
+ if (tool === "cursor") {
2016
+ let model = UNKNOWN_MODEL_PLACEHOLDER;
2017
+ try {
2018
+ const adapter = getAdapterForTool(tool, sessionId);
2019
+ const info = await adapter.getSessionInfo(sessionId);
2020
+ if (info?.model)
2021
+ model = info.model;
2022
+ }
2023
+ catch {
2024
+ // adapter 异常时降级为占位符(不阻塞 /state 卡片)
2025
+ }
2026
+ return { model, effort: null };
2027
+ }
2028
+ if (tool === "codex") {
2029
+ const m = getEffectiveModelForTool(tool, sessionId);
2030
+ const e = getEffectiveEffortForTool(tool, sessionId);
2031
+ return {
2032
+ model: m.trim() !== "" ? m : UNKNOWN_MODEL_PLACEHOLDER,
2033
+ effort: e.trim() !== "" ? e : UNKNOWN_MODEL_PLACEHOLDER,
2034
+ };
2035
+ }
2036
+ if (tool === "ccc") {
2037
+ const m = getEffectiveModelForTool(tool, sessionId);
2038
+ return {
2039
+ model: m.trim() !== "" ? m : UNKNOWN_MODEL_PLACEHOLDER,
2040
+ effort: null,
2041
+ };
2042
+ }
2043
+ return {
2044
+ model: anthropicConfigDisplay(getModelForSession(sessionId)),
2045
+ effort: anthropicConfigDisplay(getEffectiveEffortForTool(tool, sessionId)),
2046
+ };
2047
+ }
2048
+ export async function getSessionStatus(chatId) {
2049
+ const info = sessionInfoMap.get(chatId);
2050
+ if (!info)
2051
+ return null;
2052
+ const activePrompt = activePrompts.get(info.sessionId);
2053
+ const isActive = !!activePrompt && !activePrompt.stopped && !activePrompt.abnormalExit;
2054
+ const { model, effort } = await resolveModelEffort(info.tool, info.sessionId);
2055
+ const registry = await loadSessionRegistry();
2056
+ const chatName = registry[chatId]?.chatName ?? "";
2057
+ // 从 stream-state.json 获取当前累积长度
2058
+ let accumulatedLength = 0;
2059
+ const streamState = await readStreamState(info.sessionId);
2060
+ if (streamState) {
2061
+ accumulatedLength = streamState.accumulatedContent.length + streamState.finalReply.length;
2062
+ }
2063
+ return {
2064
+ sessionId: info.sessionId,
2065
+ chatName,
2066
+ running: isActive,
2067
+ turnCount: info.turnCount,
2068
+ lastContextTokens: info.lastContextTokens,
2069
+ startTime: info.startTime,
2070
+ model,
2071
+ effort,
2072
+ accumulatedLength,
2073
+ };
2074
+ }
2075
+ export async function getAllSessionsStatus() {
2076
+ const registry = await loadSessionRegistry();
2077
+ const registryEntries = Object.values(registry)
2078
+ .filter((record) => record.chatId && record.sessionId && record.tool)
2079
+ .map((record) => ({ ...record, sortTime: record.updatedAt }));
2080
+ const registeredSessionIds = new Set(registryEntries.map((record) => record.sessionId));
2081
+ const sessionTools = await loadSessionTools();
2082
+ const orphanEntries = Object.entries(sessionTools)
2083
+ .filter(([sessionId, record]) => sessionId && record?.tool && !registeredSessionIds.has(sessionId))
2084
+ .map(([sessionId, record]) => {
2085
+ const createdAt = Number.isFinite(record.createdAt) ? record.createdAt : 0;
2086
+ const active = activePrompts.get(sessionId);
2087
+ return {
2088
+ chatId: "",
2089
+ chatType: undefined,
2090
+ sessionId,
2091
+ tool: record.tool,
2092
+ chatName: record.chatName ?? "",
2093
+ turnCount: 0,
2094
+ lastContextTokens: 0,
2095
+ startTime: active?.startTime ?? createdAt,
2096
+ updatedAt: createdAt,
2097
+ running: false,
2098
+ sortTime: active?.startTime ?? createdAt,
2099
+ };
2100
+ });
2101
+ const entries = [...registryEntries, ...orphanEntries]
2102
+ .sort((a, b) => b.sortTime - a.sortTime)
2103
+ .slice(0, 20);
2104
+ // 并行解析每个 session 的 model/effort(cursor 涉及异步 store IO)
2105
+ return Promise.all(entries.map(async (info) => {
2106
+ const { model, effort } = await resolveModelEffort(info.tool, info.sessionId);
2107
+ return {
2108
+ chatId: info.chatId,
2109
+ chatType: info.chatType,
2110
+ sessionId: info.sessionId,
2111
+ chatName: info.chatName || "",
2112
+ active: !!activePrompts.get(info.sessionId) &&
2113
+ !activePrompts.get(info.sessionId)?.stopped &&
2114
+ !activePrompts.get(info.sessionId)?.abnormalExit,
2115
+ turnCount: info.turnCount,
2116
+ startTime: info.startTime,
2117
+ model,
2118
+ effort,
2119
+ tool: info.tool,
2120
+ };
2121
+ }));
2122
+ }
2123
+ // ---------------------------------------------------------------------------
2124
+ // 测试辅助:注入自定义 adapter 到 adapterCache
2125
+ // ---------------------------------------------------------------------------
2126
+ // 仅供单测使用——下划线前缀表明非生产 API。让 session-status 的测试可以
2127
+ // 注入一个内存 store + adapter,以验证 cursor 分支按 tool 取真实 model。
2128
+ // ---------------------------------------------------------------------------
2129
+ export function _setAdapterForToolForTest(tool, adapter) {
2130
+ adapterCache.set(tool, adapter);
2131
+ // 同时设置当前配置模型对应的 key(getAdapterForTool 会优先 lookup 含 model 的 key)
2132
+ const effective = getEffectiveModelForTool(tool);
2133
+ const effort = getEffectiveEffortForTool(tool);
2134
+ const fastMode = getEffectiveFastModeForTool(tool);
2135
+ adapterCache.set(`${tool}:${effective || ""}:${effort || ""}:${fastMode ? "fast" : "default"}`, adapter);
2136
+ if (effective)
2137
+ adapterCache.set(`${tool}:${effective}`, adapter);
2138
+ }
2139
+ export function clearAdapterCache() {
2140
+ adapterCache.clear();
2141
+ }
2142
+ export function _clearAdapterCacheForTest() {
2143
+ clearAdapterCache();
2144
+ }