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,2078 @@
1
+ /**
2
+ * orchestrator.ts — 平台无关的消息命令处理
3
+ *
4
+ * Phase 1: 从 index.ts 抽出 handleCommand 及辅助函数。
5
+ * 所有 IM 平台操作通过 PlatformAdapter 接口注入,不直接依赖 feishu-platform.ts。
6
+ */
7
+ import { execSync, spawn } from "node:child_process";
8
+ import { readdir, stat } from "node:fs/promises";
9
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync } from "node:fs";
10
+ import { join, resolve, dirname } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import { makeTraceId, logTrace } from "./trace.js";
13
+ import { appendStartupTrace } from "./shared.js";
14
+ import { CLAUDE_MODEL, GIT_TIMEOUT_MS, PROJECT_ROOT, anthropicConfigDisplay, config, fileLog, getAllEffortsForTool, getAllModelsForTool, getDefaultEffortForTool, getDefaultCwd, LOG_DIR, setDefaultCwd, getRecentDirs, addRecentDir, resolveDefaultAgentTool, sessionPrefixForTool, toolDisplayName, ts, } from "./config.js";
15
+ import { buildHelpCard, buildEffortCard, buildFastModeCard, buildModelCard, buildStatusCard, buildCdContent, buildCdCard, buildSessionsCard, buildQueuedCard, buildQueueFullCard, buildCodexUsageCard, } from "./cards.js";
16
+ import { formatGitResult, gitResultHeaderTemplate, runGitCommand, } from "./git-command.js";
17
+ import { clearSessionModelOverride, clearSessionEffortOverride, getSessionStatus, getAllSessionsStatus, initClaudeSession, lastMsgTimestamps, resumeAndPrompt, sessionInfoMap, setSessionModelOverride, setSessionEffortOverride, switchChatBinding, recordSessionRegistry, getAdapterForTool, getEffectiveModelForTool, getEffectiveEffortForTool, getEffectiveFastModeForTool, setSessionFastModeOverride, stopSession, loadSessionRegistryForBinding, removeSessionRegistryRecord, saveSessionTool, recordChatPlatform, } from "./session.js";
18
+ import { bindChatToSession, unbindChatFromSession, isSessionRunning, displayCards, enqueueMessage, cancelQueuedMessage, } from "./session-chat-binding.js";
19
+ import { getCodexUsageSummary, getTenantAccessToken, sendPostMessage } from "./feishu-platform.js";
20
+ import { getCursorUsageSummary } from "./cursor-usage.js";
21
+ import { getChatGptSubscriptionStatus } from "./chatgpt-subscription.js";
22
+ import { applySharedPrefix } from "./shared-prefix.js";
23
+ import { sessionChatName } from "./session-name.js";
24
+ import { reloadRuntimeConfig } from "./runtime-reload.js";
25
+ import { acquireUpdateCommandGuard } from "./update-command-guard.js";
26
+ import { createInternalRestartEnv } from "./startup-lifecycle.js";
27
+ import { resolveChatCccRuntimeSpawnSpec } from "./runtime-entry.js";
28
+ // ---------------------------------------------------------------------------
29
+ // 辅助函数
30
+ // ---------------------------------------------------------------------------
31
+ /** 模型模糊匹配:精确匹配优先,否则找子串匹配(模型名越短越优先) */
32
+ function findModelMatch(input, models) {
33
+ if (models.length === 0)
34
+ return null;
35
+ const inputLower = input.toLowerCase();
36
+ // 1) 精确匹配(忽略大小写)
37
+ for (const m of models) {
38
+ if (m.toLowerCase() === inputLower)
39
+ return m;
40
+ }
41
+ // 2) 子串匹配:模型全名包含输入,按模型名长度升序(越短越优先)
42
+ const candidates = models
43
+ .filter(m => m.toLowerCase().includes(inputLower))
44
+ .sort((a, b) => a.length - b.length);
45
+ return candidates[0] ?? null;
46
+ }
47
+ function formatCodexUsageSummary(usage, chatGptSubscription = null) {
48
+ const progressBar = (usedPercent) => {
49
+ const width = 20;
50
+ const usedBlocks = Math.max(0, Math.min(width, Math.round((usedPercent / 100) * width)));
51
+ return `[${"█".repeat(usedBlocks)}${"░".repeat(width - usedBlocks)}]`;
52
+ };
53
+ const formatDuration = (seconds) => {
54
+ if (seconds === null)
55
+ return "";
56
+ if (seconds <= 0)
57
+ return "(已到重置时间)";
58
+ const totalMinutes = Math.max(1, Math.floor(seconds / 60));
59
+ const days = Math.floor(totalMinutes / 1440);
60
+ const hours = Math.floor((totalMinutes % 1440) / 60);
61
+ const minutes = totalMinutes % 60;
62
+ const parts = [];
63
+ if (days > 0)
64
+ parts.push(`${days}天`);
65
+ if (hours > 0)
66
+ parts.push(`${hours}小时`);
67
+ if (minutes > 0 || parts.length === 0)
68
+ parts.push(`${minutes}分钟`);
69
+ return `(约 ${parts.join("")}后)`;
70
+ };
71
+ const formatResetTime = (balance) => {
72
+ if (balance.resetAtEpochSeconds === null)
73
+ return "暂无数据";
74
+ const date = new Date(balance.resetAtEpochSeconds * 1000);
75
+ const pad = (value) => String(value).padStart(2, "0");
76
+ const absolute = [
77
+ date.getFullYear(),
78
+ "-",
79
+ pad(date.getMonth() + 1),
80
+ "-",
81
+ pad(date.getDate()),
82
+ " ",
83
+ pad(date.getHours()),
84
+ ":",
85
+ pad(date.getMinutes()),
86
+ ].join("");
87
+ return `${absolute}${formatDuration(balance.resetAfterSeconds)}`;
88
+ };
89
+ const formatWindow = (label, balance) => {
90
+ if (!balance)
91
+ return `**${label}:** 暂无数据`;
92
+ return [
93
+ `**${label}:** 已用 ${balance.usedPercent}%,剩余 ${balance.remainingPercent}%,重置: ${formatResetTime(balance)}`,
94
+ progressBar(balance.usedPercent),
95
+ ].join("\n");
96
+ };
97
+ const formatResetCredits = () => {
98
+ if (usage.rateLimitResetCreditsAvailable === null)
99
+ return "**主动重置:** 暂无数据";
100
+ const lines = [`**主动重置:** 剩余 ${usage.rateLimitResetCreditsAvailable} 次`];
101
+ const credits = usage.rateLimitResetCredits ?? [];
102
+ if (credits.length > 0) {
103
+ const pad = (value) => String(value).padStart(2, "0");
104
+ const formatExpiresAt = (value) => {
105
+ const date = new Date(value);
106
+ if (!Number.isFinite(date.getTime()))
107
+ return value;
108
+ return [
109
+ date.getFullYear(),
110
+ "-",
111
+ pad(date.getMonth() + 1),
112
+ "-",
113
+ pad(date.getDate()),
114
+ " ",
115
+ pad(date.getHours()),
116
+ ":",
117
+ pad(date.getMinutes()),
118
+ ":",
119
+ pad(date.getSeconds()),
120
+ ].join("");
121
+ };
122
+ lines.push("**过期时间:**");
123
+ for (const credit of credits) {
124
+ lines.push(`- ${formatExpiresAt(credit.expiresAt)}`);
125
+ }
126
+ }
127
+ return lines.join("\n");
128
+ };
129
+ const formatSubscriptionFailureReason = (result) => {
130
+ const port = result.chromeCdp.port;
131
+ switch (result.code) {
132
+ case "chrome_cdp_unreachable":
133
+ return `Chrome CDP 端口 ${port} 不可访问。请确认常驻 Chrome 已启动,或重启 ChatCCC。`;
134
+ case "chrome_cdp_occupied":
135
+ return `${port} 端口可访问,但不是健康的 Chrome CDP。请释放该端口或修改 chromeDevtools.port。`;
136
+ case "chatgpt_page_missing":
137
+ return `没有可用的 ChatGPT 页面。请在 ${port} 端口对应的 Chrome 浏览器中打开 https://chatgpt.com/ 并登录。`;
138
+ case "chatgpt_session_missing":
139
+ return `请在 ${port} 端口对应的 Chrome 浏览器中登录 ChatGPT。`;
140
+ case "chatgpt_subscription_failed":
141
+ return "ChatGPT 订阅接口探测失败。可能是页面未加载完成、ChatGPT 接口变更或网络异常。";
142
+ case "chrome_cdp_disabled":
143
+ return "";
144
+ case "ok":
145
+ return "";
146
+ }
147
+ };
148
+ const formatChatGptSubscriptionFailure = () => {
149
+ if (!chatGptSubscription || chatGptSubscription.ok || !chatGptSubscription.chromeCdp.enabled)
150
+ return "";
151
+ const lines = [
152
+ "**ChatGPT 订阅查询失败:**",
153
+ `- 原因: ${formatSubscriptionFailureReason(chatGptSubscription) || "暂无数据"}`,
154
+ ];
155
+ const detail = chatGptSubscription.reason?.replace(/\s+/g, " ").trim();
156
+ if (detail) {
157
+ lines.push(`- 详情: ${detail.length > 240 ? `${detail.slice(0, 240)}...` : detail}`);
158
+ }
159
+ return lines.join("\n");
160
+ };
161
+ const formatChatGptSubscription = () => {
162
+ if (!chatGptSubscription?.ok || !chatGptSubscription.subscription)
163
+ return "";
164
+ const subscription = chatGptSubscription.subscription;
165
+ const pad = (value) => String(value).padStart(2, "0");
166
+ const formatExpiresAt = (value) => {
167
+ if (!value)
168
+ return "暂无数据";
169
+ const date = new Date(value);
170
+ if (!Number.isFinite(date.getTime()))
171
+ return value;
172
+ return [
173
+ date.getFullYear(),
174
+ "-",
175
+ pad(date.getMonth() + 1),
176
+ "-",
177
+ pad(date.getDate()),
178
+ " ",
179
+ pad(date.getHours()),
180
+ ":",
181
+ pad(date.getMinutes()),
182
+ ].join("");
183
+ };
184
+ const remaining = typeof subscription.remainingDays === "number"
185
+ ? `(剩余 ${subscription.remainingDays} 天)`
186
+ : "";
187
+ return [
188
+ "**ChatGPT 订阅:**",
189
+ `- 套餐: ${subscription.plan ?? "暂无数据"}`,
190
+ `- 到期: ${formatExpiresAt(subscription.expiresAt)}${remaining}`,
191
+ `- 自动续费: ${subscription.willRenew === null ? "暂无数据" : subscription.willRenew ? "是" : "否"}`,
192
+ ].join("\n");
193
+ };
194
+ return [
195
+ "Codex 用量:",
196
+ "",
197
+ formatChatGptSubscription(),
198
+ formatChatGptSubscriptionFailure(),
199
+ formatResetCredits(),
200
+ "",
201
+ usage.fiveHour ? formatWindow("5h", usage.fiveHour) : "",
202
+ usage.weekly ? formatWindow("7天", usage.weekly) : "",
203
+ ].filter((line, index, arr) => line !== "" || (index > 0 && arr[index - 1] !== "")).join("\n");
204
+ }
205
+ function formatCursorUsageSummary(usage) {
206
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
207
+ const dateFormatter = new Intl.DateTimeFormat("zh-CN", {
208
+ timeZone,
209
+ year: "numeric",
210
+ month: "2-digit",
211
+ day: "2-digit",
212
+ hour: "2-digit",
213
+ minute: "2-digit",
214
+ second: "2-digit",
215
+ hour12: false,
216
+ timeZoneName: "shortOffset",
217
+ });
218
+ const formatDate = (value) => {
219
+ const timestamp = Number(value);
220
+ if (!Number.isFinite(timestamp))
221
+ return "暂无数据";
222
+ return dateFormatter.format(new Date(timestamp));
223
+ };
224
+ const formatMoney = (value) => {
225
+ if (typeof value !== "number" || !Number.isFinite(value))
226
+ return "暂无数据";
227
+ return `$${(value / 100).toFixed(2)}`;
228
+ };
229
+ const formatPercent = (value) => {
230
+ if (typeof value !== "number" || !Number.isFinite(value))
231
+ return "暂无数据";
232
+ return `${value}%`;
233
+ };
234
+ const plan = usage.planUsage;
235
+ const spendLimit = usage.spendLimitUsage;
236
+ return [
237
+ "Cursor 用量:",
238
+ "",
239
+ `**计费周期:** ${formatDate(usage.billingCycleStart)} - ${formatDate(usage.billingCycleEnd)}`,
240
+ "",
241
+ "**Included usage:**",
242
+ `- Total: ${formatMoney(plan?.totalSpend)} / ${formatMoney(plan?.limit)} (${formatPercent(plan?.totalPercentUsed)})`,
243
+ `- Included: ${formatMoney(plan?.includedSpend)}`,
244
+ `- Bonus: ${formatMoney(plan?.bonusSpend)}`,
245
+ `- Auto: ${formatPercent(plan?.autoPercentUsed)}`,
246
+ `- API: ${formatPercent(plan?.apiPercentUsed)}`,
247
+ "",
248
+ "**On-Demand / Spend limit:**",
249
+ `- Individual used: ${formatMoney(spendLimit?.individualUsed)}`,
250
+ `- Pool used: ${formatMoney(spendLimit?.pooledUsed)} / ${formatMoney(spendLimit?.pooledLimit)}`,
251
+ `- Pool remaining: ${formatMoney(spendLimit?.pooledRemaining)}`,
252
+ `- Limit type: ${spendLimit?.limitType ?? "暂无数据"}`,
253
+ `- Display threshold: ${formatMoney(usage.displayThreshold)}`,
254
+ "",
255
+ `**Enabled:** ${usage.enabled === undefined ? "暂无数据" : String(usage.enabled)}`,
256
+ usage.displayMessage ? `**Message:** ${usage.displayMessage}` : "",
257
+ usage.autoModelSelectedDisplayMessage ? `**Auto model:** ${usage.autoModelSelectedDisplayMessage}` : "",
258
+ usage.namedModelSelectedDisplayMessage ? `**Named model:** ${usage.namedModelSelectedDisplayMessage}` : "",
259
+ usage.autoBucketModels?.length ? `**Auto bucket models:** ${usage.autoBucketModels.join(", ")}` : "",
260
+ ].filter(Boolean).join("\n");
261
+ }
262
+ function usageHelpLine(tool) {
263
+ if (tool === "codex")
264
+ return "\n发送 **/usage** 查看 Codex 实际存在的 5h/7天用量窗口,以及查询/使用主动重置卡。";
265
+ if (tool === "cursor")
266
+ return "\n发送 **/usage** 查看 Cursor 用量。";
267
+ return "";
268
+ }
269
+ function fastHelpAfterModel(tool) {
270
+ return tool === "codex"
271
+ ? "\n发送 **/fast** 查看或切换当前会话的 Fast 模式。"
272
+ : "";
273
+ }
274
+ function setChatAvatarForSession(platform, chatId, tool, status, sessionId, usageHints) {
275
+ const fastMode = getEffectiveFastModeForTool(tool, sessionId);
276
+ if (!usageHints && !fastMode)
277
+ return platform.setChatAvatar(chatId, tool, status);
278
+ return platform.setChatAvatar(chatId, tool, status, {
279
+ ...usageHints,
280
+ ...(fastMode ? { fastMode: true } : {}),
281
+ });
282
+ }
283
+ async function sendFastModeStatus(platform, chatId, enabled) {
284
+ if (platform.kind === "wechat") {
285
+ const mode = enabled ? "ON (Fast)" : "OFF (Standard)";
286
+ await platform.sendText(chatId, `Codex Fast 模式: ${mode}\n输入 /fast on 或 /fast off 切换。切换将在下一条消息生效,当前生成不中断。`);
287
+ return;
288
+ }
289
+ await platform.sendRawCard(chatId, buildFastModeCard(enabled));
290
+ }
291
+ async function resolveUsageTarget(chatId) {
292
+ try {
293
+ const registry = await loadSessionRegistryForBinding();
294
+ const record = registry[chatId];
295
+ const tool = record?.tool;
296
+ if (tool === "cursor")
297
+ return { tool: "cursor", sessionId: record?.sessionId };
298
+ if (tool === "ccc")
299
+ return { tool: "ccc", sessionId: record?.sessionId };
300
+ return { tool: "codex", sessionId: record?.sessionId };
301
+ }
302
+ catch {
303
+ return { tool: "codex" };
304
+ }
305
+ }
306
+ function isOfficialDeepSeek(baseURL) {
307
+ try {
308
+ return new URL(baseURL).host === "api.deepseek.com";
309
+ }
310
+ catch {
311
+ return false;
312
+ }
313
+ }
314
+ async function fetchDeepSeekBalance(apiKey, baseURL) {
315
+ const apiOrigin = new URL(baseURL).origin;
316
+ const resp = await fetch(`${apiOrigin}/user/balance`, {
317
+ headers: { Authorization: `Bearer ${apiKey}` },
318
+ });
319
+ if (!resp.ok) {
320
+ throw new Error(`DeepSeek 余额查询失败: HTTP ${resp.status}`);
321
+ }
322
+ return (await resp.json());
323
+ }
324
+ function formatDeepSeekBalance(balance) {
325
+ if (!balance.is_available)
326
+ return "**DeepSeek 余额:** 暂无数据";
327
+ const infos = balance.balance_infos ?? [];
328
+ if (infos.length === 0)
329
+ return "**DeepSeek 余额:** 暂无数据";
330
+ const lines = infos.map((info) => {
331
+ const parts = [`**${info.currency}:**`];
332
+ parts.push(`- 总余额: ${info.total_balance}`);
333
+ if (info.topped_up_balance)
334
+ parts.push(`- 充值余额: ${info.topped_up_balance}`);
335
+ if (info.granted_balance)
336
+ parts.push(`- 赠送余额: ${info.granted_balance}`);
337
+ return parts.join("\n");
338
+ });
339
+ return `**DeepSeek 余额:**\n${lines.join("\n")}`;
340
+ }
341
+ function refreshUsageAvatar(platform, chatId, tool, status, usageHints, sessionId) {
342
+ setChatAvatarForSession(platform, chatId, tool, status, sessionId, usageHints).catch((err) => {
343
+ console.warn(`[${ts()}] [AVATAR] usage refresh failed: chatId=${chatId} tool=${tool} ${err.message}`);
344
+ });
345
+ }
346
+ async function sendUsageSummary(platform, chatId, tool, avatarStatus = "idle", sessionId) {
347
+ if (tool === "ccc") {
348
+ const baseURL = config.ccc.DEEPSEEK_BASE_URL;
349
+ if (!isOfficialDeepSeek(baseURL)) {
350
+ const msg = "CCC 用量查询仅支持官方 DeepSeek API (api.deepseek.com),当前使用的非官方接口不支持余额查询。";
351
+ if (platform.kind === "wechat") {
352
+ await platform.sendText(chatId, msg).catch(() => { });
353
+ }
354
+ else {
355
+ await platform.sendCard(chatId, "CCC Usage", msg, "blue");
356
+ }
357
+ return;
358
+ }
359
+ const balance = await fetchDeepSeekBalance(config.ccc.DEEPSEEK_API_KEY, baseURL);
360
+ const content = formatDeepSeekBalance(balance);
361
+ if (platform.kind === "wechat") {
362
+ await platform.sendText(chatId, content).catch(() => { });
363
+ }
364
+ else {
365
+ await platform.sendCard(chatId, "CCC Usage", content, "blue");
366
+ }
367
+ return;
368
+ }
369
+ if (tool === "cursor") {
370
+ const usage = await getCursorUsageSummary();
371
+ const content = formatCursorUsageSummary(usage);
372
+ if (platform.kind === "wechat") {
373
+ await platform.sendText(chatId, content).catch(() => { });
374
+ }
375
+ else {
376
+ await platform.sendCard(chatId, "Cursor Usage", content, "blue");
377
+ }
378
+ refreshUsageAvatar(platform, chatId, tool, avatarStatus, { cursorUsage: usage }, sessionId);
379
+ return;
380
+ }
381
+ const [usage, chatGptSubscription] = await Promise.all([
382
+ getCodexUsageSummary(),
383
+ getChatGptSubscriptionStatus().catch(() => null),
384
+ ]);
385
+ const content = formatCodexUsageSummary(usage, chatGptSubscription);
386
+ if (platform.kind === "wechat") {
387
+ await platform.sendText(chatId, content).catch(() => { });
388
+ }
389
+ else if (platform.kind === "feishu") {
390
+ await platform.sendRawCard(chatId, buildCodexUsageCard(content, usage.rateLimitResetCreditsAvailable));
391
+ }
392
+ else {
393
+ await platform.sendCard(chatId, "Codex Usage", content, "blue");
394
+ }
395
+ refreshUsageAvatar(platform, chatId, tool, avatarStatus, { codexUsage: usage }, sessionId);
396
+ }
397
+ async function sendUsageError(platform, chatId, tool, err) {
398
+ const toolLabel = tool === "cursor" ? "Cursor" : tool === "ccc" ? "CCC" : "Codex";
399
+ const message = `${toolLabel} 用量获取失败:${err.message}`;
400
+ if (platform.kind === "wechat") {
401
+ await platform.sendText(chatId, message).catch(() => { });
402
+ }
403
+ else {
404
+ await platform.sendCard(chatId, `${toolLabel} Usage`, message, "red");
405
+ }
406
+ }
407
+ function isUntitledSessionChatName(name) {
408
+ return name === "新会话" || name.startsWith("新会话-");
409
+ }
410
+ function shouldSendWechatProcessingAck(platform, isCommandText, chatType) {
411
+ return platform.kind === "wechat" && chatType === "p2p" && !isCommandText;
412
+ }
413
+ /** 飞书私聊是专属会话容器;显式 /new 才创建独立群聊。 */
414
+ function isFeishuP2p(platform, chatType) {
415
+ return chatType === "p2p" && platform.kind === "feishu";
416
+ }
417
+ async function sendStateCard(platform, chatId, sessionId, toolLabel, traceId) {
418
+ const status = sessionId ? await getSessionStatus(chatId) : null;
419
+ const isActive = sessionId ? isSessionRunning(sessionId) : false;
420
+ const stateLabel = sessionId
421
+ ? (isActive ? "🟢 运行中" : "⚪ 空闲")
422
+ : "⚪ 未建立会话";
423
+ const statusText = [
424
+ `**群名:** ${status?.chatName || "—"}`,
425
+ `**Session ID:** ${sessionId ? `\`${status?.sessionId ?? sessionId}\`` : "—"}`,
426
+ `**工具:** ${toolLabel}`,
427
+ `**状态:** ${stateLabel}`,
428
+ `**已对话轮数:** ${status?.turnCount ?? 0}`,
429
+ `**模型:** ${sessionId ? (status?.model ?? anthropicConfigDisplay(CLAUDE_MODEL)) : "—"}`,
430
+ ];
431
+ if (status?.effort != null) {
432
+ statusText.push(`**Effort:** ${status.effort}`);
433
+ }
434
+ if (isActive && status) {
435
+ const elapsed = Math.floor((Date.now() - status.startTime) / 1000);
436
+ const mins = Math.floor(elapsed / 60);
437
+ const secs = elapsed % 60;
438
+ statusText.push(`**本轮已运行:** ${mins}分${secs}秒`);
439
+ statusText.push(`**已产出总字符:** ${status.accumulatedLength.toLocaleString()}`);
440
+ }
441
+ if (status?.lastContextTokens) {
442
+ statusText.push(`**上下文 Token 数:** ~${status.lastContextTokens.toLocaleString()}`);
443
+ }
444
+ const card = buildStatusCard(statusText.join("\n"), isActive ? "blue" : "green");
445
+ const ok = await platform.sendRawCard(chatId, card);
446
+ console.log(`[${ts()}] [STATUS] card sent, ok=${ok}`);
447
+ logTrace(traceId, "DONE", {
448
+ outcome: sessionId ? "status" : "status_no_session",
449
+ ok,
450
+ });
451
+ }
452
+ // 同一个飞书私聊可能短时间收到多条消息。切换期间共享同一个 Promise,避免
453
+ // 为同一次默认 Agent 变化创建多个空会话。
454
+ const feishuP2pAgentSwitches = new Map();
455
+ async function resolveFeishuP2pAgent(platform, chatId, text, record, traceId) {
456
+ const desiredTool = resolveDefaultAgentTool();
457
+ if (record.tool === desiredTool) {
458
+ return { kind: "ready", sessionId: record.sessionId, tool: record.tool };
459
+ }
460
+ if (isSessionRunning(record.sessionId)) {
461
+ return {
462
+ kind: "waiting",
463
+ sessionId: record.sessionId,
464
+ tool: record.tool,
465
+ desiredTool,
466
+ };
467
+ }
468
+ const existingSwitch = feishuP2pAgentSwitches.get(chatId);
469
+ if (existingSwitch)
470
+ return existingSwitch;
471
+ const switchOperation = (async () => {
472
+ try {
473
+ // 异步创建开始前重新读取一次,防止另一个请求刚完成了绑定切换。
474
+ const latestRecord = (await loadSessionRegistryForBinding())[chatId];
475
+ if (!latestRecord?.sessionId || !latestRecord.tool || latestRecord.chatType !== "p2p") {
476
+ return {
477
+ kind: "error",
478
+ previousTool: record.tool,
479
+ desiredTool,
480
+ error: new Error("飞书私聊绑定在切换前已发生变化"),
481
+ };
482
+ }
483
+ if (latestRecord.tool === desiredTool) {
484
+ return { kind: "ready", sessionId: latestRecord.sessionId, tool: latestRecord.tool };
485
+ }
486
+ if (isSessionRunning(latestRecord.sessionId)) {
487
+ return {
488
+ kind: "waiting",
489
+ sessionId: latestRecord.sessionId,
490
+ tool: latestRecord.tool,
491
+ desiredTool,
492
+ };
493
+ }
494
+ const cwd = homedir();
495
+ const init = await initClaudeSession(desiredTool, cwd);
496
+ const chatName = sessionChatName(text.slice(0, 10) || "私聊会话", cwd);
497
+ const switchResult = await switchChatBinding({
498
+ chatId,
499
+ chatType: "p2p",
500
+ oldSessionId: latestRecord.sessionId,
501
+ newSessionId: init.sessionId,
502
+ tool: desiredTool,
503
+ chatName,
504
+ newDescription: `${sessionPrefixForTool(desiredTool)} ${init.sessionId}`,
505
+ updateChatInfoFn: (id, name, desc) => platform.updateChatInfo(id, name, desc),
506
+ });
507
+ if (!switchResult.ok) {
508
+ return {
509
+ kind: "error",
510
+ previousTool: latestRecord.tool,
511
+ desiredTool,
512
+ error: switchResult.error ?? new Error("更新飞书私聊绑定失败"),
513
+ };
514
+ }
515
+ const previousLabel = toolDisplayName(latestRecord.tool);
516
+ const desiredLabel = toolDisplayName(desiredTool);
517
+ logTrace(traceId, "BRANCH", {
518
+ reason: "switch_feishu_p2p_default_agent",
519
+ chatId,
520
+ oldSessionId: latestRecord.sessionId,
521
+ newSessionId: init.sessionId,
522
+ oldTool: latestRecord.tool,
523
+ newTool: desiredTool,
524
+ });
525
+ await platform.sendCard(chatId, "默认 Agent 已切换", `检测到默认 Agent 已变化:**${previousLabel} → ${desiredLabel}**。\n\n已创建新的空白 ${desiredLabel} 私聊会话,并从本条消息开始使用。`, "green").catch(() => { });
526
+ setChatAvatarForSession(platform, chatId, desiredTool, "new", init.sessionId).catch(() => { });
527
+ return { kind: "ready", sessionId: init.sessionId, tool: desiredTool };
528
+ }
529
+ catch (err) {
530
+ return {
531
+ kind: "error",
532
+ previousTool: record.tool,
533
+ desiredTool,
534
+ error: err,
535
+ };
536
+ }
537
+ })();
538
+ feishuP2pAgentSwitches.set(chatId, switchOperation);
539
+ try {
540
+ return await switchOperation;
541
+ }
542
+ finally {
543
+ if (feishuP2pAgentSwitches.get(chatId) === switchOperation) {
544
+ feishuP2pAgentSwitches.delete(chatId);
545
+ }
546
+ }
547
+ }
548
+ /** 检测当前进程是否从 npm 全局安装启动 */
549
+ function isRunningFromGlobalNpm() {
550
+ try {
551
+ const globalRoot = execSync("npm root -g", { encoding: "utf8", timeout: 5000, windowsHide: true }).trim();
552
+ return resolve(PROJECT_ROOT).startsWith(resolve(globalRoot));
553
+ }
554
+ catch {
555
+ return false;
556
+ }
557
+ }
558
+ const UPDATE_LOG = join(homedir(), ".chatccc", "logs", "update-watcher.log");
559
+ function updLog(msg) {
560
+ const ts = new Date().toISOString();
561
+ try {
562
+ appendFileSync(UPDATE_LOG, `${ts} [UPDATE-SYNC] ${msg}\n`, "utf-8");
563
+ }
564
+ catch { }
565
+ }
566
+ /** 同步更新 npm 全局包并 spawn 新进程重启。不依赖 systemd 或任何服务管理器。 */
567
+ function syncUpdateAndRestart() {
568
+ updLog(`sync update start, pid=${process.pid}`);
569
+ appendStartupTrace("update: sync update start", { pid: process.pid });
570
+ const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
571
+ // 1. npm update
572
+ updLog(`running: ${npmCmd} update -g chatccc`);
573
+ appendStartupTrace("update: npm update begin", { npmCmd });
574
+ const t0 = Date.now();
575
+ try {
576
+ const out = execSync(`${npmCmd} update -g chatccc 2>&1`, { encoding: "utf8", timeout: 120000, windowsHide: true });
577
+ const elapsed = Date.now() - t0;
578
+ updLog(`npm update OK (${elapsed}ms): ${out.slice(0, 500)}`);
579
+ appendStartupTrace("update: npm update OK", { elapsedMs: elapsed, outputLen: out.length });
580
+ }
581
+ catch (e) {
582
+ const elapsed = Date.now() - t0;
583
+ const err = e;
584
+ updLog(`npm update failed (${elapsed}ms): message=${err.message}, stderr=${(err.stderr || "").slice(0, 500)}, stdout=${(err.stdout || "").slice(0, 200)}`);
585
+ appendStartupTrace("update: npm update failed", { elapsedMs: elapsed, message: err.message, stderrLen: (err.stderr || "").length });
586
+ // fallback
587
+ updLog(`fallback: ${npmCmd} install -g chatccc@latest`);
588
+ appendStartupTrace("update: npm install fallback begin", { npmCmd });
589
+ const t1 = Date.now();
590
+ try {
591
+ const out2 = execSync(`${npmCmd} install -g chatccc@latest 2>&1`, { encoding: "utf8", timeout: 120000, windowsHide: true });
592
+ const elapsed2 = Date.now() - t1;
593
+ updLog(`npm install fallback OK (${elapsed2}ms): ${out2.slice(0, 500)}`);
594
+ appendStartupTrace("update: npm install fallback OK", { elapsedMs: elapsed2, outputLen: out2.length });
595
+ }
596
+ catch (e2) {
597
+ const elapsed2 = Date.now() - t1;
598
+ const err2 = e2;
599
+ updLog(`npm install fallback also failed (${elapsed2}ms): message=${err2.message}, stderr=${(err2.stderr || "").slice(0, 500)}`);
600
+ appendStartupTrace("update: npm install fallback failed", { elapsedMs: elapsed2, message: err2.message });
601
+ }
602
+ }
603
+ // 2. resolve bin path
604
+ const npmPrefix = process.env.NPM_PREFIX || "";
605
+ const binName = process.platform === "win32" ? "chatccc.cmd" : "chatccc";
606
+ const binPath = npmPrefix ? join(npmPrefix, binName) : "chatccc";
607
+ updLog(`bin path: npmPrefix=${npmPrefix || "(empty)"}, binPath=${binPath}`);
608
+ appendStartupTrace("update: spawn begin", { npmPrefix: npmPrefix || "(empty)", binPath });
609
+ // 3. spawn new chatccc:优先 node + 全局包入口绝对路径(不依赖 PATH/shell),
610
+ // 避免继承环境 PATH 异常时秒退;失败时回退到 binPath(走 shell)。
611
+ try {
612
+ let spawnSpec = null;
613
+ if (npmPrefix) {
614
+ const entry = join(npmPrefix, "node_modules", "chatccc", "bin", "chatccc.mjs");
615
+ if (existsSync(entry)) {
616
+ spawnSpec = { command: process.execPath, args: [entry] };
617
+ }
618
+ }
619
+ const child = spawnSpec
620
+ ? spawn(spawnSpec.command, spawnSpec.args, {
621
+ detached: true,
622
+ stdio: "ignore",
623
+ shell: false,
624
+ env: createInternalRestartEnv(),
625
+ })
626
+ : spawn(binPath, [], {
627
+ detached: true,
628
+ stdio: "ignore",
629
+ shell: true,
630
+ env: createInternalRestartEnv(),
631
+ });
632
+ child.unref();
633
+ const spawnedAs = spawnSpec ? `${spawnSpec.command} ${spawnSpec.args.join(" ")}` : binPath;
634
+ updLog(`spawn new chatccc OK, childPid=${child.pid}, bin=${spawnedAs}`);
635
+ appendStartupTrace("update: spawn OK", {
636
+ childPid: child.pid,
637
+ binPath: spawnSpec ? spawnSpec.args[0] : binPath,
638
+ });
639
+ return child;
640
+ }
641
+ catch (e) {
642
+ const errMsg = e.message;
643
+ updLog(`spawn new chatccc failed: ${errMsg}`);
644
+ appendStartupTrace("update: spawn failed", { error: errMsg });
645
+ return undefined;
646
+ }
647
+ }
648
+ // ---------------------------------------------------------------------------
649
+ // /restart — 自重启子进程(不经过 npx/npm,避免 PATH 注入秒退;防空窗兜底)
650
+ // ---------------------------------------------------------------------------
651
+ /** 父进程等待子进程稳定启动的时间窗口(毫秒)。 */
652
+ export const RESTART_CHILD_READY_MS = 3000;
653
+ /**
654
+ * 构建自重启的 spawn 参数:发布包直接运行编译后的 JavaScript;只有尚未
655
+ * build 的开发工作区才使用本地 tsx CLI。两种情况都不经过 npx/npm。
656
+ */
657
+ export function buildRestartSpawnSpec(projectRoot = PROJECT_ROOT) {
658
+ return resolveChatCccRuntimeSpawnSpec(projectRoot);
659
+ }
660
+ /**
661
+ * spawn 自重启子进程。
662
+ *
663
+ * stdout/stderr 按启动方式分流:
664
+ * - **终端(TTY)场景**(用户从 cmd/PowerShell/node.exe 窗口启动):stdio 用
665
+ * ["ignore", "inherit", "inherit"] 直接继承终端句柄,restart 后窗口日志不中断。
666
+ * 终端句柄的生命周期不随父进程退出而关闭,因此不存在 EPIPE 风险。
667
+ * - **非终端场景**(守护进程/黑匣子等管道或文件启动):stderr 重定向到磁盘日志
668
+ * 文件(restart-*.log),子进程继承文件句柄,父进程退出不影响写入。
669
+ * 不能用 pipe 收集:pipe 读端随父进程退出关闭后,子进程
670
+ * 再写 stderr(如飞书 SDK 内部 console.warn)会 EPIPE → uncaughtException
671
+ * → 整个服务崩溃。若日志文件打开失败,退回 pipe 收集(旧行为),并记录 trace。
672
+ */
673
+ export function spawnRestartChild(deps = {}) {
674
+ const projectRoot = deps.projectRoot ?? PROJECT_ROOT;
675
+ const spawnImpl = deps.spawnImpl ?? spawn;
676
+ const trace = deps.trace ?? appendStartupTrace;
677
+ const restartLogDir = deps.restartLogDir ?? LOG_DIR;
678
+ const isTty = deps.isTty ?? (() => process.stdout.isTTY === true || process.stderr.isTTY === true);
679
+ const { command, args } = buildRestartSpawnSpec(projectRoot);
680
+ let stderrFd;
681
+ const stdio = ["ignore", "ignore", "pipe"];
682
+ const tty = isTty();
683
+ if (tty) {
684
+ // 终端场景:全部 inherit(含 stdin)。注意 stdin 不能是 "ignore":
685
+ // Windows 上 detached + stdio[0]=ignore 的组合(DETACHED_PROCESS)会让
686
+ // 子进程丢失控制台关联,或在部分 Node/libuv 组合下触发 CREATE_NEW_CONSOLE
687
+ // 弹出新窗口——日志全跑到新窗口,用户当前窗口反而看不到。
688
+ // 全 inherit 让子进程直接复用当前终端句柄,父进程退出后窗口日志不中断,
689
+ // 且终端句柄不随父进程退出关闭,天然无 EPIPE 风险。
690
+ stdio[0] = "inherit";
691
+ stdio[1] = "inherit";
692
+ stdio[2] = "inherit";
693
+ }
694
+ else {
695
+ try {
696
+ mkdirSync(restartLogDir, { recursive: true });
697
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
698
+ const restartLogPath = join(restartLogDir, `restart-${timestamp}.log`);
699
+ stderrFd = openSync(restartLogPath, "a");
700
+ stdio[2] = stderrFd;
701
+ }
702
+ catch (err) {
703
+ trace("restart: stderr log open failed, falling back to pipe", {
704
+ error: err instanceof Error ? err.message : String(err),
705
+ });
706
+ }
707
+ }
708
+ // 运行时自检:记录本次 restart 子进程的启动方式,便于确认日志走向。
709
+ trace("restart: spawn child", {
710
+ isTty: tty,
711
+ stdio: JSON.stringify(stdio),
712
+ });
713
+ const child = spawnImpl(command, args, {
714
+ cwd: projectRoot,
715
+ detached: true,
716
+ stdio,
717
+ shell: false,
718
+ env: createInternalRestartEnv(),
719
+ });
720
+ // 子进程已继承 stderr 文件句柄;父进程关闭自己的副本,避免"子进程早退、
721
+ // 父进程留下继续服务"时 fd 泄漏。
722
+ if (stderrFd !== undefined) {
723
+ try {
724
+ closeSync(stderrFd);
725
+ }
726
+ catch { /* ignore */ }
727
+ }
728
+ child.on("error", (err) => {
729
+ trace("restart: spawn error", { error: err.message });
730
+ });
731
+ child.on("exit", (code, signal) => {
732
+ trace("restart: child exit", {
733
+ childPid: child.pid,
734
+ code,
735
+ signal,
736
+ });
737
+ });
738
+ return child;
739
+ }
740
+ /**
741
+ * 决定父进程是否应退出(防空窗兜底):
742
+ * - 窗口内子进程已退出(死亡或信号终止)→ 返回 false,父进程留下继续服务;
743
+ * - 子进程存活满整个窗口 → 返回 true,父进程退出并把端口让给新进程。
744
+ */
745
+ export async function decideRestartParentExit(child, timeoutMs, pollMs = 500, trace = appendStartupTrace) {
746
+ const deadline = Date.now() + timeoutMs;
747
+ while (Date.now() < deadline) {
748
+ if (child.exitCode !== null || child.signalCode !== null) {
749
+ trace("restart: child died during window, keeping parent", {
750
+ childPid: child.pid,
751
+ exitCode: child.exitCode,
752
+ signalCode: child.signalCode,
753
+ });
754
+ return false;
755
+ }
756
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
757
+ }
758
+ trace("restart: child alive after window, parent exiting", { childPid: child.pid });
759
+ return true;
760
+ }
761
+ // ---------------------------------------------------------------------------
762
+ // handleCommand — 平台无关的命令分发
763
+ // ---------------------------------------------------------------------------
764
+ export async function handleCommand(platform, text, chatId, openId, msgTimestamp, chatType = "group", traceId, commandId) {
765
+ const tid = traceId ?? makeTraceId();
766
+ const sharedPrefix = applySharedPrefix(text);
767
+ const promptText = sharedPrefix.text;
768
+ text = sharedPrefix.body;
769
+ const textLower = text.toLowerCase();
770
+ const isCommandText = !sharedPrefix.matched && textLower.startsWith("/");
771
+ recordChatPlatform(chatId, platform);
772
+ if (isCommandText && textLower === "/reload") {
773
+ logTrace(tid, "BRANCH", { cmd: "/reload" });
774
+ try {
775
+ const result = await reloadRuntimeConfig("chat-command");
776
+ await platform.sendText(chatId, [
777
+ "配置已重新加载。",
778
+ `默认 Agent: ${toolDisplayName(result.defaultAgent)}`,
779
+ `配置文件: ${result.configPath}`,
780
+ "后续新会话会使用最新配置;飞书私聊会在下一条普通消息时跟随默认 Agent,正在生成的会话不会被中断。",
781
+ ].join("\n")).catch(() => { });
782
+ logTrace(tid, "DONE", { outcome: "reload", defaultAgent: result.defaultAgent });
783
+ }
784
+ catch (err) {
785
+ await platform.sendText(chatId, `配置重载失败:${err.message}`).catch(() => { });
786
+ logTrace(tid, "DONE", { outcome: "reload_fail", error: err.message });
787
+ }
788
+ return;
789
+ }
790
+ if (isCommandText && textLower === "/restart") {
791
+ logTrace(tid, "BRANCH", { cmd: "/restart" });
792
+ await platform.sendText(chatId, "重启中...请几秒后发消息唤醒我").catch(() => { });
793
+ logTrace(tid, "DONE", { outcome: "restart" });
794
+ appendStartupTrace("restart: spawn begin", { fromPid: process.pid });
795
+ const child = spawnRestartChild();
796
+ child.unref();
797
+ // 子进程存活满窗口才退出父进程;若子进程在窗口内死亡,父进程留下继续
798
+ // 服务(防空窗),并已把子进程 stderr 写入 startup-trace 供排查。
799
+ void decideRestartParentExit(child, RESTART_CHILD_READY_MS).then((shouldExit) => {
800
+ if (!shouldExit)
801
+ return;
802
+ appendStartupTrace("restart: parent exit", { childPid: child.pid });
803
+ process.exit(0);
804
+ });
805
+ return;
806
+ }
807
+ if (isCommandText && textLower === "/update") {
808
+ logTrace(tid, "BRANCH", { cmd: "/update" });
809
+ const isGlobal = isRunningFromGlobalNpm();
810
+ appendStartupTrace("update: command received", { isGlobal, chatId });
811
+ if (!isGlobal) {
812
+ await platform.sendText(chatId, "当前进程非 npm 全局安装,无法使用 /update 更新。请通过 npm install -g chatccc 安装后使用。").catch(() => { });
813
+ logTrace(tid, "DONE", { outcome: "update_not_global" });
814
+ return;
815
+ }
816
+ // `/update` 会主动重启进程,内存 processedMessages 随之丢失。必须在发送
817
+ // “正在更新”以及执行 npm 命令之前同步落盘,才能挡住新进程收到的飞书重投。
818
+ // 该护栏只位于此分支,不改变普通消息和 `/restart` 的现有去重行为。
819
+ const updateGuard = acquireUpdateCommandGuard({ commandId });
820
+ appendStartupTrace("update: command guard checked", {
821
+ allowed: updateGuard.allowed,
822
+ reason: updateGuard.reason,
823
+ hasCommandId: Boolean(commandId),
824
+ });
825
+ if (!updateGuard.allowed) {
826
+ if (updateGuard.reason === "duplicate_id") {
827
+ // 同一条飞书消息的重投静默丢弃,避免用户再次看到重复提示。
828
+ logTrace(tid, "DONE", { outcome: "update_duplicate_id" });
829
+ return;
830
+ }
831
+ await platform.sendText(chatId, "无法写入更新保护状态。为避免连续更新和重启,本次 /update 未执行。").catch(() => { });
832
+ logTrace(tid, "DONE", { outcome: "update_guard_write_failed" });
833
+ return;
834
+ }
835
+ await platform.sendText(chatId, "正在更新并重启,请稍候...").catch(() => { });
836
+ logTrace(tid, "DONE", { outcome: "update" });
837
+ appendStartupTrace("update: sync update begin", { fromPid: process.pid });
838
+ const child = syncUpdateAndRestart();
839
+ if (child) {
840
+ // 子进程存活满窗口才退出父进程;若子进程在窗口内死亡,父进程留下继续
841
+ // 服务(防空窗)。
842
+ void decideRestartParentExit(child, RESTART_CHILD_READY_MS).then((shouldExit) => {
843
+ if (!shouldExit)
844
+ return;
845
+ appendStartupTrace("update: parent exit", { childPid: child.pid });
846
+ process.exit(0);
847
+ });
848
+ }
849
+ else {
850
+ // spawn 失败:没有子进程可等,给残留日志写入时间后退出
851
+ setTimeout(() => process.exit(0), 2000);
852
+ }
853
+ return;
854
+ }
855
+ if (isCommandText && textLower === "/usage") {
856
+ const usageTarget = await resolveUsageTarget(chatId);
857
+ const usageTool = usageTarget.tool;
858
+ const avatarStatus = usageTarget.sessionId && isSessionRunning(usageTarget.sessionId) ? "busy" : "idle";
859
+ logTrace(tid, "BRANCH", { cmd: "/usage", tool: usageTool });
860
+ try {
861
+ await sendUsageSummary(platform, chatId, usageTool, avatarStatus, usageTarget.sessionId);
862
+ logTrace(tid, "DONE", { outcome: "usage", tool: usageTool });
863
+ }
864
+ catch (err) {
865
+ await sendUsageError(platform, chatId, usageTool, err);
866
+ logTrace(tid, "DONE", { outcome: "usage_fail", tool: usageTool, error: err.message });
867
+ }
868
+ return;
869
+ }
870
+ if (isCommandText && (textLower === "/cd" || textLower.startsWith("/cd "))) {
871
+ logTrace(tid, "BRANCH", {
872
+ cmd: "/cd",
873
+ arg: text.slice(3).trim() || "(none)",
874
+ });
875
+ const currentDir = await getDefaultCwd(chatId);
876
+ // 获取当前会话的实际工作路径(若在会话群内)
877
+ let sessionCwd;
878
+ try {
879
+ const chatInfo = await platform.getChatInfo(chatId);
880
+ const sessionInfoResult = platform.extractSessionInfo(chatInfo.description);
881
+ if (sessionInfoResult) {
882
+ const adapter = getAdapterForTool(sessionInfoResult.tool, sessionInfoResult.sessionId);
883
+ const info = await adapter.getSessionInfo(sessionInfoResult.sessionId);
884
+ sessionCwd = info?.cwd;
885
+ }
886
+ }
887
+ catch {
888
+ /* 非会话群或获取失败,不显示 */
889
+ }
890
+ const arg = text.slice(3).trim();
891
+ // Resolve target directory
892
+ let targetDir;
893
+ if (!arg) {
894
+ targetDir = currentDir;
895
+ }
896
+ else if (arg === "..") {
897
+ targetDir = dirname(currentDir);
898
+ }
899
+ else {
900
+ targetDir = resolve(currentDir, arg);
901
+ }
902
+ // Verify the target exists and is a directory
903
+ try {
904
+ const s = await stat(targetDir);
905
+ if (!s.isDirectory()) {
906
+ logTrace(tid, "DONE", { outcome: "cd_not_dir", targetDir });
907
+ await platform.sendCard(chatId, "新会话工作路径", `路径存在但不是目录:\n\`${targetDir}\``, "red");
908
+ return;
909
+ }
910
+ }
911
+ catch {
912
+ logTrace(tid, "DONE", { outcome: "cd_not_found", targetDir });
913
+ await platform.sendCard(chatId, "新会话工作路径", `路径不存在:\n\`${targetDir}\``, "red");
914
+ return;
915
+ }
916
+ // Change working dir if user provided a path
917
+ const isUpdate = !!arg && targetDir !== currentDir;
918
+ if (isUpdate) {
919
+ await setDefaultCwd(targetDir, chatId);
920
+ await addRecentDir(targetDir);
921
+ }
922
+ // Read directory entries
923
+ let entries;
924
+ try {
925
+ entries = await readdir(targetDir);
926
+ }
927
+ catch (err) {
928
+ logTrace(tid, "DONE", {
929
+ outcome: "cd_readdir_fail",
930
+ error: err.message,
931
+ });
932
+ await platform.sendCard(chatId, "新会话工作路径", `无法读取目录:\n\`${targetDir}\`\n\n${err.message}`, "red");
933
+ return;
934
+ }
935
+ // Sort: directories first, then files, alphabetically within each group
936
+ const withStats = [];
937
+ for (const name of entries) {
938
+ try {
939
+ const s = await stat(resolve(targetDir, name));
940
+ withStats.push({ name, isDir: s.isDirectory() });
941
+ }
942
+ catch {
943
+ withStats.push({ name, isDir: false });
944
+ }
945
+ }
946
+ withStats.sort((a, b) => {
947
+ if (a.isDir !== b.isDir)
948
+ return a.isDir ? -1 : 1;
949
+ return a.name.localeCompare(b.name);
950
+ });
951
+ if (!arg) {
952
+ // /cd 无参数:展示卡片(含最近使用路径按钮)
953
+ const recentDirs = await getRecentDirs();
954
+ const card = buildCdCard(targetDir, withStats, recentDirs, sessionCwd);
955
+ const ok = await platform.sendRawCard(chatId, card);
956
+ console.log(`[${ts()}] [CD] card sent, ok=${ok}, recentDirs=${recentDirs.length}`);
957
+ logTrace(tid, "DONE", { outcome: "cd_card", ok });
958
+ }
959
+ else {
960
+ // /cd <path>:切换目录,发送文本卡片
961
+ const content = buildCdContent(targetDir, withStats, isUpdate, sessionCwd);
962
+ await platform.sendCard(chatId, "新会话工作路径", content, "blue");
963
+ logTrace(tid, "DONE", { outcome: "cd_path", targetDir, isUpdate });
964
+ // 微信模式下,若用户没有活跃会话,自动创建新会话
965
+ if (platform.kind === "wechat" && !sessionInfoMap.has(chatId)) {
966
+ logTrace(tid, "BRANCH", { cmd: "/new", trigger: "auto_after_cd" });
967
+ await handleCommand(platform, "/new", chatId, openId, msgTimestamp, chatType, traceId);
968
+ }
969
+ }
970
+ return;
971
+ }
972
+ if (isCommandText && (textLower === "/new" || textLower.startsWith("/new "))) {
973
+ const toolArg = text.slice(5).trim().toLowerCase();
974
+ const tool = toolArg || resolveDefaultAgentTool();
975
+ logTrace(tid, "BRANCH", { cmd: "/new", tool });
976
+ const validTools = ["claude", "cursor", "codex", "ccc"];
977
+ if (!validTools.includes(tool)) {
978
+ logTrace(tid, "DONE", { outcome: "new_invalid_tool", tool });
979
+ await platform.sendCard(chatId, "Error", `未知的工具类型: "${toolArg}"。支持: claude (Claude Code), cursor (Cursor), codex (Codex), ccc (CCC Agent)。`, "red");
980
+ return;
981
+ }
982
+ const toolLabel = toolDisplayName(tool);
983
+ if (!openId) {
984
+ logTrace(tid, "DONE", { outcome: "new_no_openid" });
985
+ console.log(`[${ts()}] [WARN] Cannot get sender open_id`);
986
+ await platform.sendCard(chatId, "Error", "Cannot identify sender.", "red");
987
+ return;
988
+ }
989
+ let sessionId;
990
+ let sessionCwd;
991
+ try {
992
+ const init = await initClaudeSession(tool, undefined, chatId);
993
+ sessionId = init.sessionId;
994
+ sessionCwd = init.cwd;
995
+ console.log(`[${ts()}] [STEP 1/4] ${toolLabel} session created: ${sessionId} → OK`);
996
+ }
997
+ catch (err) {
998
+ console.error(`[${ts()}] [STEP 1/4] FAIL: ${err.message}`);
999
+ logTrace(tid, "DONE", {
1000
+ outcome: "new_session_fail",
1001
+ error: err.message,
1002
+ });
1003
+ await platform.sendCard(chatId, "Error", `Failed to initialize ${toolLabel} session:\n${err.message}`, "red");
1004
+ return;
1005
+ }
1006
+ const cwd = sessionCwd;
1007
+ const initialName = sessionChatName("新会话", cwd);
1008
+ // /new 的平台语义保持不同:微信在当前私聊新建 session;飞书显式
1009
+ // /new 始终创建独立群聊。飞书私聊自己的常驻 session 不在这里切换。
1010
+ if (chatType === "p2p" && platform.kind === "wechat") {
1011
+ // 先解绑旧 session(如果存在),避免旧 session 的 display loop
1012
+ // 继续往同一个 chat 推送内容(/newh 走 switchChatBinding 已有此逻辑,
1013
+ // 但 /new p2p 之前遗漏了解绑)。
1014
+ const oldRegistry = await loadSessionRegistryForBinding();
1015
+ const oldRecord = oldRegistry[chatId];
1016
+ if (oldRecord?.sessionId && oldRecord.sessionId !== sessionId) {
1017
+ unbindChatFromSession(oldRecord.sessionId, chatId);
1018
+ displayCards.delete(chatId);
1019
+ cancelQueuedMessage(oldRecord.sessionId);
1020
+ }
1021
+ bindChatToSession(sessionId, chatId);
1022
+ sessionInfoMap.set(chatId, {
1023
+ sessionId,
1024
+ turnCount: 0,
1025
+ lastContextTokens: 0,
1026
+ startTime: Date.now(),
1027
+ tool,
1028
+ });
1029
+ await setDefaultCwd(cwd, chatId);
1030
+ await recordSessionRegistry({
1031
+ chatId,
1032
+ sessionId,
1033
+ tool,
1034
+ chatType,
1035
+ chatName: initialName,
1036
+ turnCount: 0,
1037
+ startTime: Date.now(),
1038
+ running: false,
1039
+ });
1040
+ await saveSessionTool(sessionId, tool, initialName);
1041
+ await platform.sendCard(chatId, `${toolLabel} Session Ready`, `这是你的 **${toolLabel}** 私聊会话。\n\n` +
1042
+ `**Session ID:** ${sessionId}\n` +
1043
+ `**工作目录:** \`${cwd}\`\n\n` +
1044
+ `直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
1045
+ `发送 **/cd** 切换新建会话的默认目录。\n` +
1046
+ `发送 **/model** 查看或切换当前会话的模型。${fastHelpAfterModel(tool)}\n` +
1047
+ `发送 **/new** 创建新会话,**/newh** 重置当前会话(沿用工作目录)。\n` +
1048
+ `发送 **/sessions** 查看所有会话状态。\n` +
1049
+ `发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5\`。` +
1050
+ usageHelpLine(tool), "green");
1051
+ console.log(`[${ts()}] [NEW] P2P session created: ${sessionId} (${toolLabel})`);
1052
+ logTrace(tid, "DONE", {
1053
+ outcome: "session_ready_p2p",
1054
+ chatId,
1055
+ sessionId,
1056
+ tool,
1057
+ });
1058
+ return;
1059
+ }
1060
+ let newChatId;
1061
+ try {
1062
+ newChatId = await platform.createGroup(initialName, [openId]);
1063
+ console.log(`[${ts()}] [STEP 2/4] Created Feishu group: ${newChatId} → OK`);
1064
+ }
1065
+ catch (err) {
1066
+ console.error(`[${ts()}] [STEP 2/4] FAIL: ${err.message}`);
1067
+ logTrace(tid, "DONE", {
1068
+ outcome: "new_group_fail",
1069
+ error: err.message,
1070
+ });
1071
+ await platform.sendCard(chatId, "Error", `Failed to create group:\n${err.message}`, "red");
1072
+ return;
1073
+ }
1074
+ try {
1075
+ const descPrefix = sessionPrefixForTool(tool);
1076
+ await platform.updateChatInfo(newChatId, initialName, `${descPrefix} ${sessionId}`);
1077
+ console.log(`[${ts()}] [STEP 3/4] Renamed group → name="${initialName}" (${toolLabel}) → OK`);
1078
+ }
1079
+ catch (err) {
1080
+ console.error(`[${ts()}] [STEP 3/4] FAIL: ${err.message}`);
1081
+ logTrace(tid, "DONE", {
1082
+ outcome: "new_rename_fail",
1083
+ error: err.message,
1084
+ });
1085
+ await platform.sendCard(chatId, "Error", `Group created but rename failed:\n${err.message}`, "yellow");
1086
+ return;
1087
+ }
1088
+ // 让新群的默认工作目录继承当前会话的 cwd
1089
+ await setDefaultCwd(cwd, newChatId);
1090
+ bindChatToSession(sessionId, newChatId);
1091
+ await recordSessionRegistry({
1092
+ chatId: newChatId,
1093
+ sessionId,
1094
+ tool,
1095
+ chatType: "group",
1096
+ chatName: initialName,
1097
+ turnCount: 0,
1098
+ startTime: Date.now(),
1099
+ running: false,
1100
+ });
1101
+ await saveSessionTool(sessionId, tool, initialName);
1102
+ await platform.sendCard(newChatId, `${toolLabel} Session Ready`, `群聊已创建,这是你的 **${toolLabel}** 会话群。\n\n` +
1103
+ `**Session ID:** ${sessionId}\n` +
1104
+ `**工作目录:** \`${cwd}\`\n\n` +
1105
+ `直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
1106
+ `发送 **/cd** 切换新建会话的默认目录。\n` +
1107
+ `发送 **/model** 查看或切换当前会话的模型。${fastHelpAfterModel(tool)}\n` +
1108
+ `发送 **/new** 创建新会话,**/newh** 重置当前会话(沿用工作目录)。\n` +
1109
+ `发送 **/sessions** 查看所有会话状态。\n` +
1110
+ `发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5\`。` +
1111
+ usageHelpLine(tool), "green");
1112
+ console.log(`[${ts()}] [STEP 4/4] Replied to new group → OK`);
1113
+ logTrace(tid, "DONE", {
1114
+ outcome: "session_ready",
1115
+ newChatId,
1116
+ sessionId,
1117
+ tool,
1118
+ });
1119
+ setChatAvatarForSession(platform, newChatId, tool, "new", sessionId).catch(() => { });
1120
+ console.log(`${"=".repeat(60)}`);
1121
+ return;
1122
+ }
1123
+ // 检测会话上下文:群聊从 description 获取,飞书/微信私聊都从
1124
+ // session-registry 获取。私聊 chatId 是稳定容器,进程重启后仍恢复绑定。
1125
+ let sessionId = null;
1126
+ let descriptionTool = null;
1127
+ let toolLabel = null;
1128
+ let pendingFeishuP2pDefaultTool = null;
1129
+ let chatInfo;
1130
+ let description;
1131
+ if (chatType !== "p2p") {
1132
+ try {
1133
+ chatInfo = await platform.getChatInfo(chatId);
1134
+ description = chatInfo.description;
1135
+ const sessionInfo = platform.extractSessionInfo(description);
1136
+ if (sessionInfo) {
1137
+ sessionId = sessionInfo.sessionId;
1138
+ descriptionTool = sessionInfo.tool;
1139
+ toolLabel = toolDisplayName(descriptionTool);
1140
+ // 群描述是群聊会话路由的权威来源。历史群可能早于 registry 创建,
1141
+ // 或在冷启动时没有被重建进内存映射;若只解析 sessionId 而不补绑定,
1142
+ // prompt 虽能启动,却找不到生成卡片目标,收尾也无法清除 running。
1143
+ const registry = await loadSessionRegistryForBinding();
1144
+ const record = registry[chatId];
1145
+ if (record?.sessionId && record.sessionId !== sessionId) {
1146
+ unbindChatFromSession(record.sessionId, chatId);
1147
+ }
1148
+ bindChatToSession(sessionId, chatId);
1149
+ const memoryInfo = sessionInfoMap.get(chatId);
1150
+ if (!memoryInfo || memoryInfo.sessionId !== sessionId) {
1151
+ sessionInfoMap.set(chatId, {
1152
+ sessionId,
1153
+ tool: descriptionTool,
1154
+ turnCount: record?.sessionId === sessionId ? record.turnCount : 0,
1155
+ lastContextTokens: record?.sessionId === sessionId ? record.lastContextTokens : 0,
1156
+ startTime: record?.sessionId === sessionId
1157
+ ? record.startTime
1158
+ : Date.now(),
1159
+ });
1160
+ }
1161
+ // 同步自愈持久化记录,使下一次重启可以直接重建绑定。running 取实际
1162
+ // 内存状态,顺便修复旧故障遗留的 stale running=true。
1163
+ await recordSessionRegistry({
1164
+ chatId,
1165
+ sessionId,
1166
+ tool: descriptionTool,
1167
+ chatType,
1168
+ chatName: chatInfo.name,
1169
+ running: isSessionRunning(sessionId),
1170
+ });
1171
+ }
1172
+ }
1173
+ catch (err) {
1174
+ logTrace(tid, "BRANCH", {
1175
+ reason: "get_chat_info_failed",
1176
+ error: err.message,
1177
+ });
1178
+ console.log(`[${ts()}] [INFO] Cannot get chat info for ${chatId}: ${err.message}`);
1179
+ }
1180
+ }
1181
+ else if (platform.kind === "wechat" || platform.kind === "feishu") {
1182
+ // 私聊没有可写的群描述,因此会话绑定只持久化在 session-registry.json。
1183
+ try {
1184
+ const registry = await loadSessionRegistryForBinding();
1185
+ const record = registry[chatId];
1186
+ // 旧版飞书曾把私聊视为建群入口,并会清理私聊 registry。没有 p2p
1187
+ // 标记的残留记录不能证明它是在固定用户目录创建的,因此只迁移一次:
1188
+ // 先解除旧绑定;若本条是普通消息,随后在用户目录创建新的私聊 session。
1189
+ if (platform.kind === "feishu" && record?.sessionId && record.chatType !== "p2p") {
1190
+ unbindChatFromSession(record.sessionId, chatId);
1191
+ displayCards.delete(chatId);
1192
+ cancelQueuedMessage(record.sessionId);
1193
+ sessionInfoMap.delete(chatId);
1194
+ await removeSessionRegistryRecord(chatId);
1195
+ logTrace(tid, "BRANCH", {
1196
+ reason: "migrate_legacy_feishu_p2p_binding",
1197
+ chatId,
1198
+ oldSessionId: record.sessionId,
1199
+ });
1200
+ }
1201
+ else if (record && record.sessionId && record.tool) {
1202
+ let resolvedRecord = record;
1203
+ if (platform.kind === "feishu" && !isCommandText && record.chatType === "p2p") {
1204
+ const resolution = await resolveFeishuP2pAgent(platform, chatId, text, record, tid);
1205
+ if (resolution.kind === "error") {
1206
+ const previousLabel = toolDisplayName(resolution.previousTool);
1207
+ const desiredLabel = toolDisplayName(resolution.desiredTool);
1208
+ console.error(`[${ts()}] [P2P-SWITCH] ${previousLabel} -> ${desiredLabel} FAIL: ${resolution.error.message}`);
1209
+ logTrace(tid, "DONE", {
1210
+ outcome: "switch_feishu_p2p_default_agent_fail",
1211
+ oldTool: resolution.previousTool,
1212
+ newTool: resolution.desiredTool,
1213
+ error: resolution.error.message,
1214
+ });
1215
+ await platform.sendCard(chatId, "Agent 切换失败", `无法从 ${previousLabel} 切换到 ${desiredLabel}:\n${resolution.error.message}`, "red").catch(() => { });
1216
+ return;
1217
+ }
1218
+ resolvedRecord = resolution;
1219
+ if (resolution.kind === "waiting") {
1220
+ pendingFeishuP2pDefaultTool = resolution.desiredTool;
1221
+ }
1222
+ }
1223
+ sessionId = resolvedRecord.sessionId;
1224
+ descriptionTool = resolvedRecord.tool;
1225
+ toolLabel = toolDisplayName(descriptionTool);
1226
+ // 确保内存状态在冷启动后恢复;bindChatToSession 是幂等的。
1227
+ if (!sessionInfoMap.has(chatId)) {
1228
+ sessionInfoMap.set(chatId, {
1229
+ sessionId,
1230
+ turnCount: record.turnCount ?? 0,
1231
+ lastContextTokens: record.lastContextTokens ?? 0,
1232
+ startTime: record.startTime ?? Date.now(),
1233
+ tool: descriptionTool,
1234
+ });
1235
+ }
1236
+ bindChatToSession(sessionId, chatId);
1237
+ }
1238
+ }
1239
+ catch (err) {
1240
+ console.log(`[${ts()}] [INFO] Cannot load registry for p2p ${chatId}: ${err.message}`);
1241
+ }
1242
+ }
1243
+ if (sessionId && descriptionTool && toolLabel) {
1244
+ // 有会话上下文 — 路由到命令处理或 prompt
1245
+ logTrace(tid, "BRANCH", { sessionId, tool: descriptionTool });
1246
+ const routeKind = isCommandText ? "command" : "prompt";
1247
+ const chatKind = chatType === "p2p" ? "p2p chat" : "session group";
1248
+ console.log(`[${ts()}] [ROUTE] ${toolLabel} ${chatKind} ${routeKind} detected, session=${sessionId} tool=${descriptionTool}`);
1249
+ if (chatType !== "p2p" &&
1250
+ isUntitledSessionChatName(chatInfo.name) &&
1251
+ !isCommandText) {
1252
+ const MAX_PREFIX = 10;
1253
+ const prefix = text.slice(0, MAX_PREFIX);
1254
+ const adapter = getAdapterForTool(descriptionTool, sessionId);
1255
+ const info = await adapter
1256
+ .getSessionInfo(sessionId)
1257
+ .catch(() => undefined);
1258
+ const sessionCwd = info?.cwd ?? (await getDefaultCwd(chatId));
1259
+ const newName = sessionChatName(prefix, sessionCwd);
1260
+ try {
1261
+ await platform.updateChatInfo(chatId, newName, description);
1262
+ console.log(`[${ts()}] [RENAME] First message → group renamed to "${newName}"`);
1263
+ await recordSessionRegistry({
1264
+ chatId,
1265
+ sessionId,
1266
+ tool: descriptionTool,
1267
+ chatName: newName,
1268
+ }).catch(() => { });
1269
+ await saveSessionTool(sessionId, descriptionTool, newName).catch(() => { });
1270
+ }
1271
+ catch (err) {
1272
+ console.error(`[${ts()}] [RENAME] Failed: ${err.message}`);
1273
+ }
1274
+ }
1275
+ // P2P:首条非指令消息只更新 registry 中的展示名,不修改私聊信息。
1276
+ if (chatType === "p2p" &&
1277
+ (platform.kind === "wechat" || platform.kind === "feishu") &&
1278
+ !isCommandText) {
1279
+ try {
1280
+ const reg = await loadSessionRegistryForBinding();
1281
+ const rec = reg[chatId];
1282
+ if (rec &&
1283
+ rec.sessionId === sessionId &&
1284
+ isUntitledSessionChatName(rec.chatName ?? "")) {
1285
+ const MAX_PREFIX = 10;
1286
+ const prefix = text.slice(0, MAX_PREFIX);
1287
+ const adapter = getAdapterForTool(descriptionTool, sessionId);
1288
+ const info = await adapter
1289
+ .getSessionInfo(sessionId)
1290
+ .catch(() => undefined);
1291
+ const sessionCwd = info?.cwd ?? (await getDefaultCwd(chatId));
1292
+ const newName2 = sessionChatName(prefix, sessionCwd);
1293
+ await recordSessionRegistry({
1294
+ chatId,
1295
+ sessionId,
1296
+ tool: descriptionTool,
1297
+ chatName: newName2,
1298
+ }).catch(() => { });
1299
+ await saveSessionTool(sessionId, descriptionTool, newName2).catch(() => { });
1300
+ console.log(`[${ts()}] [RENAME] ${platform.kind} P2P → "${newName2}"`);
1301
+ }
1302
+ }
1303
+ catch (err) {
1304
+ console.error(`[${ts()}] [RENAME] ${platform.kind} P2P failed: ${err.message}`);
1305
+ }
1306
+ }
1307
+ if (isCommandText && textLower === "/stop") {
1308
+ logTrace(tid, "BRANCH", { cmd: "/stop" });
1309
+ if (stopSession(sessionId)) {
1310
+ console.log(`[${ts()}] [STOP] User sent /stop, session=${sessionId}`);
1311
+ logTrace(tid, "DONE", { outcome: "stop_requested" });
1312
+ }
1313
+ else {
1314
+ await platform
1315
+ .sendText(chatId, "当前没有正在进行的会话。")
1316
+ .catch(() => { });
1317
+ logTrace(tid, "DONE", { outcome: "stop_no_session" });
1318
+ }
1319
+ return;
1320
+ }
1321
+ if (isCommandText && textLower === "/cancel") {
1322
+ logTrace(tid, "BRANCH", { cmd: "/cancel" });
1323
+ if (cancelQueuedMessage(sessionId)) {
1324
+ console.log(`[${ts()}] [CANCEL] Queue cancelled for session=${sessionId}`);
1325
+ await platform.sendText(chatId, "已取消缓存队列中的消息。").catch(() => { });
1326
+ logTrace(tid, "DONE", { outcome: "cancelled" });
1327
+ }
1328
+ else {
1329
+ await platform.sendText(chatId, "当前缓存队列中没有消息。").catch(() => { });
1330
+ logTrace(tid, "DONE", { outcome: "cancel_no_queue" });
1331
+ }
1332
+ return;
1333
+ }
1334
+ if (isCommandText && textLower === "/test") {
1335
+ logTrace(tid, "BRANCH", { cmd: "/test" });
1336
+ const tableHeaders = ["名称", "版本", "状态"];
1337
+ const tableRows = [
1338
+ ["ChatCCC", "0.2.96", "运行中"],
1339
+ ["Claude SDK", "0.50.0", "已连接"],
1340
+ ["Feishu API", "v1", "正常"],
1341
+ ];
1342
+ const mdTable = [
1343
+ `| ${tableHeaders.join(" | ")} |`,
1344
+ `| ${tableHeaders.map(() => "---").join(" | ")} |`,
1345
+ ...tableRows.map((row) => `| ${row.join(" | ")} |`),
1346
+ ].join("\n");
1347
+ if (platform.kind === "feishu") {
1348
+ try {
1349
+ const token = await getTenantAccessToken();
1350
+ const postContent = [
1351
+ // 先尝试富文本表格
1352
+ [{ tag: "table", cells: [tableHeaders, ...tableRows] }],
1353
+ // 再用代码块包起来
1354
+ [{ tag: "text", text: `\n表格(代码块格式):\n\`\`\`\n${mdTable}\n\`\`\`` }],
1355
+ ];
1356
+ await sendPostMessage(token, chatId, "测试表格", postContent);
1357
+ }
1358
+ catch (err) {
1359
+ console.error(`[${ts()}] [TEST] post message failed: ${err.message}`);
1360
+ // Fallback to markdown card
1361
+ await platform.sendText(chatId, `表格(代码块格式):\n\`\`\`\n${mdTable}\n\`\`\``).catch(() => { });
1362
+ }
1363
+ }
1364
+ else {
1365
+ // WeChat / other platforms: just send code block
1366
+ await platform.sendText(chatId, `表格(代码块格式):\n\`\`\`\n${mdTable}\n\`\`\``).catch(() => { });
1367
+ }
1368
+ logTrace(tid, "DONE", { outcome: "test" });
1369
+ return;
1370
+ }
1371
+ if (isCommandText && textLower === "/state") {
1372
+ logTrace(tid, "BRANCH", { cmd: "/state" });
1373
+ await sendStateCard(platform, chatId, sessionId, toolLabel, tid);
1374
+ return;
1375
+ }
1376
+ if (isCommandText && textLower === "/sessions") {
1377
+ logTrace(tid, "BRANCH", { cmd: "/sessions" });
1378
+ const allSessions = await getAllSessionsStatus();
1379
+ const now = Date.now();
1380
+ const cardData = allSessions.map((s) => ({
1381
+ sessionId: s.sessionId,
1382
+ chatName: s.chatName,
1383
+ chatId: s.chatId,
1384
+ chatType: s.chatType,
1385
+ active: s.active,
1386
+ turnCount: s.turnCount,
1387
+ elapsedSeconds: s.active
1388
+ ? Math.floor((now - s.startTime) / 1000)
1389
+ : null,
1390
+ model: s.model,
1391
+ tool: s.tool,
1392
+ }));
1393
+ const card = buildSessionsCard(cardData, {
1394
+ defaultToolLabel: toolDisplayName(resolveDefaultAgentTool()),
1395
+ fixedPrivateSession: isFeishuP2p(platform, chatType),
1396
+ });
1397
+ const ok = await platform.sendRawCard(chatId, card);
1398
+ console.log(`[${ts()}] [SESSIONS] card sent, ok=${ok}, count=${cardData.length}`);
1399
+ logTrace(tid, "DONE", { outcome: "sessions", ok, count: cardData.length });
1400
+ return;
1401
+ }
1402
+ if (isCommandText && textLower === "/newh") {
1403
+ logTrace(tid, "BRANCH", { cmd: "/newh" });
1404
+ let cwd;
1405
+ if (isFeishuP2p(platform, chatType)) {
1406
+ // 飞书私聊不支持切换工作目录。即使 /cd 已为后续 /new 群聊
1407
+ // 保存了其他默认目录,/newh 仍必须在运行 ChatCCC 的用户目录重建。
1408
+ cwd = homedir();
1409
+ }
1410
+ else {
1411
+ const adapter = getAdapterForTool(descriptionTool, sessionId);
1412
+ try {
1413
+ const info = await adapter.getSessionInfo(sessionId);
1414
+ cwd = info?.cwd ?? (await getDefaultCwd(chatId));
1415
+ }
1416
+ catch {
1417
+ cwd = await getDefaultCwd(chatId);
1418
+ }
1419
+ }
1420
+ // 第一步:创建新 session(此时尚未碰任何内存绑定,失败可直接返回,
1421
+ // 旧 session 状态完全保留)。
1422
+ let newSessionId;
1423
+ try {
1424
+ const init = await initClaudeSession(descriptionTool, cwd);
1425
+ newSessionId = init.sessionId;
1426
+ }
1427
+ catch (err) {
1428
+ logTrace(tid, "DONE", {
1429
+ outcome: "newh_session_fail",
1430
+ error: err.message,
1431
+ });
1432
+ await platform.sendCard(chatId, "Error", `Failed to create new session:\n${err.message}`, "red");
1433
+ return;
1434
+ }
1435
+ // 第二步:事务式切换 chat 绑定
1436
+ const descPrefix = sessionPrefixForTool(descriptionTool);
1437
+ const newName = sessionChatName("新会话", cwd);
1438
+ const switchResult = await switchChatBinding({
1439
+ chatId,
1440
+ chatType,
1441
+ oldSessionId: sessionId,
1442
+ newSessionId,
1443
+ tool: descriptionTool,
1444
+ chatName: newName,
1445
+ newDescription: `${descPrefix} ${newSessionId}`,
1446
+ updateChatInfoFn: (cid, name, desc) => platform.updateChatInfo(cid, name, desc),
1447
+ });
1448
+ if (!switchResult.ok) {
1449
+ logTrace(tid, "DONE", {
1450
+ outcome: "newh_update_chat_fail",
1451
+ error: switchResult.error?.message,
1452
+ });
1453
+ await platform.sendCard(chatId, "Error", `更新群描述失败,会话未切换(新 session 已创建但未启用):\n${switchResult.error?.message}`, "red");
1454
+ return;
1455
+ }
1456
+ if (chatType !== "p2p") {
1457
+ console.log(`[${ts()}] [NEWH] Group updated: name="${newName}" desc="${descPrefix} ${newSessionId}"`);
1458
+ }
1459
+ setChatAvatarForSession(platform, chatId, descriptionTool, "new", newSessionId).catch(() => { });
1460
+ await platform.sendCard(chatId, `${toolLabel} Session Reset`, `会话已重置为新的 **${toolLabel}** 会话。\n\n` +
1461
+ `**Session ID:** ${newSessionId}\n` +
1462
+ `**工作目录:** \`${cwd}\`${isFeishuP2p(platform, chatType) ? "(飞书私聊固定使用系统用户目录)" : "(沿用当前会话目录)"}\n\n` +
1463
+ `直接在这里发消息即可继续对话。\n` +
1464
+ `发送 **/cd** 可切换新建会话的默认目录。\n` +
1465
+ `发送 **/model** 查看或切换当前会话的模型。${fastHelpAfterModel(descriptionTool)}`, "green");
1466
+ console.log(`[${ts()}] [NEWH] Session ${sessionId} → ${newSessionId} (same cwd=${cwd})`);
1467
+ logTrace(tid, "DONE", { outcome: "newh", newSessionId, cwd });
1468
+ return;
1469
+ }
1470
+ if (isCommandText && textLower === "/deleteg") {
1471
+ logTrace(tid, "BRANCH", { cmd: "/deleteg" });
1472
+ if (chatType === "p2p") {
1473
+ await platform
1474
+ .sendText(chatId, "私聊无法使用 /deleteg,该指令仅用于群聊。")
1475
+ .catch(() => { });
1476
+ logTrace(tid, "DONE", { outcome: "deleteg_p2p" });
1477
+ return;
1478
+ }
1479
+ console.log(`[${ts()}] [DELETEG] Disbanding group chat ${chatId}, session=${sessionId}`);
1480
+ // 先解绑 session(不删除 Agent 会话)
1481
+ unbindChatFromSession(sessionId, chatId);
1482
+ displayCards.delete(chatId);
1483
+ sessionInfoMap.delete(chatId);
1484
+ await removeSessionRegistryRecord(chatId);
1485
+ await platform
1486
+ .sendText(chatId, "群聊已解散,Agent 会话保留。")
1487
+ .catch(() => { });
1488
+ // 解散群聊
1489
+ try {
1490
+ await platform.disbandChat(chatId);
1491
+ console.log(`[${ts()}] [DELETEG] Group disbanded: ${chatId}`);
1492
+ }
1493
+ catch (err) {
1494
+ console.error(`[${ts()}] [DELETEG] Disband API failed: ${err.message}`);
1495
+ }
1496
+ logTrace(tid, "DONE", { outcome: "deleteg", chatId, sessionId });
1497
+ return;
1498
+ }
1499
+ // /session <number>:切换到 /sessions 列表中的指定会话
1500
+ const sessionMatch = isCommandText ? textLower.match(/^\/session\s+(\d+)$/) : null;
1501
+ if (sessionMatch) {
1502
+ // 飞书私聊有自己唯一的常驻 session;历史群聊 session 只能回到对应群聊
1503
+ // 继续,禁止通过 /session 把它们重新绑定进私聊。
1504
+ if (isFeishuP2p(platform, chatType)) {
1505
+ await platform.sendCard(chatId, "/session", "飞书私聊不能通过 /session 切换到历史群聊会话;它会在下一条普通消息时跟随默认 Agent。请回到对应群聊继续,或发送 /new 新建群聊。", "yellow");
1506
+ logTrace(tid, "DONE", { outcome: "session_switch_disabled_feishu_p2p" });
1507
+ return;
1508
+ }
1509
+ const index = parseInt(sessionMatch[1], 10) - 1;
1510
+ logTrace(tid, "BRANCH", { cmd: "/session", index: index + 1 });
1511
+ const allSessions = await getAllSessionsStatus();
1512
+ const claudeOrdered = allSessions.filter((s) => s.tool !== "cursor" && s.tool !== "codex");
1513
+ const cursorOrdered = allSessions.filter((s) => s.tool === "cursor");
1514
+ const codexOrdered = allSessions.filter((s) => s.tool === "codex");
1515
+ const ordered = [
1516
+ ...claudeOrdered,
1517
+ ...cursorOrdered,
1518
+ ...codexOrdered,
1519
+ ];
1520
+ if (ordered.length === 0) {
1521
+ await platform.sendCard(chatId, "/session", "暂无历史会话。", "yellow");
1522
+ logTrace(tid, "DONE", { outcome: "session_no_sessions" });
1523
+ return;
1524
+ }
1525
+ if (index < 0 || index >= ordered.length) {
1526
+ await platform.sendCard(chatId, "/session", `序号超出范围,当前共 ${ordered.length} 个会话。`, "yellow");
1527
+ logTrace(tid, "DONE", {
1528
+ outcome: "session_out_of_range",
1529
+ index: index + 1,
1530
+ total: ordered.length,
1531
+ });
1532
+ return;
1533
+ }
1534
+ const target = ordered[index];
1535
+ // 切换到当前已在使用的会话:no-op,避免解绑再重绑的抖动
1536
+ if (target.sessionId === sessionId) {
1537
+ await platform.sendCard(chatId, "/session", "已经是当前会话。", "green");
1538
+ logTrace(tid, "DONE", { outcome: "session_already_current", sessionId });
1539
+ return;
1540
+ }
1541
+ const targetAdapter = getAdapterForTool(target.tool, target.sessionId);
1542
+ let cwd2;
1543
+ try {
1544
+ const targetInfo = await targetAdapter.getSessionInfo(target.sessionId);
1545
+ cwd2 = targetInfo?.cwd ?? (await getDefaultCwd(chatId));
1546
+ }
1547
+ catch {
1548
+ cwd2 = await getDefaultCwd(chatId);
1549
+ }
1550
+ const descPrefix2 = sessionPrefixForTool(target.tool);
1551
+ const newName2 = target.chatName || sessionChatName("新会话", cwd2);
1552
+ const switchResult = await switchChatBinding({
1553
+ chatId,
1554
+ chatType,
1555
+ oldSessionId: sessionId,
1556
+ newSessionId: target.sessionId,
1557
+ tool: target.tool,
1558
+ chatName: newName2,
1559
+ newDescription: `${descPrefix2} ${target.sessionId}`,
1560
+ initialTurnCount: target.turnCount,
1561
+ initialContextTokens: 0,
1562
+ updateChatInfoFn: (cid, name, desc) => platform.updateChatInfo(cid, name, desc),
1563
+ });
1564
+ if (!switchResult.ok) {
1565
+ logTrace(tid, "DONE", {
1566
+ outcome: "session_update_chat_fail",
1567
+ error: switchResult.error?.message,
1568
+ });
1569
+ await platform.sendCard(chatId, "Error", `更新群描述失败,会话未切换:\n${switchResult.error?.message}`, "red");
1570
+ return;
1571
+ }
1572
+ if (chatType !== "p2p") {
1573
+ console.log(`[${ts()}] [SESSION] Switched to session ${target.sessionId} (#${index + 1}), name="${newName2}"`);
1574
+ }
1575
+ setChatAvatarForSession(platform, chatId, target.tool, "new", target.sessionId).catch(() => { });
1576
+ const targetToolLabel = toolDisplayName(target.tool);
1577
+ const busyNote = isSessionRunning(target.sessionId)
1578
+ ? "\n\n⚠️ 该会话当前正在生成中,请等待完成后再发送消息。"
1579
+ : "";
1580
+ await platform.sendCard(chatId, `${targetToolLabel} Session Switched`, `已切换到 **${targetToolLabel}** 会话。\n\n` +
1581
+ `**序号:** ${index + 1}\n` +
1582
+ `**Session ID:** ${target.sessionId}\n` +
1583
+ `**工作目录:** \`${cwd2}\`\n\n` +
1584
+ `直接在这里发消息即可继续对话。\n` +
1585
+ `发送 **/model** 查看或切换当前会话的模型。${fastHelpAfterModel(descriptionTool)}${busyNote}`, "green");
1586
+ logTrace(tid, "DONE", {
1587
+ outcome: "session_switch",
1588
+ sessionId: target.sessionId,
1589
+ index: index + 1,
1590
+ cwd: cwd2,
1591
+ });
1592
+ return;
1593
+ }
1594
+ if (isCommandText && (textLower === "/fast" || textLower.startsWith("/fast "))) {
1595
+ const fastArg = text.slice(5).trim().toLowerCase();
1596
+ logTrace(tid, "BRANCH", { cmd: "/fast", arg: fastArg, sessionId, tool: descriptionTool });
1597
+ if (descriptionTool !== "codex") {
1598
+ const msg = `当前 ${toolLabel} 会话不支持 Fast 模式;/fast 仅适用于 Codex。`;
1599
+ await (platform.kind === "wechat"
1600
+ ? platform.sendText(chatId, msg)
1601
+ : platform.sendCard(chatId, "Codex Fast 模式", msg, "yellow")).catch(() => { });
1602
+ logTrace(tid, "DONE", { outcome: "fast_unsupported", tool: descriptionTool });
1603
+ return;
1604
+ }
1605
+ if (fastArg && fastArg !== "on" && fastArg !== "off") {
1606
+ const msg = "用法: /fast、/fast on 或 /fast off";
1607
+ await (platform.kind === "wechat"
1608
+ ? platform.sendText(chatId, msg)
1609
+ : platform.sendCard(chatId, "Codex Fast 模式", msg, "yellow")).catch(() => { });
1610
+ logTrace(tid, "DONE", { outcome: "fast_invalid", arg: fastArg });
1611
+ return;
1612
+ }
1613
+ if (fastArg) {
1614
+ setSessionFastModeOverride(sessionId, fastArg === "on");
1615
+ }
1616
+ const enabled = getEffectiveFastModeForTool("codex", sessionId);
1617
+ await sendFastModeStatus(platform, chatId, enabled).catch(() => { });
1618
+ if (fastArg) {
1619
+ const avatarStatus = isSessionRunning(sessionId) ? "busy" : "idle";
1620
+ await platform.setChatAvatar(chatId, "codex", avatarStatus, { fastMode: enabled }).catch((err) => {
1621
+ console.warn(`[${ts()}] [AVATAR] Fast mode refresh failed: chatId=${chatId} ${err.message}`);
1622
+ });
1623
+ }
1624
+ logTrace(tid, "DONE", {
1625
+ outcome: fastArg ? "fast_switched" : "fast_query",
1626
+ enabled,
1627
+ sessionId,
1628
+ });
1629
+ return;
1630
+ }
1631
+ // /model clear — 清除当前 session 的模型覆盖
1632
+ if (isCommandText && textLower === "/model clear") {
1633
+ logTrace(tid, "BRANCH", { cmd: "/model clear", sessionId });
1634
+ clearSessionModelOverride(sessionId);
1635
+ const defaultModel = getEffectiveModelForTool(descriptionTool);
1636
+ const toolLabel = toolDisplayName(descriptionTool);
1637
+ const msg = `已清除当前 ${toolLabel} 会话的模型覆盖,恢复使用: \`${defaultModel || "(未指定)"}\``;
1638
+ await (platform.kind === "wechat"
1639
+ ? platform.sendText(chatId, msg)
1640
+ : platform.sendCard(chatId, "模型切换", msg, "green")).catch(() => { });
1641
+ logTrace(tid, "DONE", { outcome: "model_cleared", sessionId, tool: descriptionTool });
1642
+ return;
1643
+ }
1644
+ // /model <name> — 切换当前 session 的模型(支持所有 agent,模糊匹配)
1645
+ if (isCommandText && textLower.startsWith("/model ")) {
1646
+ const modelArg = text.slice(7).trim();
1647
+ if (!modelArg)
1648
+ return; // 纯 "/model " 不处理,交给上面的 /model 分支
1649
+ logTrace(tid, "BRANCH", { cmd: "/model", arg: modelArg, sessionId, tool: descriptionTool });
1650
+ const models = getAllModelsForTool(descriptionTool);
1651
+ const toolLabel = toolDisplayName(descriptionTool);
1652
+ // 查找目标模型:精确匹配优先,否则子串匹配(模型名越短越优先)
1653
+ const target = findModelMatch(modelArg, models);
1654
+ if (!target) {
1655
+ const msg = models.length > 0
1656
+ ? `未找到匹配 "${modelArg}" 的模型。当前 ${toolLabel} 可选模型:\n${models.map(m => ` \`${m}\``).join("\n")}`
1657
+ : `当前 ${toolLabel} 没有可切换的模型。请在 config.json 中配置模型字段。`;
1658
+ await (platform.kind === "wechat"
1659
+ ? platform.sendText(chatId, msg)
1660
+ : platform.sendCard(chatId, "模型切换", msg, "red")).catch(() => { });
1661
+ logTrace(tid, "DONE", { outcome: "model_not_found", arg: modelArg, tool: descriptionTool });
1662
+ return;
1663
+ }
1664
+ setSessionModelOverride(sessionId, target);
1665
+ const msg = `已切换当前 ${toolLabel} 会话模型为: \`${target}\``;
1666
+ await (platform.kind === "wechat"
1667
+ ? platform.sendText(chatId, msg)
1668
+ : platform.sendCard(chatId, "模型切换", msg, "green")).catch(() => { });
1669
+ logTrace(tid, "DONE", { outcome: "model_switched", arg: modelArg, target, sessionId, tool: descriptionTool });
1670
+ return;
1671
+ }
1672
+ // /model — 查看当前会话的可用模型(根据会话 Agent 类型)
1673
+ if (isCommandText && textLower === "/model") {
1674
+ logTrace(tid, "BRANCH", { cmd: "/model", sessionId, tool: descriptionTool });
1675
+ const models = getAllModelsForTool(descriptionTool);
1676
+ const currentModel = getEffectiveModelForTool(descriptionTool, sessionId);
1677
+ if (platform.kind === "wechat") {
1678
+ const lines = [currentModel ? `当前模型 (${toolLabel}): ${currentModel}` : `当前模型 (${toolLabel}): 未指定`];
1679
+ if (models.length > 0) {
1680
+ lines.push("", "可切换模型:");
1681
+ for (const m of models)
1682
+ lines.push(` ${m}`);
1683
+ lines.push("", "输入 /model <模型名> 切换模型");
1684
+ }
1685
+ else {
1686
+ lines.push("", "没有可切换的模型。请在 config.json 中配置模型字段。");
1687
+ }
1688
+ if (descriptionTool === "codex") {
1689
+ lines.push("输入 /fast 查看或切换当前会话的 Fast 模式");
1690
+ }
1691
+ await platform.sendText(chatId, lines.join("\n")).catch(() => { });
1692
+ }
1693
+ else {
1694
+ const card = buildModelCard(currentModel, models, descriptionTool);
1695
+ await platform.sendRawCard(chatId, card);
1696
+ }
1697
+ logTrace(tid, "DONE", { outcome: "model_query", tool: descriptionTool });
1698
+ return;
1699
+ }
1700
+ // /git <args>:在「当前会话工作目录」执行 git 命令
1701
+ if (isCommandText && textLower === "/effort clear") {
1702
+ logTrace(tid, "BRANCH", { cmd: "/effort clear", sessionId, tool: descriptionTool });
1703
+ const efforts = getAllEffortsForTool(descriptionTool);
1704
+ const toolLabel = toolDisplayName(descriptionTool);
1705
+ if (efforts.length === 0) {
1706
+ const msg = `当前 ${toolLabel} 不支持 effort 切换。`;
1707
+ await (platform.kind === "wechat"
1708
+ ? platform.sendText(chatId, msg)
1709
+ : platform.sendCard(chatId, "Effort 切换", msg, "red")).catch(() => { });
1710
+ logTrace(tid, "DONE", { outcome: "effort_unsupported", tool: descriptionTool });
1711
+ return;
1712
+ }
1713
+ clearSessionEffortOverride(sessionId);
1714
+ const defaultEffort = getDefaultEffortForTool(descriptionTool);
1715
+ const msg = `已清除当前 ${toolLabel} 会话的 effort 覆盖,恢复使用: \`${defaultEffort || "(未指定)"}\``;
1716
+ await (platform.kind === "wechat"
1717
+ ? platform.sendText(chatId, msg)
1718
+ : platform.sendCard(chatId, "Effort 切换", msg, "green")).catch(() => { });
1719
+ logTrace(tid, "DONE", { outcome: "effort_cleared", sessionId, tool: descriptionTool });
1720
+ return;
1721
+ }
1722
+ if (isCommandText && textLower.startsWith("/effort ")) {
1723
+ const effortArg = text.slice(8).trim();
1724
+ if (!effortArg)
1725
+ return;
1726
+ logTrace(tid, "BRANCH", { cmd: "/effort", arg: effortArg, sessionId, tool: descriptionTool });
1727
+ const efforts = getAllEffortsForTool(descriptionTool);
1728
+ const toolLabel = toolDisplayName(descriptionTool);
1729
+ if (efforts.length === 0) {
1730
+ const msg = `当前 ${toolLabel} 不支持 effort 切换。`;
1731
+ await (platform.kind === "wechat"
1732
+ ? platform.sendText(chatId, msg)
1733
+ : platform.sendCard(chatId, "Effort 切换", msg, "red")).catch(() => { });
1734
+ logTrace(tid, "DONE", { outcome: "effort_unsupported", tool: descriptionTool });
1735
+ return;
1736
+ }
1737
+ const target = findModelMatch(effortArg, efforts);
1738
+ if (!target) {
1739
+ const msg = `未找到匹配 "${effortArg}" 的 effort。当前 ${toolLabel} 可选 effort:\n${efforts.map(e => ` \`${e}\``).join("\n")}`;
1740
+ await (platform.kind === "wechat"
1741
+ ? platform.sendText(chatId, msg)
1742
+ : platform.sendCard(chatId, "Effort 切换", msg, "red")).catch(() => { });
1743
+ logTrace(tid, "DONE", { outcome: "effort_not_found", arg: effortArg, tool: descriptionTool });
1744
+ return;
1745
+ }
1746
+ setSessionEffortOverride(sessionId, target);
1747
+ const msg = `已切换当前 ${toolLabel} 会话 effort 为: \`${target}\``;
1748
+ await (platform.kind === "wechat"
1749
+ ? platform.sendText(chatId, msg)
1750
+ : platform.sendCard(chatId, "Effort 切换", msg, "green")).catch(() => { });
1751
+ logTrace(tid, "DONE", { outcome: "effort_switched", arg: effortArg, target, sessionId, tool: descriptionTool });
1752
+ return;
1753
+ }
1754
+ if (isCommandText && textLower === "/effort") {
1755
+ logTrace(tid, "BRANCH", { cmd: "/effort", sessionId, tool: descriptionTool });
1756
+ const efforts = getAllEffortsForTool(descriptionTool);
1757
+ const currentEffort = getEffectiveEffortForTool(descriptionTool, sessionId);
1758
+ const toolLabel = toolDisplayName(descriptionTool);
1759
+ if (efforts.length === 0) {
1760
+ const msg = `当前 ${toolLabel} 不支持 effort 切换。`;
1761
+ await (platform.kind === "wechat"
1762
+ ? platform.sendText(chatId, msg)
1763
+ : platform.sendCard(chatId, "Effort 切换", msg, "red")).catch(() => { });
1764
+ }
1765
+ else if (platform.kind === "wechat") {
1766
+ const lines = [currentEffort ? `当前 effort (${toolLabel}): ${currentEffort}` : `当前 effort (${toolLabel}): 未指定`];
1767
+ lines.push("", "可切换 effort:");
1768
+ for (const e of efforts)
1769
+ lines.push(` ${e}`);
1770
+ lines.push("", "输入 /effort <effort> 切换 effort");
1771
+ await platform.sendText(chatId, lines.join("\n")).catch(() => { });
1772
+ }
1773
+ else {
1774
+ const card = buildEffortCard(currentEffort, efforts, descriptionTool);
1775
+ await platform.sendRawCard(chatId, card);
1776
+ }
1777
+ logTrace(tid, "DONE", { outcome: "effort_query", tool: descriptionTool });
1778
+ return;
1779
+ }
1780
+ if (isCommandText && (textLower.startsWith("/git ") || textLower === "/git")) {
1781
+ const args = text === "/git" ? "" : text.slice(5).trim();
1782
+ logTrace(tid, "BRANCH", { cmd: "/git", args: args || "(none)" });
1783
+ if (!args) {
1784
+ logTrace(tid, "DONE", { outcome: "git_no_args" });
1785
+ await platform.sendCard(chatId, "/git", "用法:`/git <子命令> [参数]`,例如 `/git status`、`/git log --oneline -n 5`。", "yellow");
1786
+ return;
1787
+ }
1788
+ const adapter = getAdapterForTool(descriptionTool, sessionId);
1789
+ let cwd;
1790
+ try {
1791
+ const info = await adapter.getSessionInfo(sessionId);
1792
+ cwd = info?.cwd;
1793
+ }
1794
+ catch (err) {
1795
+ console.error(`[${ts()}] [GIT] getSessionInfo FAIL: ${err.message}`);
1796
+ }
1797
+ if (!cwd) {
1798
+ logTrace(tid, "DONE", { outcome: "git_no_cwd", tool: descriptionTool });
1799
+ const isCursor = descriptionTool === "cursor";
1800
+ const hint = isCursor
1801
+ ? "无法获取当前 Cursor 会话的工作目录(缺少 sessionId→cwd 持久化映射)。请先在本群发送一条普通消息(让 adapter 从 cursor-agent 流中自动补回 cwd),然后再试 /git;若仍失败,可用 /new 重建会话。"
1802
+ : `无法获取当前会话的工作目录(${toolLabel} adapter 未返回 cwd)。请先与 AI 对话一次再试,或检查会话是否仍存在。`;
1803
+ await platform.sendCard(chatId, "/git", hint, "red");
1804
+ return;
1805
+ }
1806
+ console.log(`[${ts()}] [GIT] chat=${chatId} cwd=${cwd} cmd="git ${args}" timeoutMs=${GIT_TIMEOUT_MS}`);
1807
+ const result = await runGitCommand(args, cwd, {
1808
+ timeoutMs: GIT_TIMEOUT_MS,
1809
+ });
1810
+ console.log(`[${ts()}] [GIT] exitCode=${result.exitCode}, durationMs=${result.durationMs}, truncated=${result.truncated}, timedOut=${result.timedOut}`);
1811
+ const content = formatGitResult(args, cwd, result);
1812
+ const template = gitResultHeaderTemplate(result);
1813
+ await platform.sendCard(chatId, "/git 输出", content, template);
1814
+ logTrace(tid, "DONE", {
1815
+ outcome: "git_result",
1816
+ exitCode: result.exitCode,
1817
+ durationMs: result.durationMs,
1818
+ });
1819
+ return;
1820
+ }
1821
+ const lastTs = lastMsgTimestamps.get(chatId);
1822
+ if (lastTs !== undefined && msgTimestamp <= lastTs) {
1823
+ logTrace(tid, "DONE", {
1824
+ outcome: "skip_old_message_no_session",
1825
+ msgTimestamp,
1826
+ lastTimestamp: lastTs,
1827
+ });
1828
+ console.log(`[${ts()}] [SKIP] Older message (${msgTimestamp} <= ${lastTs}), no active session, ignoring`);
1829
+ return;
1830
+ }
1831
+ // 并发检查:同一 session 只能有一个活跃 prompt,多余消息进入队列
1832
+ if (isSessionRunning(sessionId)) {
1833
+ const queued = enqueueMessage(sessionId, {
1834
+ text: promptText, chatId, openId, msgTimestamp, chatType, traceId: tid,
1835
+ });
1836
+ if (queued) {
1837
+ logTrace(tid, "QUEUED", { sessionId });
1838
+ console.log(`[${ts()}] [QUEUED] Session ${sessionId} is busy, message from chat ${chatId} enqueued`);
1839
+ if (platform.kind === "wechat") {
1840
+ await platform.sendText(chatId, "当前会话正在生成中,你的消息已进入缓存队列,生成完成后会立即处理。发送 /cancel 可取消缓存。").catch(() => { });
1841
+ }
1842
+ else {
1843
+ if (isFeishuP2p(platform, chatType) && pendingFeishuP2pDefaultTool) {
1844
+ const currentLabel = toolDisplayName(descriptionTool);
1845
+ const desiredLabel = toolDisplayName(pendingFeishuP2pDefaultTool);
1846
+ await platform.sendCard(chatId, "Agent 切换等待中", `当前 ${currentLabel} 正在生成;完成后会切换到 ${desiredLabel},并用新的空会话处理这条消息。\n\n发送 **/cancel** 可取消缓存。`, "blue").catch(() => { });
1847
+ }
1848
+ else {
1849
+ await platform.sendRawCard(chatId, buildQueuedCard(text)).catch(() => { });
1850
+ }
1851
+ }
1852
+ }
1853
+ else {
1854
+ logTrace(tid, "QUEUE_FULL", { sessionId });
1855
+ console.log(`[${ts()}] [QUEUE_FULL] Session ${sessionId} queue full, rejecting message from chat ${chatId}`);
1856
+ if (platform.kind === "wechat") {
1857
+ await platform.sendText(chatId, "当前缓存队列中已有消息等待处理,请等待或发送 /stop(停止生成)或 /cancel(取消缓存)。").catch(() => { });
1858
+ }
1859
+ else {
1860
+ await platform.sendRawCard(chatId, buildQueueFullCard()).catch(() => { });
1861
+ }
1862
+ }
1863
+ return;
1864
+ }
1865
+ if (shouldSendWechatProcessingAck(platform, isCommandText, chatType)) {
1866
+ await platform.sendText(chatId, "生成中...").catch(() => { });
1867
+ }
1868
+ try {
1869
+ logTrace(tid, "RESUME", { sessionId, tool: descriptionTool });
1870
+ const resumeOutcome = await resumeAndPrompt(sessionId, promptText, platform, chatId, msgTimestamp, descriptionTool, tid);
1871
+ if (resumeOutcome === "error") {
1872
+ logTrace(tid, "DONE", { outcome: "resume_error", sessionId });
1873
+ console.error(`[${ts()}] [RESUME] Session ${sessionId} ended with an Agent error`);
1874
+ }
1875
+ else {
1876
+ logTrace(tid, "DONE", { outcome: "resume_done", sessionId, sessionOutcome: resumeOutcome });
1877
+ console.log(`[${ts()}] [RESUME] Session ${sessionId} done (${resumeOutcome})`);
1878
+ }
1879
+ }
1880
+ catch (err) {
1881
+ logTrace(tid, "DONE", {
1882
+ outcome: "resume_fail",
1883
+ error: err.message,
1884
+ });
1885
+ console.error(`[${ts()}] [RESUME] FAIL: ${err.message}`);
1886
+ fileLog.flush();
1887
+ await platform.sendCard(chatId, "Error", `Failed to resume ${toolLabel} session:\n${err.message}`, "red");
1888
+ }
1889
+ return;
1890
+ }
1891
+ if (isCommandText && (textLower === "/fast" || textLower.startsWith("/fast "))) {
1892
+ const defaultTool = resolveDefaultAgentTool();
1893
+ const fastArg = text.slice(5).trim().toLowerCase();
1894
+ if (defaultTool !== "codex") {
1895
+ const msg = `当前默认 Agent (${toolDisplayName(defaultTool)}) 不支持 Fast 模式;/fast 仅适用于 Codex。`;
1896
+ await (platform.kind === "wechat"
1897
+ ? platform.sendText(chatId, msg)
1898
+ : platform.sendCard(chatId, "Codex Fast 模式", msg, "yellow")).catch(() => { });
1899
+ logTrace(tid, "DONE", { outcome: "fast_unsupported", defaultTool });
1900
+ return;
1901
+ }
1902
+ if (fastArg) {
1903
+ const msg = "当前没有绑定 Codex 会话,无法设置会话覆盖。请先创建或进入 Codex 会话;全局默认值可在 Web UI 中设置。";
1904
+ await (platform.kind === "wechat"
1905
+ ? platform.sendText(chatId, msg)
1906
+ : platform.sendCard(chatId, "Codex Fast 模式", msg, "yellow")).catch(() => { });
1907
+ logTrace(tid, "DONE", { outcome: "fast_no_session", arg: fastArg });
1908
+ return;
1909
+ }
1910
+ const enabled = getEffectiveFastModeForTool("codex");
1911
+ await sendFastModeStatus(platform, chatId, enabled).catch(() => { });
1912
+ logTrace(tid, "DONE", { outcome: "fast_query", enabled, defaultTool });
1913
+ return;
1914
+ }
1915
+ // 无会话上下文 → 检查是否是 /model 查询
1916
+ if (isCommandText && textLower === "/model") {
1917
+ const defaultTool = resolveDefaultAgentTool();
1918
+ const models = getAllModelsForTool(defaultTool);
1919
+ let currentModel = "";
1920
+ if (defaultTool === "cursor")
1921
+ currentModel = config.cursor.model;
1922
+ else if (defaultTool === "codex")
1923
+ currentModel = config.codex.model;
1924
+ else if (defaultTool === "ccc")
1925
+ currentModel = config.ccc.model;
1926
+ else
1927
+ currentModel = CLAUDE_MODEL;
1928
+ if (platform.kind === "wechat") {
1929
+ const lines = [currentModel ? `当前模型 (${defaultTool}): ${currentModel}` : `当前模型 (${defaultTool}): 未指定`];
1930
+ if (models.length > 0) {
1931
+ lines.push("", "可切换模型:");
1932
+ for (const m of models)
1933
+ lines.push(` ${m}`);
1934
+ lines.push("", "在会话中输入 /model <模型名> 切换模型");
1935
+ }
1936
+ else {
1937
+ lines.push("", "没有可切换的模型。请在 config.json 中配置模型字段。");
1938
+ }
1939
+ if (defaultTool === "codex") {
1940
+ lines.push("输入 /fast 查看当前 Codex Fast 模式");
1941
+ }
1942
+ await platform.sendText(chatId, lines.join("\n")).catch(() => { });
1943
+ }
1944
+ else {
1945
+ const card = buildModelCard(currentModel, models, defaultTool);
1946
+ await platform.sendRawCard(chatId, card);
1947
+ }
1948
+ logTrace(tid, "DONE", { outcome: "model_query", defaultTool });
1949
+ return;
1950
+ }
1951
+ // A private /state query is useful even before the first Agent session exists.
1952
+ // Keep it read-only and render the same status-card shape as established chats.
1953
+ if (isCommandText && textLower === "/state" && isFeishuP2p(platform, chatType)) {
1954
+ logTrace(tid, "BRANCH", { cmd: "/state", scope: "unbound_p2p" });
1955
+ await sendStateCard(platform, chatId, null, toolDisplayName(resolveDefaultAgentTool()), tid);
1956
+ return;
1957
+ }
1958
+ // 无会话上下文 → /sessions 仍是有效指令,不触发飞书私聊自动建群。
1959
+ if (isCommandText && textLower === "/effort") {
1960
+ const defaultTool = resolveDefaultAgentTool();
1961
+ const efforts = getAllEffortsForTool(defaultTool);
1962
+ const currentEffort = getDefaultEffortForTool(defaultTool);
1963
+ const toolLabel = toolDisplayName(defaultTool);
1964
+ if (efforts.length === 0) {
1965
+ const msg = `当前默认 agent (${toolLabel}) 不支持 effort 切换。`;
1966
+ await (platform.kind === "wechat"
1967
+ ? platform.sendText(chatId, msg)
1968
+ : platform.sendCard(chatId, "Effort 切换", msg, "red")).catch(() => { });
1969
+ }
1970
+ else if (platform.kind === "wechat") {
1971
+ const lines = [currentEffort ? `当前默认 effort (${toolLabel}): ${currentEffort}` : `当前默认 effort (${toolLabel}): 未指定`];
1972
+ lines.push("", "可切换 effort:");
1973
+ for (const e of efforts)
1974
+ lines.push(` ${e}`);
1975
+ lines.push("", "在会话中输入 /effort <effort> 切换 effort");
1976
+ await platform.sendText(chatId, lines.join("\n")).catch(() => { });
1977
+ }
1978
+ else {
1979
+ const card = buildEffortCard(currentEffort, efforts, defaultTool);
1980
+ await platform.sendRawCard(chatId, card);
1981
+ }
1982
+ logTrace(tid, "DONE", { outcome: "effort_query", defaultTool });
1983
+ return;
1984
+ }
1985
+ if (isCommandText && textLower.startsWith("/effort ")) {
1986
+ const defaultTool = resolveDefaultAgentTool();
1987
+ const toolLabel = toolDisplayName(defaultTool);
1988
+ const msg = `当前没有绑定会话。请先进入 Claude/Codex 会话,再输入 /effort <effort> 切换当前会话的 effort。当前默认 agent: ${toolLabel}`;
1989
+ await (platform.kind === "wechat"
1990
+ ? platform.sendText(chatId, msg)
1991
+ : platform.sendCard(chatId, "Effort 切换", msg, "yellow")).catch(() => { });
1992
+ logTrace(tid, "DONE", { outcome: "effort_no_session", defaultTool });
1993
+ return;
1994
+ }
1995
+ if (isCommandText && textLower === "/sessions") {
1996
+ logTrace(tid, "BRANCH", { cmd: "/sessions", scope: "global" });
1997
+ const allSessions = await getAllSessionsStatus();
1998
+ const now = Date.now();
1999
+ const cardData = allSessions.map((s) => ({
2000
+ sessionId: s.sessionId,
2001
+ chatName: s.chatName,
2002
+ chatId: s.chatId,
2003
+ chatType: s.chatType,
2004
+ active: s.active,
2005
+ turnCount: s.turnCount,
2006
+ elapsedSeconds: s.active
2007
+ ? Math.floor((now - s.startTime) / 1000)
2008
+ : null,
2009
+ model: s.model,
2010
+ tool: s.tool,
2011
+ }));
2012
+ const card = buildSessionsCard(cardData, {
2013
+ defaultToolLabel: toolDisplayName(resolveDefaultAgentTool()),
2014
+ fixedPrivateSession: isFeishuP2p(platform, chatType),
2015
+ });
2016
+ const ok = await platform.sendRawCard(chatId, card);
2017
+ console.log(`[${ts()}] [SESSIONS] card sent, ok=${ok}, count=${cardData.length}`);
2018
+ logTrace(tid, "DONE", { outcome: "sessions", ok, count: cardData.length });
2019
+ return;
2020
+ }
2021
+ // 飞书私聊普通消息:首次使用时在当前私聊创建并持久化一个专属 session,
2022
+ // 随后的消息会在上面的 registry 路由中继续该 session。只有显式 /new 才建群。
2023
+ if (isFeishuP2p(platform, chatType) && !isCommandText) {
2024
+ const tool = resolveDefaultAgentTool();
2025
+ const toolLabel = toolDisplayName(tool);
2026
+ // 私聊 cwd 故意不读取 /cd 的 chatId 默认值:/cd 只为之后显式
2027
+ // /new 创建的群聊服务,飞书私聊始终从 ChatCCC 运行账号的用户目录启动。
2028
+ const cwd = homedir();
2029
+ logTrace(tid, "BRANCH", { cmd: "auto_new_feishu_p2p", tool, cwd });
2030
+ try {
2031
+ const init = await initClaudeSession(tool, cwd);
2032
+ const sessionId = init.sessionId;
2033
+ const chatName = sessionChatName(text.slice(0, 10) || "私聊会话", cwd);
2034
+ const switchResult = await switchChatBinding({
2035
+ chatId,
2036
+ chatType,
2037
+ oldSessionId: null,
2038
+ newSessionId: sessionId,
2039
+ tool,
2040
+ chatName,
2041
+ newDescription: `${sessionPrefixForTool(tool)} ${sessionId}`,
2042
+ updateChatInfoFn: (cid, name, desc) => platform.updateChatInfo(cid, name, desc),
2043
+ });
2044
+ if (!switchResult.ok) {
2045
+ throw switchResult.error ?? new Error("Failed to bind Feishu private session");
2046
+ }
2047
+ await resumeAndPrompt(sessionId, promptText, platform, chatId, msgTimestamp, tool, tid);
2048
+ logTrace(tid, "DONE", {
2049
+ outcome: "auto_new_feishu_p2p_prompt_done",
2050
+ chatId,
2051
+ sessionId,
2052
+ tool,
2053
+ cwd,
2054
+ });
2055
+ }
2056
+ catch (err) {
2057
+ console.error(`[${ts()}] [AUTO-P2P] FAIL: ${err.message}`);
2058
+ logTrace(tid, "DONE", {
2059
+ outcome: "auto_new_feishu_p2p_fail",
2060
+ error: err.message,
2061
+ });
2062
+ await platform.sendCard(chatId, "Error", `Failed to create ${toolLabel} private session:\n${err.message}`, "red");
2063
+ }
2064
+ return;
2065
+ }
2066
+ // 无会话上下文 → help card
2067
+ logTrace(tid, "SEND", { method: "help_card", chatId });
2068
+ const card = buildHelpCard(text, { defaultToolLabel: toolDisplayName(resolveDefaultAgentTool()) });
2069
+ const ok = await platform.sendRawCard(chatId, card);
2070
+ if (!ok) {
2071
+ console.error(`[${ts()}] [SEND] help_card FAIL: chatId=${chatId}`);
2072
+ logTrace(tid, "DONE", { outcome: "help_card_fail" });
2073
+ }
2074
+ else {
2075
+ console.log(`[${ts()}] [SEND] help_card OK: chatId=${chatId}`);
2076
+ logTrace(tid, "DONE", { outcome: "help_card_sent" });
2077
+ }
2078
+ }