chatccc 0.2.50 → 0.2.52

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.
@@ -0,0 +1,1032 @@
1
+ /**
2
+ * orchestrator.ts — 平台无关的消息命令处理
3
+ *
4
+ * Phase 1: 从 index.ts 抽出 handleCommand 及辅助函数。
5
+ * 所有 IM 平台操作通过 PlatformAdapter 接口注入,不直接依赖 feishu-platform.ts。
6
+ */
7
+
8
+ import { spawn } from "node:child_process";
9
+ import { readdir, stat } from "node:fs/promises";
10
+ import { resolve, dirname } from "node:path";
11
+
12
+ import { makeTraceId, logTrace } from "./trace.ts";
13
+ import {
14
+ CLAUDE_EFFORT,
15
+ CLAUDE_MODEL,
16
+ GIT_TIMEOUT_MS,
17
+ PROJECT_ROOT,
18
+ anthropicConfigDisplay,
19
+ fileLog,
20
+ getDefaultCwd,
21
+ setDefaultCwd,
22
+ getRecentDirs,
23
+ addRecentDir,
24
+ sessionPrefixForTool,
25
+ toolDisplayName,
26
+ ts,
27
+ } from "./config.ts";
28
+ import {
29
+ buildHelpCard,
30
+ buildStatusCard,
31
+ buildCdContent,
32
+ buildCdCard,
33
+ buildSessionsCard,
34
+ } from "./cards.ts";
35
+ import {
36
+ formatGitResult,
37
+ gitResultHeaderTemplate,
38
+ runGitCommand,
39
+ } from "./git-command.ts";
40
+ import {
41
+ getSessionStatus,
42
+ getAllSessionsStatus,
43
+ initClaudeSession,
44
+ lastMsgTimestamps,
45
+ resumeAndPrompt,
46
+ sessionInfoMap,
47
+ switchChatBinding,
48
+ recordSessionRegistry,
49
+ getAdapterForTool,
50
+ stopSession,
51
+ loadSessionRegistryForBinding,
52
+ removeSessionRegistryRecord,
53
+ saveSessionTool,
54
+ recordChatPlatform,
55
+ } from "./session.ts";
56
+ import {
57
+ bindChatToSession,
58
+ unbindChatFromSession,
59
+ isSessionRunning,
60
+ displayCards,
61
+ recordLastActiveChat,
62
+ } from "./session-chat-binding.ts";
63
+ export { type PlatformAdapter } from "./platform-adapter.ts";
64
+ import type { PlatformAdapter } from "./platform-adapter.ts";
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // 辅助函数
68
+ // ---------------------------------------------------------------------------
69
+
70
+ export function cwdDisplayName(cwd: string): string {
71
+ const trimmed = cwd.trim().replace(/[\\/]+$/, "");
72
+ return trimmed.split(/[\\/]/).filter(Boolean).pop() || trimmed || "cwd";
73
+ }
74
+
75
+ export function sessionChatName(left: string, cwd: string): string {
76
+ return `${left}-${cwdDisplayName(cwd)}`;
77
+ }
78
+
79
+ function isUntitledSessionChatName(name: string): boolean {
80
+ return name === "新会话" || name.startsWith("新会话-");
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // handleCommand — 平台无关的命令分发
85
+ // ---------------------------------------------------------------------------
86
+
87
+ export async function handleCommand(
88
+ platform: PlatformAdapter,
89
+ text: string,
90
+ chatId: string,
91
+ openId: string,
92
+ msgTimestamp: number,
93
+ chatType = "group",
94
+ traceId?: string,
95
+ ): Promise<void> {
96
+ const tid = traceId ?? makeTraceId();
97
+ const textLower = text.toLowerCase();
98
+ recordChatPlatform(chatId, platform);
99
+
100
+ if (textLower === "/restart") {
101
+ logTrace(tid, "BRANCH", { cmd: "/restart" });
102
+ await platform.sendText(chatId, "重启中...请几秒后发消息唤醒我").catch(() => {});
103
+ logTrace(tid, "DONE", { outcome: "restart" });
104
+ console.log(`[${ts()}] [RESTART] Spawning new process...`);
105
+ const child = spawn("npx", ["tsx", "src/index.ts"], {
106
+ cwd: PROJECT_ROOT,
107
+ detached: true,
108
+ stdio: "ignore",
109
+ shell: true,
110
+ });
111
+ child.unref();
112
+ setTimeout(() => process.exit(0), 200);
113
+ return;
114
+ }
115
+
116
+ if (textLower === "/cd" || textLower.startsWith("/cd ")) {
117
+ logTrace(tid, "BRANCH", {
118
+ cmd: "/cd",
119
+ arg: text.slice(3).trim() || "(none)",
120
+ });
121
+ const currentDir = await getDefaultCwd(chatId);
122
+
123
+ // 获取当前会话的实际工作路径(若在会话群内)
124
+ let sessionCwd: string | undefined;
125
+ try {
126
+ const chatInfo = await platform.getChatInfo(chatId);
127
+ const sessionInfoResult = platform.extractSessionInfo(
128
+ chatInfo.description,
129
+ );
130
+ if (sessionInfoResult) {
131
+ const adapter = getAdapterForTool(sessionInfoResult.tool);
132
+ const info = await adapter.getSessionInfo(sessionInfoResult.sessionId);
133
+ sessionCwd = info?.cwd;
134
+ }
135
+ } catch {
136
+ /* 非会话群或获取失败,不显示 */
137
+ }
138
+
139
+ const arg = text.slice(3).trim();
140
+
141
+ // Resolve target directory
142
+ let targetDir: string;
143
+ if (!arg) {
144
+ targetDir = currentDir;
145
+ } else if (arg === "..") {
146
+ targetDir = dirname(currentDir);
147
+ } else {
148
+ targetDir = resolve(currentDir, arg);
149
+ }
150
+
151
+ // Verify the target exists and is a directory
152
+ try {
153
+ const s = await stat(targetDir);
154
+ if (!s.isDirectory()) {
155
+ logTrace(tid, "DONE", { outcome: "cd_not_dir", targetDir });
156
+ await platform.sendCard(
157
+ chatId,
158
+ "新会话工作路径",
159
+ `路径存在但不是目录:\n\`${targetDir}\``,
160
+ "red",
161
+ );
162
+ return;
163
+ }
164
+ } catch {
165
+ logTrace(tid, "DONE", { outcome: "cd_not_found", targetDir });
166
+ await platform.sendCard(
167
+ chatId,
168
+ "新会话工作路径",
169
+ `路径不存在:\n\`${targetDir}\``,
170
+ "red",
171
+ );
172
+ return;
173
+ }
174
+
175
+ // Change working dir if user provided a path
176
+ const isUpdate = !!arg && targetDir !== currentDir;
177
+ if (isUpdate) {
178
+ await setDefaultCwd(targetDir, chatId);
179
+ await addRecentDir(targetDir);
180
+ }
181
+
182
+ // Read directory entries
183
+ let entries: string[];
184
+ try {
185
+ entries = await readdir(targetDir);
186
+ } catch (err) {
187
+ logTrace(tid, "DONE", {
188
+ outcome: "cd_readdir_fail",
189
+ error: (err as Error).message,
190
+ });
191
+ await platform.sendCard(
192
+ chatId,
193
+ "新会话工作路径",
194
+ `无法读取目录:\n\`${targetDir}\`\n\n${(err as Error).message}`,
195
+ "red",
196
+ );
197
+ return;
198
+ }
199
+
200
+ // Sort: directories first, then files, alphabetically within each group
201
+ const withStats: { name: string; isDir: boolean }[] = [];
202
+ for (const name of entries) {
203
+ try {
204
+ const s = await stat(resolve(targetDir, name));
205
+ withStats.push({ name, isDir: s.isDirectory() });
206
+ } catch {
207
+ withStats.push({ name, isDir: false });
208
+ }
209
+ }
210
+ withStats.sort((a, b) => {
211
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
212
+ return a.name.localeCompare(b.name);
213
+ });
214
+
215
+ if (!arg) {
216
+ // /cd 无参数:展示卡片(含最近使用路径按钮)
217
+ const recentDirs = await getRecentDirs();
218
+ const card = buildCdCard(targetDir, withStats, recentDirs, sessionCwd);
219
+ const ok = await platform.sendRawCard(chatId, card);
220
+ console.log(
221
+ `[${ts()}] [CD] card sent, ok=${ok}, recentDirs=${recentDirs.length}`,
222
+ );
223
+ logTrace(tid, "DONE", { outcome: "cd_card", ok });
224
+ } else {
225
+ // /cd <path>:切换目录,发送文本卡片
226
+ const content = buildCdContent(targetDir, withStats, isUpdate, sessionCwd);
227
+ await platform.sendCard(chatId, "新会话工作路径", content, "blue");
228
+ logTrace(tid, "DONE", { outcome: "cd_path", targetDir, isUpdate });
229
+ }
230
+ return;
231
+ }
232
+
233
+ if (textLower === "/new" || textLower.startsWith("/new ")) {
234
+ const toolArg = text.slice(5).trim().toLowerCase();
235
+ const tool = toolArg || "claude";
236
+ logTrace(tid, "BRANCH", { cmd: "/new", tool });
237
+ const validTools = ["claude", "cursor", "codex"];
238
+ if (!validTools.includes(tool)) {
239
+ logTrace(tid, "DONE", { outcome: "new_invalid_tool", tool });
240
+ await platform.sendCard(
241
+ chatId,
242
+ "Error",
243
+ `未知的工具类型: "${toolArg}"。支持: claude (Claude Code), cursor (Cursor), codex (Codex)。`,
244
+ "red",
245
+ );
246
+ return;
247
+ }
248
+ const toolLabel = toolDisplayName(tool);
249
+
250
+ if (!openId) {
251
+ logTrace(tid, "DONE", { outcome: "new_no_openid" });
252
+ console.log(`[${ts()}] [WARN] Cannot get sender open_id`);
253
+ await platform.sendCard(
254
+ chatId,
255
+ "Error",
256
+ "Cannot identify sender.",
257
+ "red",
258
+ );
259
+ return;
260
+ }
261
+
262
+ let sessionId: string;
263
+ let sessionCwd: string;
264
+ try {
265
+ const init = await initClaudeSession(tool, undefined, chatId);
266
+ sessionId = init.sessionId;
267
+ sessionCwd = init.cwd;
268
+ console.log(
269
+ `[${ts()}] [STEP 1/4] ${toolLabel} session created: ${sessionId} → OK`,
270
+ );
271
+ } catch (err) {
272
+ console.error(`[${ts()}] [STEP 1/4] FAIL: ${(err as Error).message}`);
273
+ logTrace(tid, "DONE", {
274
+ outcome: "new_session_fail",
275
+ error: (err as Error).message,
276
+ });
277
+ await platform.sendCard(
278
+ chatId,
279
+ "Error",
280
+ `Failed to initialize ${toolLabel} session:\n${(err as Error).message}`,
281
+ "red",
282
+ );
283
+ return;
284
+ }
285
+
286
+ const cwd = sessionCwd;
287
+ const initialName = sessionChatName("新会话", cwd);
288
+
289
+ // 私聊:不创建群,直接绑定 session 到当前私聊
290
+ if (chatType === "p2p") {
291
+ // 先解绑旧 session(如果存在),避免旧 session 的 display loop
292
+ // 继续往同一个 chat 推送内容(/newh 走 switchChatBinding 已有此逻辑,
293
+ // 但 /new p2p 之前遗漏了解绑)。
294
+ const oldRegistry = await loadSessionRegistryForBinding();
295
+ const oldRecord = oldRegistry[chatId];
296
+ if (oldRecord?.sessionId && oldRecord.sessionId !== sessionId) {
297
+ unbindChatFromSession(oldRecord.sessionId, chatId);
298
+ displayCards.delete(chatId);
299
+ }
300
+ bindChatToSession(sessionId, chatId);
301
+ sessionInfoMap.set(chatId, {
302
+ sessionId,
303
+ turnCount: 0,
304
+ lastContextTokens: 0,
305
+ startTime: Date.now(),
306
+ tool,
307
+ });
308
+ await setDefaultCwd(cwd, chatId);
309
+ await recordSessionRegistry({
310
+ chatId,
311
+ sessionId,
312
+ tool,
313
+ chatName: initialName,
314
+ turnCount: 0,
315
+ startTime: Date.now(),
316
+ running: false,
317
+ });
318
+ await saveSessionTool(sessionId, tool, initialName);
319
+ await platform.sendCard(
320
+ chatId,
321
+ `${toolLabel} Session Ready`,
322
+ `这是你的 **${toolLabel}** 私聊会话。\n\n` +
323
+ `**Session ID:** ${sessionId}\n` +
324
+ `**工作目录:** \`${cwd}\`\n\n` +
325
+ `直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
326
+ `发送 **/cd** 切换新建会话的默认目录。\n` +
327
+ `发送 **/new** 创建新会话,**/newh** 重置当前会话(沿用工作目录)。\n` +
328
+ `发送 **/sessions** 查看所有会话状态。\n` +
329
+ `发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5\`。`,
330
+ "green",
331
+ );
332
+ console.log(
333
+ `[${ts()}] [NEW] P2P session created: ${sessionId} (${toolLabel})`,
334
+ );
335
+ logTrace(tid, "DONE", {
336
+ outcome: "session_ready_p2p",
337
+ chatId,
338
+ sessionId,
339
+ tool,
340
+ });
341
+ return;
342
+ }
343
+
344
+ let newChatId: string;
345
+ try {
346
+ newChatId = await platform.createGroup(initialName, [openId]);
347
+ console.log(
348
+ `[${ts()}] [STEP 2/4] Created Feishu group: ${newChatId} → OK`,
349
+ );
350
+ } catch (err) {
351
+ console.error(`[${ts()}] [STEP 2/4] FAIL: ${(err as Error).message}`);
352
+ logTrace(tid, "DONE", {
353
+ outcome: "new_group_fail",
354
+ error: (err as Error).message,
355
+ });
356
+ await platform.sendCard(
357
+ chatId,
358
+ "Error",
359
+ `Failed to create group:\n${(err as Error).message}`,
360
+ "red",
361
+ );
362
+ return;
363
+ }
364
+
365
+ try {
366
+ const descPrefix = sessionPrefixForTool(tool);
367
+ await platform.updateChatInfo(
368
+ newChatId,
369
+ initialName,
370
+ `${descPrefix} ${sessionId}`,
371
+ );
372
+ console.log(
373
+ `[${ts()}] [STEP 3/4] Renamed group → name="${initialName}" (${toolLabel}) → OK`,
374
+ );
375
+ } catch (err) {
376
+ console.error(`[${ts()}] [STEP 3/4] FAIL: ${(err as Error).message}`);
377
+ logTrace(tid, "DONE", {
378
+ outcome: "new_rename_fail",
379
+ error: (err as Error).message,
380
+ });
381
+ await platform.sendCard(
382
+ chatId,
383
+ "Error",
384
+ `Group created but rename failed:\n${(err as Error).message}`,
385
+ "yellow",
386
+ );
387
+ return;
388
+ }
389
+
390
+ // 让新群的默认工作目录继承当前会话的 cwd
391
+ await setDefaultCwd(cwd, newChatId);
392
+ bindChatToSession(sessionId, newChatId);
393
+ await recordSessionRegistry({
394
+ chatId: newChatId,
395
+ sessionId,
396
+ tool,
397
+ chatName: initialName,
398
+ turnCount: 0,
399
+ startTime: Date.now(),
400
+ running: false,
401
+ });
402
+ await saveSessionTool(sessionId, tool, initialName);
403
+
404
+ await platform.sendCard(
405
+ newChatId,
406
+ `${toolLabel} Session Ready`,
407
+ `群聊已创建,这是你的 **${toolLabel}** 会话群。\n\n` +
408
+ `**Session ID:** ${sessionId}\n` +
409
+ `**工作目录:** \`${cwd}\`\n\n` +
410
+ `直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
411
+ `发送 **/cd** 切换新建会话的默认目录。\n` +
412
+ `发送 **/new** 创建新会话,**/newh** 重置当前会话(沿用工作目录)。\n` +
413
+ `发送 **/sessions** 查看所有会话状态。\n` +
414
+ `发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5\`。`,
415
+ "green",
416
+ );
417
+
418
+ console.log(`[${ts()}] [STEP 4/4] Replied to new group → OK`);
419
+ logTrace(tid, "DONE", {
420
+ outcome: "session_ready",
421
+ newChatId,
422
+ sessionId,
423
+ tool,
424
+ });
425
+ platform.setChatAvatar(newChatId, tool, "new").catch(() => {});
426
+ console.log(`${"=".repeat(60)}`);
427
+ return;
428
+ }
429
+
430
+ // 检测会话上下文:群聊从 description 获取,私聊从 session-registry 获取
431
+ let sessionId: string | null = null;
432
+ let descriptionTool: string | null = null;
433
+ let toolLabel: string | null = null;
434
+ let chatInfo: Awaited<ReturnType<PlatformAdapter["getChatInfo"]>> | undefined;
435
+ let description: string | undefined;
436
+
437
+ if (chatType !== "p2p") {
438
+ try {
439
+ chatInfo = await platform.getChatInfo(chatId);
440
+ description = chatInfo.description;
441
+ const sessionInfo = platform.extractSessionInfo(description);
442
+ if (sessionInfo) {
443
+ sessionId = sessionInfo.sessionId;
444
+ descriptionTool = sessionInfo.tool;
445
+ toolLabel = toolDisplayName(descriptionTool);
446
+ }
447
+ } catch (err) {
448
+ logTrace(tid, "BRANCH", {
449
+ reason: "get_chat_info_failed",
450
+ error: (err as Error).message,
451
+ });
452
+ console.log(
453
+ `[${ts()}] [INFO] Cannot get chat info for ${chatId}: ${(err as Error).message}`,
454
+ );
455
+ }
456
+ } else {
457
+ // 私聊:从 session-registry.json 获取绑定的 session
458
+ try {
459
+ const registry = await loadSessionRegistryForBinding();
460
+ const record = registry[chatId];
461
+ if (record && record.sessionId && record.tool) {
462
+ sessionId = record.sessionId;
463
+ descriptionTool = record.tool;
464
+ toolLabel = toolDisplayName(descriptionTool);
465
+ // 确保 sessionInfoMap 中有该私聊的信息
466
+ if (!sessionInfoMap.has(chatId)) {
467
+ sessionInfoMap.set(chatId, {
468
+ sessionId,
469
+ turnCount: record.turnCount ?? 0,
470
+ lastContextTokens: record.lastContextTokens ?? 0,
471
+ startTime: record.startTime ?? Date.now(),
472
+ tool: descriptionTool,
473
+ });
474
+ }
475
+ bindChatToSession(sessionId, chatId);
476
+ }
477
+ } catch (err) {
478
+ console.log(
479
+ `[${ts()}] [INFO] Cannot load registry for p2p ${chatId}: ${(err as Error).message}`,
480
+ );
481
+ }
482
+ }
483
+
484
+ if (sessionId && descriptionTool && toolLabel) {
485
+ // 有会话上下文 — 路由到命令处理或 prompt
486
+ logTrace(tid, "BRANCH", { sessionId, tool: descriptionTool });
487
+ console.log(
488
+ `[${ts()}] [RESUME] ${toolLabel} session group detected, session=${sessionId} tool=${descriptionTool}`,
489
+ );
490
+
491
+ if (
492
+ chatType !== "p2p" &&
493
+ isUntitledSessionChatName(chatInfo!.name) &&
494
+ !textLower.startsWith("/")
495
+ ) {
496
+ const MAX_PREFIX = 10;
497
+ const prefix = text.slice(0, MAX_PREFIX);
498
+ const adapter = getAdapterForTool(descriptionTool);
499
+ const info = await adapter
500
+ .getSessionInfo(sessionId)
501
+ .catch(() => undefined);
502
+ const sessionCwd = info?.cwd ?? (await getDefaultCwd(chatId));
503
+ const newName = sessionChatName(prefix, sessionCwd);
504
+ try {
505
+ await platform.updateChatInfo(chatId, newName, description!);
506
+ console.log(
507
+ `[${ts()}] [RENAME] First message → group renamed to "${newName}"`,
508
+ );
509
+ await recordSessionRegistry({
510
+ chatId,
511
+ sessionId,
512
+ tool: descriptionTool,
513
+ chatName: newName,
514
+ }).catch(() => {});
515
+ await saveSessionTool(sessionId, descriptionTool, newName).catch(
516
+ () => {},
517
+ );
518
+ } catch (err) {
519
+ console.error(
520
+ `[${ts()}] [RENAME] Failed: ${(err as Error).message}`,
521
+ );
522
+ }
523
+ }
524
+
525
+ // 微信 P2P:首条非指令消息 → 更新 registry 中的会话名
526
+ if (
527
+ chatType === "p2p" &&
528
+ platform.kind === "wechat" &&
529
+ !textLower.startsWith("/")
530
+ ) {
531
+ try {
532
+ const reg = await loadSessionRegistryForBinding();
533
+ const rec = reg[chatId];
534
+ if (
535
+ rec &&
536
+ rec.sessionId === sessionId &&
537
+ isUntitledSessionChatName(rec.chatName ?? "")
538
+ ) {
539
+ const MAX_PREFIX = 10;
540
+ const prefix = text.slice(0, MAX_PREFIX);
541
+ const adapter = getAdapterForTool(descriptionTool!);
542
+ const info = await adapter
543
+ .getSessionInfo(sessionId)
544
+ .catch(() => undefined);
545
+ const sessionCwd =
546
+ info?.cwd ?? (await getDefaultCwd(chatId));
547
+ const newName2 = sessionChatName(prefix, sessionCwd);
548
+ await recordSessionRegistry({
549
+ chatId,
550
+ sessionId,
551
+ tool: descriptionTool!,
552
+ chatName: newName2,
553
+ }).catch(() => {});
554
+ await saveSessionTool(sessionId, descriptionTool!, newName2).catch(
555
+ () => {},
556
+ );
557
+ console.log(
558
+ `[${ts()}] [RENAME] WeChat P2P → "${newName2}"`,
559
+ );
560
+ }
561
+ } catch (err) {
562
+ console.error(
563
+ `[${ts()}] [RENAME] WeChat P2P failed: ${(err as Error).message}`,
564
+ );
565
+ }
566
+ }
567
+
568
+ if (textLower === "/stop") {
569
+ logTrace(tid, "BRANCH", { cmd: "/stop" });
570
+ if (stopSession(sessionId)) {
571
+ console.log(`[${ts()}] [STOP] User sent /stop, session=${sessionId}`);
572
+ await platform.sendText(chatId, "会话已停止。").catch(() => {});
573
+ logTrace(tid, "DONE", { outcome: "stopped" });
574
+ } else {
575
+ await platform
576
+ .sendText(chatId, "当前没有正在进行的会话。")
577
+ .catch(() => {});
578
+ logTrace(tid, "DONE", { outcome: "stop_no_session" });
579
+ }
580
+ return;
581
+ }
582
+
583
+ if (textLower === "/status") {
584
+ logTrace(tid, "BRANCH", { cmd: "/status" });
585
+ const status = await getSessionStatus(chatId);
586
+ const isActive = isSessionRunning(sessionId);
587
+ const statusText = [
588
+ `**群名:** ${status?.chatName || "—"}`,
589
+ `**Session ID:** \`${status?.sessionId ?? sessionId}\``,
590
+ `**工具:** ${toolLabel}`,
591
+ `**状态:** ${isActive ? "🟢 运行中" : "⚪ 空闲"}`,
592
+ `**已对话轮数:** ${status?.turnCount ?? 0}`,
593
+ `**模型:** ${status?.model ?? anthropicConfigDisplay(CLAUDE_MODEL)}`,
594
+ ];
595
+ if (status?.effort != null) {
596
+ statusText.push(`**Effort:** ${status.effort}`);
597
+ }
598
+ if (isActive) {
599
+ const elapsed = Math.floor((Date.now() - status!.startTime) / 1000);
600
+ const mins = Math.floor(elapsed / 60);
601
+ const secs = elapsed % 60;
602
+ statusText.push(`**本轮已运行:** ${mins}分${secs}秒`);
603
+ statusText.push(
604
+ `**已产出总字符:** ${status!.accumulatedLength.toLocaleString()}`,
605
+ );
606
+ }
607
+ if (status?.lastContextTokens) {
608
+ statusText.push(
609
+ `**上下文 Token 数:** ~${status.lastContextTokens.toLocaleString()}`,
610
+ );
611
+ }
612
+ const card = buildStatusCard(
613
+ statusText.join("\n"),
614
+ isActive ? "blue" : "green",
615
+ );
616
+ const ok = await platform.sendRawCard(chatId, card);
617
+ console.log(`[${ts()}] [STATUS] card sent, ok=${ok}`);
618
+ logTrace(tid, "DONE", { outcome: "status", ok });
619
+ return;
620
+ }
621
+
622
+ if (textLower === "/sessions") {
623
+ logTrace(tid, "BRANCH", { cmd: "/sessions" });
624
+ const allSessions = await getAllSessionsStatus();
625
+ const now = Date.now();
626
+ const cardData = allSessions.map((s) => ({
627
+ sessionId: s.sessionId,
628
+ chatName: s.chatName,
629
+ chatId: s.chatId,
630
+ active: s.active,
631
+ turnCount: s.turnCount,
632
+ elapsedSeconds: s.active
633
+ ? Math.floor((now - s.startTime) / 1000)
634
+ : null,
635
+ model: s.model,
636
+ tool: s.tool,
637
+ }));
638
+ const card = buildSessionsCard(cardData);
639
+ const ok = await platform.sendRawCard(chatId, card);
640
+ console.log(
641
+ `[${ts()}] [SESSIONS] card sent, ok=${ok}, count=${cardData.length}`,
642
+ );
643
+ logTrace(tid, "DONE", { outcome: "sessions", ok, count: cardData.length });
644
+ return;
645
+ }
646
+
647
+ if (textLower === "/newh") {
648
+ logTrace(tid, "BRANCH", { cmd: "/newh" });
649
+ const adapter = getAdapterForTool(descriptionTool);
650
+ let cwd: string;
651
+ try {
652
+ const info = await adapter.getSessionInfo(sessionId);
653
+ cwd = info?.cwd ?? (await getDefaultCwd(chatId));
654
+ } catch {
655
+ cwd = await getDefaultCwd(chatId);
656
+ }
657
+
658
+ // 第一步:创建新 session(此时尚未碰任何内存绑定,失败可直接返回,
659
+ // 旧 session 状态完全保留)。
660
+ let newSessionId: string;
661
+ try {
662
+ const init = await initClaudeSession(descriptionTool, cwd);
663
+ newSessionId = init.sessionId;
664
+ } catch (err) {
665
+ logTrace(tid, "DONE", {
666
+ outcome: "newh_session_fail",
667
+ error: (err as Error).message,
668
+ });
669
+ await platform.sendCard(
670
+ chatId,
671
+ "Error",
672
+ `Failed to create new session:\n${(err as Error).message}`,
673
+ "red",
674
+ );
675
+ return;
676
+ }
677
+
678
+ // 第二步:事务式切换 chat 绑定
679
+ const descPrefix = sessionPrefixForTool(descriptionTool);
680
+ const newName = sessionChatName("新会话", cwd);
681
+ const switchResult = await switchChatBinding({
682
+ chatId,
683
+ chatType,
684
+ oldSessionId: sessionId,
685
+ newSessionId,
686
+ tool: descriptionTool,
687
+ chatName: newName,
688
+ newDescription: `${descPrefix} ${newSessionId}`,
689
+ updateChatInfoFn: (cid, name, desc) =>
690
+ platform.updateChatInfo(cid, name, desc),
691
+ });
692
+ if (!switchResult.ok) {
693
+ logTrace(tid, "DONE", {
694
+ outcome: "newh_update_chat_fail",
695
+ error: switchResult.error?.message,
696
+ });
697
+ await platform.sendCard(
698
+ chatId,
699
+ "Error",
700
+ `更新群描述失败,会话未切换(新 session 已创建但未启用):\n${switchResult.error?.message}`,
701
+ "red",
702
+ );
703
+ return;
704
+ }
705
+ if (chatType !== "p2p") {
706
+ console.log(
707
+ `[${ts()}] [NEWH] Group updated: name="${newName}" desc="${descPrefix} ${newSessionId}"`,
708
+ );
709
+ }
710
+
711
+ platform
712
+ .setChatAvatar(chatId, descriptionTool, "new")
713
+ .catch(() => {});
714
+
715
+ if (isSessionRunning(newSessionId)) {
716
+ const { ensureDisplayLoop } = await import("./session.ts");
717
+ ensureDisplayLoop(newSessionId);
718
+ }
719
+
720
+ await platform.sendCard(
721
+ chatId,
722
+ `${toolLabel} Session Reset`,
723
+ `会话已重置为新的 **${toolLabel}** 会话。\n\n` +
724
+ `**Session ID:** ${newSessionId}\n` +
725
+ `**工作目录:** \`${cwd}\`(沿用当前会话目录)\n\n` +
726
+ `直接在这里发消息即可继续对话。\n` +
727
+ `发送 **/cd** 可切换新建会话的默认目录。`,
728
+ "green",
729
+ );
730
+
731
+ console.log(
732
+ `[${ts()}] [NEWH] Session ${sessionId} → ${newSessionId} (same cwd=${cwd})`,
733
+ );
734
+ logTrace(tid, "DONE", { outcome: "newh", newSessionId, cwd });
735
+ return;
736
+ }
737
+
738
+ if (textLower === "/deleteg") {
739
+ logTrace(tid, "BRANCH", { cmd: "/deleteg" });
740
+ if (chatType === "p2p") {
741
+ await platform
742
+ .sendText(chatId, "私聊无法使用 /deleteg,该指令仅用于群聊。")
743
+ .catch(() => {});
744
+ logTrace(tid, "DONE", { outcome: "deleteg_p2p" });
745
+ return;
746
+ }
747
+ console.log(
748
+ `[${ts()}] [DELETEG] Disbanding group chat ${chatId}, session=${sessionId}`,
749
+ );
750
+
751
+ // 先解绑 session(不删除 Agent 会话)
752
+ unbindChatFromSession(sessionId, chatId);
753
+ displayCards.delete(chatId);
754
+ sessionInfoMap.delete(chatId);
755
+ await removeSessionRegistryRecord(chatId);
756
+
757
+ await platform
758
+ .sendText(chatId, "群聊已解散,Agent 会话保留。")
759
+ .catch(() => {});
760
+
761
+ // 解散群聊
762
+ try {
763
+ await platform.disbandChat(chatId);
764
+ console.log(`[${ts()}] [DELETEG] Group disbanded: ${chatId}`);
765
+ } catch (err) {
766
+ console.error(
767
+ `[${ts()}] [DELETEG] Disband API failed: ${(err as Error).message}`,
768
+ );
769
+ }
770
+
771
+ logTrace(tid, "DONE", { outcome: "deleteg", chatId, sessionId });
772
+ return;
773
+ }
774
+
775
+ // /session <number>:切换到 /sessions 列表中的指定会话
776
+ const sessionMatch = textLower.match(/^\/session\s+(\d+)$/);
777
+ if (sessionMatch) {
778
+ const index = parseInt(sessionMatch[1], 10) - 1;
779
+ logTrace(tid, "BRANCH", { cmd: "/session", index: index + 1 });
780
+ const allSessions = await getAllSessionsStatus();
781
+ const claudeOrdered = allSessions.filter(
782
+ (s) => s.tool !== "cursor" && s.tool !== "codex",
783
+ );
784
+ const cursorOrdered = allSessions.filter((s) => s.tool === "cursor");
785
+ const codexOrdered = allSessions.filter((s) => s.tool === "codex");
786
+ const ordered = [
787
+ ...claudeOrdered,
788
+ ...cursorOrdered,
789
+ ...codexOrdered,
790
+ ];
791
+ if (ordered.length === 0) {
792
+ await platform.sendCard(
793
+ chatId,
794
+ "/session",
795
+ "暂无历史会话。",
796
+ "yellow",
797
+ );
798
+ logTrace(tid, "DONE", { outcome: "session_no_sessions" });
799
+ return;
800
+ }
801
+ if (index < 0 || index >= ordered.length) {
802
+ await platform.sendCard(
803
+ chatId,
804
+ "/session",
805
+ `序号超出范围,当前共 ${ordered.length} 个会话。`,
806
+ "yellow",
807
+ );
808
+ logTrace(tid, "DONE", {
809
+ outcome: "session_out_of_range",
810
+ index: index + 1,
811
+ total: ordered.length,
812
+ });
813
+ return;
814
+ }
815
+ const target = ordered[index];
816
+
817
+ // 切换到当前已在使用的会话:no-op,避免解绑再重绑的抖动
818
+ if (target.sessionId === sessionId) {
819
+ await platform.sendCard(
820
+ chatId,
821
+ "/session",
822
+ "已经是当前会话。",
823
+ "green",
824
+ );
825
+ logTrace(tid, "DONE", { outcome: "session_already_current", sessionId });
826
+ return;
827
+ }
828
+
829
+ const targetAdapter = getAdapterForTool(target.tool);
830
+ let cwd2: string;
831
+ try {
832
+ const targetInfo = await targetAdapter.getSessionInfo(
833
+ target.sessionId,
834
+ );
835
+ cwd2 = targetInfo?.cwd ?? (await getDefaultCwd(chatId));
836
+ } catch {
837
+ cwd2 = await getDefaultCwd(chatId);
838
+ }
839
+
840
+ const descPrefix2 = sessionPrefixForTool(target.tool);
841
+ const newName2 = target.chatName || sessionChatName("新会话", cwd2);
842
+ const switchResult = await switchChatBinding({
843
+ chatId,
844
+ chatType,
845
+ oldSessionId: sessionId,
846
+ newSessionId: target.sessionId,
847
+ tool: target.tool,
848
+ chatName: newName2,
849
+ newDescription: `${descPrefix2} ${target.sessionId}`,
850
+ initialTurnCount: target.turnCount,
851
+ initialContextTokens: 0,
852
+ updateChatInfoFn: (cid, name, desc) =>
853
+ platform.updateChatInfo(cid, name, desc),
854
+ });
855
+ if (!switchResult.ok) {
856
+ logTrace(tid, "DONE", {
857
+ outcome: "session_update_chat_fail",
858
+ error: switchResult.error?.message,
859
+ });
860
+ await platform.sendCard(
861
+ chatId,
862
+ "Error",
863
+ `更新群描述失败,会话未切换:\n${switchResult.error?.message}`,
864
+ "red",
865
+ );
866
+ return;
867
+ }
868
+ if (chatType !== "p2p") {
869
+ console.log(
870
+ `[${ts()}] [SESSION] Switched to session ${target.sessionId} (#${index + 1}), name="${newName2}"`,
871
+ );
872
+ }
873
+
874
+ platform.setChatAvatar(chatId, target.tool, "new").catch(() => {});
875
+
876
+ if (isSessionRunning(target.sessionId)) {
877
+ const { ensureDisplayLoop } = await import("./session.ts");
878
+ ensureDisplayLoop(target.sessionId);
879
+ }
880
+
881
+ const targetToolLabel = toolDisplayName(target.tool);
882
+ const busyNote = isSessionRunning(target.sessionId)
883
+ ? "\n\n⚠️ 该会话当前正在生成中,请等待完成后再发送消息。"
884
+ : "";
885
+ await platform.sendCard(
886
+ chatId,
887
+ `${targetToolLabel} Session Switched`,
888
+ `已切换到 **${targetToolLabel}** 会话。\n\n` +
889
+ `**序号:** ${index + 1}\n` +
890
+ `**Session ID:** ${target.sessionId}\n` +
891
+ `**工作目录:** \`${cwd2}\`\n\n` +
892
+ `直接在这里发消息即可继续对话。${busyNote}`,
893
+ "green",
894
+ );
895
+
896
+ logTrace(tid, "DONE", {
897
+ outcome: "session_switch",
898
+ sessionId: target.sessionId,
899
+ index: index + 1,
900
+ cwd: cwd2,
901
+ });
902
+ return;
903
+ }
904
+
905
+ // /git <args>:在「当前会话工作目录」执行 git 命令
906
+ if (textLower.startsWith("/git ") || textLower === "/git") {
907
+ const args = text === "/git" ? "" : text.slice(5).trim();
908
+ logTrace(tid, "BRANCH", { cmd: "/git", args: args || "(none)" });
909
+ if (!args) {
910
+ logTrace(tid, "DONE", { outcome: "git_no_args" });
911
+ await platform.sendCard(
912
+ chatId,
913
+ "/git",
914
+ "用法:`/git <子命令> [参数]`,例如 `/git status`、`/git log --oneline -n 5`。",
915
+ "yellow",
916
+ );
917
+ return;
918
+ }
919
+
920
+ const adapter = getAdapterForTool(descriptionTool);
921
+ let cwd: string | undefined;
922
+ try {
923
+ const info = await adapter.getSessionInfo(sessionId);
924
+ cwd = info?.cwd;
925
+ } catch (err) {
926
+ console.error(
927
+ `[${ts()}] [GIT] getSessionInfo FAIL: ${(err as Error).message}`,
928
+ );
929
+ }
930
+ if (!cwd) {
931
+ logTrace(tid, "DONE", { outcome: "git_no_cwd", tool: descriptionTool });
932
+ const isCursor = descriptionTool === "cursor";
933
+ const hint = isCursor
934
+ ? "无法获取当前 Cursor 会话的工作目录(缺少 sessionId→cwd 持久化映射)。请先在本群发送一条普通消息(让 adapter 从 cursor-agent 流中自动补回 cwd),然后再试 /git;若仍失败,可用 /new 重建会话。"
935
+ : `无法获取当前会话的工作目录(${toolLabel} adapter 未返回 cwd)。请先与 AI 对话一次再试,或检查会话是否仍存在。`;
936
+ await platform.sendCard(chatId, "/git", hint, "red");
937
+ return;
938
+ }
939
+
940
+ console.log(
941
+ `[${ts()}] [GIT] chat=${chatId} cwd=${cwd} cmd="git ${args}" timeoutMs=${GIT_TIMEOUT_MS}`,
942
+ );
943
+ const result = await runGitCommand(args, cwd, {
944
+ timeoutMs: GIT_TIMEOUT_MS,
945
+ });
946
+ console.log(
947
+ `[${ts()}] [GIT] exitCode=${result.exitCode}, durationMs=${result.durationMs}, truncated=${result.truncated}, timedOut=${result.timedOut}`,
948
+ );
949
+ const content = formatGitResult(args, cwd, result);
950
+ const template = gitResultHeaderTemplate(result);
951
+ await platform.sendCard(chatId, "/git 输出", content, template);
952
+ logTrace(tid, "DONE", {
953
+ outcome: "git_result",
954
+ exitCode: result.exitCode,
955
+ durationMs: result.durationMs,
956
+ });
957
+ return;
958
+ }
959
+
960
+ const lastTs = lastMsgTimestamps.get(chatId);
961
+ if (lastTs !== undefined && msgTimestamp <= lastTs) {
962
+ logTrace(tid, "DONE", {
963
+ outcome: "skip_old_message_no_session",
964
+ msgTimestamp,
965
+ lastTimestamp: lastTs,
966
+ });
967
+ console.log(
968
+ `[${ts()}] [SKIP] Older message (${msgTimestamp} <= ${lastTs}), no active session, ignoring`,
969
+ );
970
+ return;
971
+ }
972
+
973
+ // 并发检查:同一 session 只能有一个活跃 prompt
974
+ if (isSessionRunning(sessionId)) {
975
+ logTrace(tid, "BLOCKED", {
976
+ outcome: "session_busy",
977
+ sessionId,
978
+ });
979
+ console.log(
980
+ `[${ts()}] [BLOCKED] Session ${sessionId} is already generating, rejecting message from chat ${chatId}`,
981
+ );
982
+ await platform.sendCard(
983
+ chatId,
984
+ "生成中",
985
+ "该会话正在生成回复中,请等待完成后再发送新消息。",
986
+ "yellow",
987
+ );
988
+ return;
989
+ }
990
+
991
+ try {
992
+ logTrace(tid, "RESUME", { sessionId, tool: descriptionTool });
993
+ await resumeAndPrompt(
994
+ sessionId,
995
+ text,
996
+ platform,
997
+ chatId,
998
+ msgTimestamp,
999
+ descriptionTool,
1000
+ tid,
1001
+ );
1002
+ logTrace(tid, "DONE", { outcome: "resume_done", sessionId });
1003
+ console.log(`[${ts()}] [RESUME] Session ${sessionId} done`);
1004
+ } catch (err) {
1005
+ logTrace(tid, "DONE", {
1006
+ outcome: "resume_fail",
1007
+ error: (err as Error).message,
1008
+ });
1009
+ console.error(`[${ts()}] [RESUME] FAIL: ${(err as Error).message}`);
1010
+ fileLog.flush();
1011
+ await platform.sendCard(
1012
+ chatId,
1013
+ "Error",
1014
+ `Failed to resume ${toolLabel} session:\n${(err as Error).message}`,
1015
+ "red",
1016
+ );
1017
+ }
1018
+ return;
1019
+ }
1020
+
1021
+ // 无会话上下文 → help card
1022
+ logTrace(tid, "SEND", { method: "help_card", chatId });
1023
+ const card = buildHelpCard(text);
1024
+ const ok = await platform.sendRawCard(chatId, card);
1025
+ if (!ok) {
1026
+ console.error(`[${ts()}] [SEND] help_card FAIL: chatId=${chatId}`);
1027
+ logTrace(tid, "DONE", { outcome: "help_card_fail" });
1028
+ } else {
1029
+ console.log(`[${ts()}] [SEND] help_card OK: chatId=${chatId}`);
1030
+ logTrace(tid, "DONE", { outcome: "help_card_sent" });
1031
+ }
1032
+ }