chatccc 0.2.41 → 0.2.42

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.
@@ -1,9 +1,9 @@
1
- import { mkdtemp, readFile, rm } from "node:fs/promises";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { describe, it, expect, vi } from "vitest";
5
-
6
- import { buildCrashLoggingHandlers, installCrashLogging, setupFileLogging } from "../shared.ts";
1
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, it, expect, vi } from "vitest";
5
+
6
+ import { buildCrashLoggingHandlers, installCrashLogging, setupFileLogging } from "../shared.ts";
7
7
 
8
8
  describe("buildCrashLoggingHandlers", () => {
9
9
  it("uncaughtException: 写诊断、刷新日志、调用 onFatal", () => {
@@ -144,7 +144,7 @@ describe("buildCrashLoggingHandlers", () => {
144
144
  });
145
145
  });
146
146
 
147
- describe("installCrashLogging", () => {
147
+ describe("installCrashLogging", () => {
148
148
  it("注册所有相关事件监听器", () => {
149
149
  const before = {
150
150
  uncaught: process.listenerCount("uncaughtException"),
@@ -209,58 +209,58 @@ describe("installCrashLogging", () => {
209
209
  } finally {
210
210
  cleanup();
211
211
  }
212
- });
213
- });
214
-
215
- describe("setupFileLogging", () => {
216
- it("flush 后继续写日志不会触发 write after end,且日志已落盘", async () => {
217
- const originalLog = console.log;
218
- const originalError = console.error;
219
- console.log = vi.fn() as never;
220
- console.error = vi.fn() as never;
221
- const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
222
-
223
- try {
224
- const fileLog = setupFileLogging(dir, "index");
225
-
226
- console.log("before flush");
227
- fileLog.flush();
228
-
229
- expect(() => console.error("after flush")).not.toThrow();
230
- fileLog.flush();
231
-
232
- const content = await readFile(fileLog.logPath, "utf8");
233
- expect(content).toContain("[LOG] before flush");
234
- expect(content).toContain("[ERR] after flush");
235
- } finally {
236
- console.log = originalLog;
237
- console.error = originalError;
238
- await rm(dir, { recursive: true, force: true });
239
- }
240
- });
241
-
242
- it("日志参数无法 JSON 序列化时也不会让 console 调用抛错", async () => {
243
- const originalLog = console.log;
244
- const originalError = console.error;
245
- console.log = vi.fn() as never;
246
- console.error = vi.fn() as never;
247
- const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
248
-
249
- try {
250
- const fileLog = setupFileLogging(dir, "index");
251
- const circular: Record<string, unknown> = { name: "root" };
252
- circular.self = circular;
253
-
254
- expect(() => console.log("circular", circular)).not.toThrow();
255
- fileLog.flush();
256
-
257
- const content = await readFile(fileLog.logPath, "utf8");
258
- expect(content).toContain("[LOG] circular");
259
- expect(content).toContain("self");
260
- } finally {
261
- console.log = originalLog;
262
- console.error = originalError;
263
- await rm(dir, { recursive: true, force: true });
264
- }
265
- });
266
- });
212
+ });
213
+ });
214
+
215
+ describe("setupFileLogging", () => {
216
+ it("flush 后继续写日志不会触发 write after end,且日志已落盘", async () => {
217
+ const originalLog = console.log;
218
+ const originalError = console.error;
219
+ console.log = vi.fn() as never;
220
+ console.error = vi.fn() as never;
221
+ const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
222
+
223
+ try {
224
+ const fileLog = setupFileLogging(dir, "index");
225
+
226
+ console.log("before flush");
227
+ fileLog.flush();
228
+
229
+ expect(() => console.error("after flush")).not.toThrow();
230
+ fileLog.flush();
231
+
232
+ const content = await readFile(fileLog.logPath, "utf8");
233
+ expect(content).toContain("[LOG] before flush");
234
+ expect(content).toContain("[ERR] after flush");
235
+ } finally {
236
+ console.log = originalLog;
237
+ console.error = originalError;
238
+ await rm(dir, { recursive: true, force: true });
239
+ }
240
+ });
241
+
242
+ it("日志参数无法 JSON 序列化时也不会让 console 调用抛错", async () => {
243
+ const originalLog = console.log;
244
+ const originalError = console.error;
245
+ console.log = vi.fn() as never;
246
+ console.error = vi.fn() as never;
247
+ const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
248
+
249
+ try {
250
+ const fileLog = setupFileLogging(dir, "index");
251
+ const circular: Record<string, unknown> = { name: "root" };
252
+ circular.self = circular;
253
+
254
+ expect(() => console.log("circular", circular)).not.toThrow();
255
+ fileLog.flush();
256
+
257
+ const content = await readFile(fileLog.logPath, "utf8");
258
+ expect(content).toContain("[LOG] circular");
259
+ expect(content).toContain("self");
260
+ } finally {
261
+ console.log = originalLog;
262
+ console.error = originalError;
263
+ await rm(dir, { recursive: true, force: true });
264
+ }
265
+ });
266
+ });
@@ -1,4 +1,7 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
2
5
  import {
3
6
  chatSessionMap,
4
7
  sessionInfoMap,
@@ -7,9 +10,12 @@ import {
7
10
  resetState,
8
11
  getSessionStatus,
9
12
  getAllSessionsStatus,
13
+ recordSessionRegistry,
10
14
  accumulateBlockContent,
11
15
  pickFinalReply,
12
16
  UNKNOWN_MODEL_PLACEHOLDER,
17
+ _setSessionRegistryFileForTest,
18
+ _resetSessionRegistryFileForTest,
13
19
  _setAdapterForToolForTest,
14
20
  _clearAdapterCacheForTest,
15
21
  } from "../session.ts";
@@ -196,39 +202,146 @@ describe("getSessionStatus", () => {
196
202
  });
197
203
 
198
204
  describe("getAllSessionsStatus", () => {
199
- beforeEach(() => {
205
+ let registryFile = "";
206
+
207
+ beforeEach(async () => {
200
208
  chatSessionMap.clear();
201
209
  sessionInfoMap.clear();
210
+ const dir = await mkdtemp(join(tmpdir(), "chatccc-session-registry-"));
211
+ registryFile = join(dir, "session-registry.json");
212
+ _setSessionRegistryFileForTest(registryFile);
202
213
  });
203
214
 
204
- afterEach(() => {
215
+ afterEach(async () => {
205
216
  _clearAdapterCacheForTest();
217
+ _resetSessionRegistryFileForTest();
218
+ if (registryFile) {
219
+ await rm(dirname(registryFile), { recursive: true, force: true });
220
+ }
206
221
  });
207
222
 
208
223
  it("returns empty array when no sessions", async () => {
209
224
  await expect(getAllSessionsStatus()).resolves.toEqual([]);
210
225
  });
211
226
 
212
- it("returns statuses for all recorded sessions", async () => {
227
+ it("does not read memory-only sessions", async () => {
213
228
  mockSessionInfo("chat1", { sessionId: "s1" });
214
229
  mockSessionInfo("chat2", { sessionId: "s2" });
215
230
  mockActiveSession("chat1");
231
+
232
+ const result = await getAllSessionsStatus();
233
+ expect(result).toEqual([]);
234
+ });
235
+
236
+ it("returns statuses from disk registry", async () => {
237
+ await recordSessionRegistry({
238
+ chatId: "chat1",
239
+ sessionId: "s1",
240
+ tool: "claude",
241
+ chatName: "test-chat-1",
242
+ turnCount: 2,
243
+ startTime: 1000,
244
+ updatedAt: 2000,
245
+ running: true,
246
+ });
247
+ await recordSessionRegistry({
248
+ chatId: "chat2",
249
+ sessionId: "s2",
250
+ tool: "claude",
251
+ chatName: "test-chat-2",
252
+ turnCount: 0,
253
+ startTime: 900,
254
+ updatedAt: 1900,
255
+ running: false,
256
+ });
257
+
216
258
  const result = await getAllSessionsStatus();
217
259
  expect(result).toHaveLength(2);
260
+ expect(result[0].chatId).toBe("chat1");
261
+ expect(result[0].active).toBe(true);
262
+ expect(result[0].turnCount).toBe(2);
263
+ expect(result[0].chatName).toBe("test-chat-1");
264
+ expect(result[1].chatId).toBe("chat2");
265
+ expect(result[1].active).toBe(false);
266
+ expect(result[1].chatName).toBe("test-chat-2");
267
+ });
268
+
269
+ it("returns recent disk sessions by updatedAt desc, limited to 20", async () => {
270
+ for (let i = 0; i < 25; i++) {
271
+ await recordSessionRegistry({
272
+ chatId: `chat-${i}`,
273
+ sessionId: `sid-${i}`,
274
+ tool: "claude",
275
+ startTime: i,
276
+ updatedAt: 1000 + i,
277
+ });
278
+ }
279
+
280
+ const result = await getAllSessionsStatus();
281
+ expect(result).toHaveLength(20);
282
+ expect(result[0].chatId).toBe("chat-24");
283
+ expect(result[19].chatId).toBe("chat-5");
284
+ expect(result.some((r) => r.chatId === "chat-4")).toBe(false);
285
+ });
286
+
287
+ it("marks disk-running sessions as active without checking chatSessionMap", async () => {
288
+ await recordSessionRegistry({
289
+ chatId: "chat1",
290
+ sessionId: "s1",
291
+ tool: "claude",
292
+ running: true,
293
+ updatedAt: 1000,
294
+ });
295
+
296
+ const result = await getAllSessionsStatus();
218
297
  expect(result.find(r => r.chatId === "chat1")!.active).toBe(true);
219
- expect(result.find(r => r.chatId === "chat2")!.active).toBe(false);
220
298
  });
221
299
 
222
- it("marks stopped sessions as not active", async () => {
223
- mockSessionInfo("chat1");
224
- mockActiveSession("chat1", { stopped: true });
300
+ it("persists chatName across updates and defaults to empty string when not set", async () => {
301
+ await recordSessionRegistry({
302
+ chatId: "chat-a",
303
+ sessionId: "sa",
304
+ tool: "claude",
305
+ chatName: "My Chat",
306
+ updatedAt: 100,
307
+ });
308
+ // Update without chatName — should keep existing
309
+ await recordSessionRegistry({
310
+ chatId: "chat-a",
311
+ sessionId: "sa",
312
+ tool: "claude",
313
+ updatedAt: 200,
314
+ });
315
+ const result = await getAllSessionsStatus();
316
+ expect(result.find(r => r.chatId === "chat-a")!.chatName).toBe("My Chat");
317
+ });
318
+
319
+ it("chatName defaults to empty string for sessions without it", async () => {
320
+ await recordSessionRegistry({
321
+ chatId: "chat-b",
322
+ sessionId: "sb",
323
+ tool: "claude",
324
+ updatedAt: 100,
325
+ });
225
326
  const result = await getAllSessionsStatus();
226
- expect(result[0].active).toBe(false);
327
+ expect(result.find(r => r.chatId === "chat-b")!.chatName).toBe("");
227
328
  });
228
329
 
229
330
  it("混合 claude + cursor 会话:各自取自己来源的 model/effort", async () => {
230
- mockSessionInfo("chat-c", { sessionId: "sid-c", tool: "claude" });
231
- mockSessionInfo("chat-x", { sessionId: "sid-x", tool: "cursor" });
331
+ await recordSessionRegistry({
332
+ chatId: "chat-c",
333
+ sessionId: "sid-c",
334
+ tool: "claude",
335
+ chatName: "claude-chat",
336
+ updatedAt: 100,
337
+ });
338
+ await recordSessionRegistry({
339
+ chatId: "chat-x",
340
+ sessionId: "sid-x",
341
+ tool: "cursor",
342
+ chatName: "cursor-chat",
343
+ updatedAt: 200,
344
+ });
232
345
  _setAdapterForToolForTest(
233
346
  "cursor",
234
347
  mockAdapter((sid) =>
@@ -473,4 +586,4 @@ describe("pickFinalReply", () => {
473
586
  }),
474
587
  ).toBe("");
475
588
  });
476
- });
589
+ });
package/src/cards.ts CHANGED
@@ -254,6 +254,7 @@ export function buildCdCard(
254
254
  // 所有会话列表卡片(Claude Code 优先,然后 Cursor)
255
255
  export function buildSessionsCard(sessions: Array<{
256
256
  sessionId: string;
257
+ chatName: string;
257
258
  active: boolean;
258
259
  turnCount: number;
259
260
  elapsedSeconds: number | null;
@@ -274,7 +275,7 @@ export function buildSessionsCard(sessions: Array<{
274
275
  config: { wide_screen_mode: true },
275
276
  header: { template: "blue", title: { content: "所有会话", tag: "plain_text" } },
276
277
  elements: [
277
- { tag: "div", text: { tag: "lark_md", content: `当前没有会话记录。\n\n使用 **/new**(默认 ${defaultToolLabel})、**/new claude**、**/new cursor** 或 **/new codex** 创建新会话。` } },
278
+ { tag: "div", text: { tag: "lark_md", content: `当前没有会话记录。\n\n使用 **/new**(默认 ${defaultToolLabel})、**/new claude**、**/new cursor** 或 **/new codex** 创建新会话。\n创建后可在任意会话群内发送 **/sessions** 查看列表,用 **/session 数字** 切换会话。` } },
278
279
  { tag: "hr" },
279
280
  { tag: "action", actions: [{ tag: "button", text: { tag: "plain_text", content: "收起" }, type: "default", value: { action: "close" } }] },
280
281
  ],
@@ -291,7 +292,8 @@ export function buildSessionsCard(sessions: Array<{
291
292
  extra = ` | 本轮: ${mins}分${secs}秒`;
292
293
  }
293
294
  const toolLabel = s.tool === "cursor" ? "Cursor" : s.tool === "codex" ? "Codex" : "Claude Code";
294
- return `**${i + 1}.** \`${shortId}\` ${status} | 工具: ${toolLabel} | 轮数: ${s.turnCount} | ${s.model}${extra}`;
295
+ const namePart = s.chatName ? `**${s.chatName}** ` : "";
296
+ return `**${i + 1}.** ${namePart}\`${shortId}\` ${status} | 工具: ${toolLabel} | 轮数: ${s.turnCount} | ${s.model}${extra}`;
295
297
  };
296
298
 
297
299
  const lines: string[] = [`共 **${sessions.length}** 个会话:`, ""];
@@ -326,7 +328,7 @@ export function buildSessionsCard(sessions: Array<{
326
328
  elements: [
327
329
  { tag: "div", text: { tag: "lark_md", content: lines.join("\n") } },
328
330
  { tag: "hr" },
329
- { tag: "div", text: { tag: "lark_md", content: "在会话群内发送 **/forget** 可重置当前会话(创建新 Session,保留工作目录和群聊)。" } },
331
+ { tag: "div", text: { tag: "lark_md", content: "在会话群内发送 **/forget** 可重置当前会话(创建新 Session,保留工作目录和群聊)。\n发送 **/session 数字**(如 `/session 1`)可将当前群聊切换到列表中对应编号的会话。" } },
330
332
  { tag: "hr" },
331
333
  {
332
334
  tag: "action",
package/src/index.ts CHANGED
@@ -102,6 +102,7 @@ import {
102
102
  resetState,
103
103
  resumeAndPrompt,
104
104
  sessionInfoMap,
105
+ recordSessionRegistry,
105
106
  getAdapterForTool,
106
107
  } from "./session.ts";
107
108
 
@@ -309,7 +310,8 @@ async function handleSimInjectMessage(req: IncomingMessage, res: ServerResponse)
309
310
 
310
311
  async function handleCommand(text: string, chatId: string, openId: string, msgTimestamp: number, chatType = "group", traceId?: string): Promise<void> {
311
312
  const tid = traceId ?? makeTraceId();
312
- if (text === "/restart") {
313
+ const textLower = text.toLowerCase();
314
+ if (textLower === "/restart") {
313
315
  logTrace(tid, "BRANCH", { cmd: "/restart" });
314
316
  const restartToken = await getTenantAccessToken();
315
317
  await sendTextReply(restartToken, chatId, "正在重启...").catch(() => {});
@@ -326,7 +328,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
326
328
  return;
327
329
  }
328
330
 
329
- if (text === "/cd" || text.startsWith("/cd ")) {
331
+ if (textLower === "/cd" || textLower.startsWith("/cd ")) {
330
332
  logTrace(tid, "BRANCH", { cmd: "/cd", arg: text.slice(3).trim() || "(none)" });
331
333
  const cdToken = await getTenantAccessToken();
332
334
  const currentDir = await getDefaultCwd(chatId);
@@ -415,8 +417,8 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
415
417
  return;
416
418
  }
417
419
 
418
- if (text === "/new" || text.startsWith("/new ")) {
419
- const toolArg = text.slice(5).trim();
420
+ if (textLower === "/new" || textLower.startsWith("/new ")) {
421
+ const toolArg = text.slice(5).trim().toLowerCase();
420
422
  const tool = toolArg || "claude";
421
423
  logTrace(tid, "BRANCH", { cmd: "/new", tool });
422
424
  const validTools = ["claude", "cursor", "codex"];
@@ -483,6 +485,15 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
483
485
 
484
486
  // 让新群的默认工作目录继承当前会话的 cwd
485
487
  await setDefaultCwd(cwd, newChatId);
488
+ await recordSessionRegistry({
489
+ chatId: newChatId,
490
+ sessionId,
491
+ tool,
492
+ chatName: initialName,
493
+ turnCount: 0,
494
+ startTime: Date.now(),
495
+ running: false,
496
+ });
486
497
 
487
498
  const adapter = getAdapterForTool(tool);
488
499
  await sendCardReply(
@@ -530,12 +541,13 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
530
541
  try {
531
542
  await updateChatInfo(freshToken, chatId, newName, description);
532
543
  console.log(`[${ts()}] [RENAME] First message → group renamed to "${newName}"`);
544
+ await recordSessionRegistry({ chatId, chatName: newName }).catch(() => {});
533
545
  } catch (err) {
534
546
  console.error(`[${ts()}] [RENAME] Failed: ${(err as Error).message}`);
535
547
  }
536
548
  }
537
549
 
538
- if (text === "/stop") {
550
+ if (textLower === "/stop") {
539
551
  logTrace(tid, "BRANCH", { cmd: "/stop" });
540
552
  const cEntry = chatSessionMap.get(chatId);
541
553
  if (cEntry) {
@@ -556,7 +568,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
556
568
  return;
557
569
  }
558
570
 
559
- if (text === "/status") {
571
+ if (textLower === "/status") {
560
572
  logTrace(tid, "BRANCH", { cmd: "/status" });
561
573
  const status = await getSessionStatus(chatId);
562
574
  const running = chatSessionMap.get(chatId);
@@ -590,12 +602,13 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
590
602
  return;
591
603
  }
592
604
 
593
- if (text === "/sessions") {
605
+ if (textLower === "/sessions") {
594
606
  logTrace(tid, "BRANCH", { cmd: "/sessions" });
595
607
  const allSessions = await getAllSessionsStatus();
596
608
  const now = Date.now();
597
609
  const cardData = allSessions.map(s => ({
598
610
  sessionId: s.sessionId,
611
+ chatName: s.chatName,
599
612
  active: s.active,
600
613
  turnCount: s.turnCount,
601
614
  elapsedSeconds: s.active ? Math.floor((now - s.startTime) / 1000) : null,
@@ -609,7 +622,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
609
622
  return;
610
623
  }
611
624
 
612
- if (text === "/forget") {
625
+ if (textLower === "/forget") {
613
626
  logTrace(tid, "BRANCH", { cmd: "/forget" });
614
627
  const adapter = getAdapterForTool(descriptionTool);
615
628
  let cwd: string;
@@ -643,9 +656,11 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
643
656
  }
644
657
 
645
658
  const descPrefix = sessionPrefixForTool(descriptionTool);
646
- const initialName = sessionChatName("新会话", cwd);
647
- await updateChatInfo(freshToken, chatId, initialName, `${descPrefix} ${newSessionId}`);
648
- console.log(`[${ts()}] [FORGET] Group updated: name="${initialName}" desc="${descPrefix} ${newSessionId}"`);
659
+ const newName = isUntitledSessionChatName(chatInfo.name)
660
+ ? sessionChatName("新会话", cwd)
661
+ : chatInfo.name;
662
+ await updateChatInfo(freshToken, chatId, newName, `${descPrefix} ${newSessionId}`);
663
+ console.log(`[${ts()}] [FORGET] Group updated: name="${newName}" desc="${descPrefix} ${newSessionId}"`);
649
664
 
650
665
  sessionInfoMap.set(chatId, {
651
666
  sessionId: newSessionId,
@@ -654,6 +669,16 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
654
669
  startTime: Date.now(),
655
670
  tool: descriptionTool,
656
671
  });
672
+ await recordSessionRegistry({
673
+ chatId,
674
+ sessionId: newSessionId,
675
+ tool: descriptionTool,
676
+ chatName: newName,
677
+ turnCount: 0,
678
+ lastContextTokens: 0,
679
+ startTime: Date.now(),
680
+ running: false,
681
+ });
657
682
 
658
683
  setChatAvatar(freshToken, chatId, descriptionTool, "new").catch(() => {});
659
684
 
@@ -671,10 +696,88 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
671
696
  return;
672
697
  }
673
698
 
699
+ // /session <number>:切换到 /sessions 列表中的指定会话
700
+ const sessionMatch = textLower.match(/^\/session\s+(\d+)$/);
701
+ if (sessionMatch) {
702
+ const index = parseInt(sessionMatch[1], 10) - 1;
703
+ logTrace(tid, "BRANCH", { cmd: "/session", index: index + 1 });
704
+ const allSessions = await getAllSessionsStatus();
705
+ if (allSessions.length === 0) {
706
+ await sendCardReply(freshToken, chatId, "/session", "暂无历史会话。", "yellow");
707
+ logTrace(tid, "DONE", { outcome: "session_no_sessions" });
708
+ return;
709
+ }
710
+ if (index < 0 || index >= allSessions.length) {
711
+ await sendCardReply(freshToken, chatId, "/session", `序号超出范围,当前共 ${allSessions.length} 个会话。`, "yellow");
712
+ logTrace(tid, "DONE", { outcome: "session_out_of_range", index: index + 1, total: allSessions.length });
713
+ return;
714
+ }
715
+ const target = allSessions[index];
716
+
717
+ const existing2 = chatSessionMap.get(chatId);
718
+ if (existing2) {
719
+ existing2.stopped = true;
720
+ if (existing2.spinnerTimer) { clearInterval(existing2.spinnerTimer); existing2.spinnerTimer = null; }
721
+ existing2.close();
722
+ const prevTs2 = lastMsgTimestamps.get(chatId);
723
+ if (prevTs2 === undefined || existing2.msgTimestamp > prevTs2) {
724
+ lastMsgTimestamps.set(chatId, existing2.msgTimestamp);
725
+ }
726
+ chatSessionMap.delete(chatId);
727
+ }
728
+
729
+ const targetAdapter = getAdapterForTool(target.tool);
730
+ let cwd2: string;
731
+ try {
732
+ const targetInfo = await targetAdapter.getSessionInfo(target.sessionId);
733
+ cwd2 = targetInfo?.cwd ?? (await getDefaultCwd(chatId));
734
+ } catch {
735
+ cwd2 = await getDefaultCwd(chatId);
736
+ }
737
+
738
+ const descPrefix2 = sessionPrefixForTool(target.tool);
739
+ const newName2 = isUntitledSessionChatName(chatInfo.name)
740
+ ? sessionChatName("新会话", cwd2)
741
+ : chatInfo.name;
742
+ await updateChatInfo(freshToken, chatId, newName2, `${descPrefix2} ${target.sessionId}`);
743
+
744
+ sessionInfoMap.set(chatId, {
745
+ sessionId: target.sessionId,
746
+ turnCount: target.turnCount,
747
+ lastContextTokens: 0,
748
+ startTime: Date.now(),
749
+ tool: target.tool,
750
+ });
751
+ await recordSessionRegistry({
752
+ chatId,
753
+ sessionId: target.sessionId,
754
+ tool: target.tool,
755
+ chatName: newName2,
756
+ running: false,
757
+ });
758
+
759
+ setChatAvatar(freshToken, chatId, target.tool, "new").catch(() => {});
760
+
761
+ const targetToolLabel = toolDisplayName(target.tool);
762
+ await sendCardReply(
763
+ freshToken, chatId, `${targetToolLabel} Session Switched`,
764
+ `已切换到 **${targetToolLabel}** 会话。\n\n` +
765
+ `**序号:** ${index + 1}\n` +
766
+ `**Session ID:** ${target.sessionId}\n` +
767
+ `**工作目录:** \`${cwd2}\`\n\n` +
768
+ `直接在这里发消息即可继续对话。`,
769
+ "green"
770
+ );
771
+
772
+ console.log(`[${ts()}] [SESSION] Switched to session ${target.sessionId} (#${index + 1})`);
773
+ logTrace(tid, "DONE", { outcome: "session_switch", sessionId: target.sessionId, index: index + 1, cwd: cwd2 });
774
+ return;
775
+ }
776
+
674
777
  // /git <args>:在「当前会话工作目录」执行 git 命令,把输出回发到群里。
675
778
  // 注意 cwd 必须取自 adapter.getSessionInfo(会话真实 cwd),而非
676
779
  // getDefaultCwd(下一次 /new 才会使用的默认路径)。
677
- if (text.startsWith("/git ") || text === "/git") {
780
+ if (textLower.startsWith("/git ") || textLower === "/git") {
678
781
  const args = text === "/git" ? "" : text.slice(5).trim();
679
782
  logTrace(tid, "BRANCH", { cmd: "/git", args: args || "(none)" });
680
783
  if (!args) {
@@ -1011,7 +1114,7 @@ async function startBotServiceCore(): Promise<void> {
1011
1114
  const openId = event.sender?.sender_id?.open_id ?? "";
1012
1115
  const chatId = message.chat_id ?? "";
1013
1116
  appendChatLog(chatId, openId, text);
1014
- if (text === "/new" && openId) {
1117
+ if (text.toLowerCase() === "/new" && openId) {
1015
1118
  console.log(`[MSG] /new from ${openId}, but local relay does not handle /new yet. Use SDK mode.`);
1016
1119
  }
1017
1120
  } catch { /* ignore */ }