chatccc 0.2.40 → 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.
@@ -0,0 +1,222 @@
1
+ /**
2
+ * OpenILink WeChat Echo Probe
3
+ * ===========================
4
+ * Minimal scan-login and text echo demo for the WeChat iLink channel.
5
+ *
6
+ * Usage:
7
+ * npm run demo:ilink-echo
8
+ *
9
+ * Behavior:
10
+ * When a user sends text to the logged-in WeChat account, this demo replies
11
+ * with "收到:" plus the received text.
12
+ */
13
+
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { createRequire } from "node:module";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ import {
20
+ Client as OpenIlinkWire,
21
+ extractText,
22
+ type GetUpdatesResponse,
23
+ type WeixinMessage,
24
+ } from "@openilink/openilink-sdk-node";
25
+
26
+ import { setupFileLogging } from "../src/shared.ts";
27
+
28
+ interface TerminalQrRenderer {
29
+ generate(
30
+ input: string,
31
+ opts: { small: boolean },
32
+ callback: (qrcode: string) => void,
33
+ ): void;
34
+ }
35
+
36
+ interface EchoProbeSnapshot {
37
+ token?: string;
38
+ baseUrl?: string;
39
+ pollCursor?: string;
40
+ botId?: string;
41
+ userId?: string;
42
+ lastSeenAt?: string;
43
+ }
44
+
45
+ const __dirname = dirname(fileURLToPath(import.meta.url));
46
+ const PROJECT_ROOT = join(__dirname, "..");
47
+ const SNAPSHOT_PATH = join(PROJECT_ROOT, "state", "ilink-echo-probe.json");
48
+ const LOG_DIR = join(__dirname, "logs");
49
+ const requireFromDemo = createRequire(import.meta.url);
50
+ const terminalQr = requireFromDemo("qrcode-terminal") as TerminalQrRenderer;
51
+
52
+ setupFileLogging(LOG_DIR, "ilink-echo-probe");
53
+
54
+ let acceptingEvents = true;
55
+
56
+ function ensureParentDir(filePath: string): void {
57
+ mkdirSync(dirname(filePath), { recursive: true });
58
+ }
59
+
60
+ function readSnapshot(): EchoProbeSnapshot {
61
+ if (!existsSync(SNAPSHOT_PATH)) {
62
+ return {};
63
+ }
64
+
65
+ try {
66
+ return JSON.parse(readFileSync(SNAPSHOT_PATH, "utf8")) as EchoProbeSnapshot;
67
+ } catch (error) {
68
+ console.warn(`Cannot read saved iLink probe state: ${(error as Error).message}`);
69
+ return {};
70
+ }
71
+ }
72
+
73
+ function writeSnapshot(snapshot: EchoProbeSnapshot): void {
74
+ ensureParentDir(SNAPSHOT_PATH);
75
+ writeFileSync(SNAPSHOT_PATH, `${JSON.stringify(snapshot, null, 2)}\n`);
76
+ }
77
+
78
+ function updateSnapshot(patch: Partial<EchoProbeSnapshot>): EchoProbeSnapshot {
79
+ const next = {
80
+ ...readSnapshot(),
81
+ ...patch,
82
+ lastSeenAt: new Date().toISOString(),
83
+ };
84
+ writeSnapshot(next);
85
+ return next;
86
+ }
87
+
88
+ function peerKeyOf(message: WeixinMessage): string {
89
+ return String(message.from_user_id ?? "");
90
+ }
91
+
92
+ function textOf(message: WeixinMessage): string {
93
+ return extractText(message).trim();
94
+ }
95
+
96
+ function printScanMaterial(content: string): void {
97
+ console.log("\n========== iLink scan content ==========");
98
+ console.log(content);
99
+ console.log("========== iLink terminal QR ==========");
100
+ terminalQr.generate(content, { small: true }, (renderedQr) => {
101
+ console.log(renderedQr);
102
+ });
103
+ console.log("========== end iLink QR ==========\n");
104
+ }
105
+
106
+ async function prepareProbeWire(saved: EchoProbeSnapshot): Promise<OpenIlinkWire> {
107
+ const ilinkWire = new OpenIlinkWire("", {
108
+ base_url: saved.baseUrl,
109
+ });
110
+
111
+ console.log("Starting iLink QR login. The QR is shown on every run.");
112
+ const loginResult = await ilinkWire.loginWithQr({
113
+ on_qrcode: (content) => {
114
+ printScanMaterial(content);
115
+ },
116
+ on_scanned: () => {
117
+ console.log("QR scanned. Confirm login in WeChat.");
118
+ },
119
+ on_expired: (attempt, maxAttempts) => {
120
+ console.log(`QR expired, refreshing (${attempt}/${maxAttempts}).`);
121
+ },
122
+ });
123
+
124
+ if (!loginResult.connected) {
125
+ throw new Error(`iLink QR login failed: ${loginResult.message}`);
126
+ }
127
+
128
+ updateSnapshot({
129
+ token: loginResult.bot_token ?? ilinkWire.token,
130
+ baseUrl: loginResult.base_url ?? ilinkWire.baseUrl,
131
+ botId: loginResult.bot_id,
132
+ userId: loginResult.user_id,
133
+ pollCursor: "",
134
+ });
135
+
136
+ console.log(`iLink login ready. BotID=${loginResult.bot_id ?? ""}`);
137
+ return ilinkWire;
138
+ }
139
+
140
+ async function mirrorIncomingLine(
141
+ ilinkWire: OpenIlinkWire,
142
+ message: WeixinMessage,
143
+ ): Promise<void> {
144
+ const peerKey = peerKeyOf(message);
145
+ if (!peerKey) {
146
+ console.warn("Skip message without from_user_id.");
147
+ return;
148
+ }
149
+
150
+ const incomingText = textOf(message);
151
+ const outgoingText = incomingText ? `收到:${incomingText}` : "收到:[non-text message]";
152
+ const contextToken = message.context_token;
153
+
154
+ if (contextToken) {
155
+ await ilinkWire.sendText(peerKey, outgoingText, contextToken);
156
+ } else {
157
+ await ilinkWire.push(peerKey, outgoingText);
158
+ }
159
+
160
+ console.log(`Echoed to ${peerKey}: ${outgoingText.slice(0, 120)}`);
161
+ }
162
+
163
+ function rememberPollingCursor(response: GetUpdatesResponse): void {
164
+ const pollCursor = response.sync_buf ?? response.get_updates_buf;
165
+ if (!pollCursor) {
166
+ return;
167
+ }
168
+ updateSnapshot({ pollCursor });
169
+ }
170
+
171
+ function installStopHooks(): void {
172
+ const stop = (): void => {
173
+ if (!acceptingEvents) {
174
+ return;
175
+ }
176
+ acceptingEvents = false;
177
+ console.log("Stopping iLink echo probe...");
178
+ };
179
+
180
+ process.once("SIGINT", stop);
181
+ process.once("SIGTERM", stop);
182
+ }
183
+
184
+ async function runProbe(): Promise<void> {
185
+ installStopHooks();
186
+
187
+ const saved = readSnapshot();
188
+ const ilinkWire = await prepareProbeWire(saved);
189
+ const latest = readSnapshot();
190
+
191
+ console.log("Listening for WeChat messages. Send text to the small account.");
192
+ console.log(`State file: ${SNAPSHOT_PATH}`);
193
+ console.log(`Log dir: ${LOG_DIR}`);
194
+
195
+ await ilinkWire.monitor(
196
+ async (message) => {
197
+ try {
198
+ await mirrorIncomingLine(ilinkWire, message);
199
+ } catch (error) {
200
+ console.error(`Echo failed: ${(error as Error).stack ?? (error as Error).message}`);
201
+ }
202
+ },
203
+ {
204
+ initial_buf: latest.pollCursor ?? "",
205
+ on_buf_update: (pollCursor) => updateSnapshot({ pollCursor }),
206
+ on_response: rememberPollingCursor,
207
+ on_error: (error) => {
208
+ console.error(`iLink monitor error: ${error.stack ?? error.message}`);
209
+ },
210
+ on_session_expired: () => {
211
+ console.error("iLink session expired. Restart with --fresh to scan again.");
212
+ acceptingEvents = false;
213
+ },
214
+ should_continue: () => acceptingEvents,
215
+ },
216
+ );
217
+ }
218
+
219
+ runProbe().catch((error: Error) => {
220
+ console.error(`Fatal iLink echo probe error: ${error.stack ?? error.message}`);
221
+ process.exitCode = 1;
222
+ });
package/package.json CHANGED
@@ -1,54 +1,59 @@
1
- {
2
- "name": "chatccc",
3
- "version": "0.2.40",
4
- "description": "Feishu bot bridge for Claude Code",
5
- "license": "Apache-2.0",
6
- "type": "module",
7
- "main": "./src/index.ts",
8
- "bin": {
9
- "chatccc": "bin/chatccc.mjs"
10
- },
11
- "files": [
12
- "src/",
13
- "bin/",
14
- "im-skills/",
15
- "images/img_readme_*.jpg",
16
- "images/img_readme_*.png",
17
- "images/avatars/status_*.png",
18
- "images/avatars/badges/",
19
- "package.json",
20
- "README.md",
21
- "config.sample.json"
22
- ],
23
- "scripts": {
24
- "dev": "tsx src/index.ts",
25
- "chatccc": "tsx src/index.ts",
26
- "start": "tsx src/index.ts",
27
- "demo:bot-test": "tsx demo/bot_test.ts",
28
- "demo:bot-test:local": "tsx demo/bot_test.ts --local",
29
- "demo:create-group": "tsx src/index.ts",
30
- "demo:create-group:local": "tsx src/index.ts --local",
31
- "demo:permission-check": "tsx demo/permission_check.ts",
32
- "demo:claude-hi": "tsx demo/claude_say_hi.ts",
33
- "demo:codex-hi": "tsx demo/codex_say_hi.ts",
34
- "test": "vitest run",
35
- "test:watch": "vitest"
36
- },
37
- "dependencies": {
38
- "@anthropic-ai/claude-agent-sdk": "0.2.133",
39
- "@larksuiteoapi/node-sdk": "^1.59.0",
40
- "nodemailer": "^8.0.7",
41
- "sharp": "^0.34.5",
42
- "tsx": "^4.0.0",
43
- "ws": "^8.18.0"
44
- },
45
- "devDependencies": {
46
- "@types/node": "^20.0.0",
47
- "@types/ws": "^8.18.1",
48
- "typescript": "^5.0.0",
49
- "vitest": "^4.1.5"
50
- },
51
- "engines": {
52
- "node": ">=20"
53
- }
54
- }
1
+ {
2
+ "name": "chatccc",
3
+ "version": "0.2.42",
4
+ "description": "Feishu bot bridge for Claude Code",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./src/index.ts",
8
+ "bin": {
9
+ "chatccc": "bin/chatccc.mjs"
10
+ },
11
+ "files": [
12
+ "src/",
13
+ "bin/",
14
+ "demo/ilink_echo_probe.ts",
15
+ "im-skills/",
16
+ "images/img_readme_*.jpg",
17
+ "images/img_readme_*.png",
18
+ "images/avatars/status_*.png",
19
+ "images/avatars/badges/",
20
+ "package.json",
21
+ "README.md",
22
+ "config.sample.json"
23
+ ],
24
+ "scripts": {
25
+ "dev": "tsx src/index.ts",
26
+ "chatccc": "tsx src/index.ts",
27
+ "start": "tsx src/index.ts",
28
+ "demo:bot-test": "tsx demo/bot_test.ts",
29
+ "demo:bot-test:local": "tsx demo/bot_test.ts --local",
30
+ "demo:create-group": "tsx src/index.ts",
31
+ "demo:create-group:local": "tsx src/index.ts --local",
32
+ "demo:permission-check": "tsx demo/permission_check.ts",
33
+ "demo:claude-hi": "tsx demo/claude_say_hi.ts",
34
+ "demo:codex-hi": "tsx demo/codex_say_hi.ts",
35
+ "demo:ilink-echo": "tsx demo/ilink_echo_probe.ts",
36
+ "test": "vitest run",
37
+ "test:watch": "vitest"
38
+ },
39
+ "dependencies": {
40
+ "@anthropic-ai/claude-agent-sdk": "0.2.133",
41
+ "@larksuiteoapi/node-sdk": "^1.59.0",
42
+ "@openilink/openilink-sdk-node": "^0.6.0",
43
+ "nodemailer": "^8.0.7",
44
+ "qrcode-terminal": "^0.12.0",
45
+ "sharp": "^0.34.5",
46
+ "tsx": "^4.0.0",
47
+ "ws": "^8.18.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^20.0.0",
51
+ "@types/qrcode-terminal": "^0.12.2",
52
+ "@types/ws": "^8.18.1",
53
+ "typescript": "^5.0.0",
54
+ "vitest": "^4.1.5"
55
+ },
56
+ "engines": {
57
+ "node": ">=20"
58
+ }
59
+ }
@@ -311,7 +311,7 @@ describe("buildSessionsCard", () => {
311
311
 
312
312
  it("returns valid JSON with session listing", () => {
313
313
  const card = buildSessionsCard([
314
- { sessionId: "abc123", active: true, turnCount: 5, elapsedSeconds: 120, model: "Claude Opus 4.7", tool: "claude" },
314
+ { sessionId: "abc123", chatName: "test-group", active: true, turnCount: 5, elapsedSeconds: 120, model: "Claude Opus 4.7", tool: "claude" },
315
315
  ]);
316
316
  const parsed = JSON.parse(card);
317
317
  expect(parsed.elements[0].text.content).toContain("共 **1** 个会话");
@@ -322,7 +322,7 @@ describe("buildSessionsCard", () => {
322
322
 
323
323
  it("shows idle status for inactive sessions", () => {
324
324
  const card = buildSessionsCard([
325
- { sessionId: "xyz", active: false, turnCount: 0, elapsedSeconds: null, model: "Claude Sonnet 4.6", tool: "claude" },
325
+ { sessionId: "xyz", chatName: "", active: false, turnCount: 0, elapsedSeconds: null, model: "Claude Sonnet 4.6", tool: "claude" },
326
326
  ]);
327
327
  const parsed = JSON.parse(card);
328
328
  expect(parsed.elements[0].text.content).toContain("⚪ 空闲");
@@ -330,7 +330,7 @@ describe("buildSessionsCard", () => {
330
330
 
331
331
  it("shows elapsed time for active sessions", () => {
332
332
  const card = buildSessionsCard([
333
- { sessionId: "active123", active: true, turnCount: 3, elapsedSeconds: 95, model: "Claude Opus 4.7", tool: "claude" },
333
+ { sessionId: "active123", chatName: "", active: true, turnCount: 3, elapsedSeconds: 95, model: "Claude Opus 4.7", tool: "claude" },
334
334
  ]);
335
335
  const parsed = JSON.parse(card);
336
336
  expect(parsed.elements[0].text.content).toContain("1分35秒");
@@ -338,8 +338,8 @@ describe("buildSessionsCard", () => {
338
338
 
339
339
  it("separates Claude Code and Cursor sessions", () => {
340
340
  const card = buildSessionsCard([
341
- { sessionId: "c1", active: false, turnCount: 1, elapsedSeconds: null, model: "(留空)", tool: "claude" },
342
- { sessionId: "c2", active: false, turnCount: 2, elapsedSeconds: null, model: "claude-opus-4-7-max", tool: "cursor" },
341
+ { sessionId: "c1", chatName: "", active: false, turnCount: 1, elapsedSeconds: null, model: "(留空)", tool: "claude" },
342
+ { sessionId: "c2", chatName: "", active: false, turnCount: 2, elapsedSeconds: null, model: "claude-opus-4-7-max", tool: "cursor" },
343
343
  ]);
344
344
  const parsed = JSON.parse(card);
345
345
  const content: string = parsed.elements[0].text.content;
@@ -349,7 +349,7 @@ describe("buildSessionsCard", () => {
349
349
 
350
350
  it("omits Cursor section when no Cursor sessions", () => {
351
351
  const card = buildSessionsCard([
352
- { sessionId: "c1", active: false, turnCount: 1, elapsedSeconds: null, model: "(留空)", tool: "claude" },
352
+ { sessionId: "c1", chatName: "", active: false, turnCount: 1, elapsedSeconds: null, model: "(留空)", tool: "claude" },
353
353
  ]);
354
354
  const parsed = JSON.parse(card);
355
355
  const content: string = parsed.elements[0].text.content;
@@ -357,6 +357,22 @@ describe("buildSessionsCard", () => {
357
357
  expect(content).not.toContain("Cursor 会话");
358
358
  });
359
359
 
360
+ it("displays chatName when provided", () => {
361
+ const card = buildSessionsCard([
362
+ { sessionId: "abc123", chatName: "帮我写代码-src", active: false, turnCount: 2, elapsedSeconds: null, model: "Claude Opus 4.7", tool: "claude" },
363
+ ]);
364
+ const parsed = JSON.parse(card);
365
+ expect(parsed.elements[0].text.content).toContain("帮我写代码-src");
366
+ });
367
+
368
+ it("includes /session help text in non-empty card", () => {
369
+ const card = buildSessionsCard([
370
+ { sessionId: "abc123", chatName: "", active: false, turnCount: 2, elapsedSeconds: null, model: "Claude Opus 4.7", tool: "claude" },
371
+ ]);
372
+ const parsed = JSON.parse(card);
373
+ expect(parsed.elements[2].text.content).toContain("/session 数字");
374
+ });
375
+
360
376
  it("includes close button", () => {
361
377
  const card = buildSessionsCard([]);
362
378
  const parsed = JSON.parse(card);
@@ -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 */ }
package/src/session.ts CHANGED
@@ -168,6 +168,87 @@ export async function getSessionTool(sessionId: string): Promise<string | null>
168
168
  return record?.tool ?? null;
169
169
  }
170
170
 
171
+ // ---------------------------------------------------------------------------
172
+ // Conversation session registry for /sessions
173
+ // ---------------------------------------------------------------------------
174
+
175
+ export const SESSION_REGISTRY_FILE = join(USER_DATA_DIR, "state", "session-registry.json");
176
+ let sessionRegistryFile = SESSION_REGISTRY_FILE;
177
+
178
+ export interface SessionRegistryUpdate {
179
+ chatId: string;
180
+ sessionId: string;
181
+ tool: string;
182
+ chatName?: string;
183
+ turnCount?: number;
184
+ lastContextTokens?: number;
185
+ startTime?: number;
186
+ updatedAt?: number;
187
+ running?: boolean;
188
+ }
189
+
190
+ interface SessionRegistryRecord {
191
+ chatId: string;
192
+ sessionId: string;
193
+ tool: string;
194
+ chatName: string;
195
+ turnCount: number;
196
+ lastContextTokens: number;
197
+ startTime: number;
198
+ updatedAt: number;
199
+ running: boolean;
200
+ }
201
+
202
+ type SessionRegistryData = Record<string, SessionRegistryRecord>;
203
+
204
+ async function loadSessionRegistry(): Promise<SessionRegistryData> {
205
+ try {
206
+ const raw = await readFile(sessionRegistryFile, "utf-8");
207
+ const parsed = JSON.parse(raw) as SessionRegistryData;
208
+ return parsed && typeof parsed === "object" ? parsed : {};
209
+ } catch {
210
+ return {};
211
+ }
212
+ }
213
+
214
+ async function saveSessionRegistry(data: SessionRegistryData): Promise<void> {
215
+ try {
216
+ await mkdir(dirname(sessionRegistryFile), { recursive: true });
217
+ await writeFile(sessionRegistryFile, JSON.stringify(data, null, 2), "utf-8");
218
+ } catch (err) {
219
+ console.error(`[${ts()}] Failed to save session-registry.json: ${(err as Error).message}`);
220
+ fileLog.flush();
221
+ }
222
+ }
223
+
224
+ export async function recordSessionRegistry(update: SessionRegistryUpdate): Promise<void> {
225
+ const data = await loadSessionRegistry();
226
+ const existing = data[update.chatId];
227
+ const now = update.updatedAt ?? Date.now();
228
+
229
+ data[update.chatId] = {
230
+ chatId: update.chatId,
231
+ sessionId: update.sessionId,
232
+ tool: update.tool,
233
+ chatName: update.chatName ?? existing?.chatName ?? "",
234
+ turnCount: update.turnCount ?? existing?.turnCount ?? 0,
235
+ lastContextTokens: update.lastContextTokens ?? existing?.lastContextTokens ?? 0,
236
+ startTime: update.startTime ?? existing?.startTime ?? now,
237
+ updatedAt: now,
238
+ running: update.running ?? existing?.running ?? false,
239
+ };
240
+
241
+ await saveSessionRegistry(data);
242
+ }
243
+
244
+ export function _setSessionRegistryFileForTest(filePath: string): void {
245
+ sessionRegistryFile = filePath;
246
+ }
247
+
248
+ export function _resetSessionRegistryFileForTest(): void {
249
+ sessionRegistryFile = SESSION_REGISTRY_FILE;
250
+ }
251
+
171
252
  // ---------------------------------------------------------------------------
172
253
  // accumulateBlockContent — 将 UnifiedBlock 累积到渲染状态(纯函数,可测试)
173
254
  // ---------------------------------------------------------------------------
@@ -385,13 +466,24 @@ export async function resumeAndPrompt(
385
466
 
386
467
  const now = Date.now();
387
468
  const existingInfo = sessionInfoMap.get(chatId);
469
+ const nextTurnCount = (existingInfo?.turnCount ?? 0) + 1;
470
+ const nextContextTokens = existingInfo?.lastContextTokens ?? 0;
388
471
  sessionInfoMap.set(chatId, {
389
472
  sessionId,
390
- turnCount: (existingInfo?.turnCount ?? 0) + 1,
391
- lastContextTokens: existingInfo?.lastContextTokens ?? 0,
473
+ turnCount: nextTurnCount,
474
+ lastContextTokens: nextContextTokens,
392
475
  startTime: now,
393
476
  tool,
394
477
  });
478
+ await recordSessionRegistry({
479
+ chatId,
480
+ sessionId,
481
+ tool,
482
+ turnCount: nextTurnCount,
483
+ lastContextTokens: nextContextTokens,
484
+ startTime: now,
485
+ running: true,
486
+ });
395
487
 
396
488
  let cardId: string | null = null;
397
489
  cardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "生成中..." })).catch((err) => {
@@ -512,6 +604,13 @@ export async function resumeAndPrompt(
512
604
  if (block.type === "compact_boundary" && block.post_tokens) {
513
605
  const info = sessionInfoMap.get(chatId);
514
606
  if (info) { info.lastContextTokens = block.post_tokens; }
607
+ await recordSessionRegistry({
608
+ chatId,
609
+ sessionId,
610
+ tool,
611
+ lastContextTokens: block.post_tokens,
612
+ running: true,
613
+ });
515
614
  }
516
615
  }
517
616
  }
@@ -561,6 +660,16 @@ export async function resumeAndPrompt(
561
660
  const finalReply = pickFinalReply(state).trim();
562
661
 
563
662
  if (wasStopped) {
663
+ const finalInfo = sessionInfoMap.get(chatId);
664
+ await recordSessionRegistry({
665
+ chatId,
666
+ sessionId,
667
+ tool,
668
+ turnCount: finalInfo?.turnCount ?? nextTurnCount,
669
+ lastContextTokens: finalInfo?.lastContextTokens ?? nextContextTokens,
670
+ startTime: finalInfo?.startTime ?? now,
671
+ running: false,
672
+ });
564
673
  if (finalReply) {
565
674
  await sendTextReply(token, chatId, finalReply).catch((err) =>
566
675
  console.error(`[${ts()}] Failed to send partial text: ${(err as Error).message}`)
@@ -597,6 +706,16 @@ export async function resumeAndPrompt(
597
706
  }
598
707
 
599
708
  console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
709
+ const finalInfo = sessionInfoMap.get(chatId);
710
+ await recordSessionRegistry({
711
+ chatId,
712
+ sessionId,
713
+ tool,
714
+ turnCount: finalInfo?.turnCount ?? nextTurnCount,
715
+ lastContextTokens: finalInfo?.lastContextTokens ?? nextContextTokens,
716
+ startTime: finalInfo?.startTime ?? now,
717
+ running: false,
718
+ });
600
719
  if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
601
720
  }
602
721
 
@@ -681,6 +800,7 @@ export async function getSessionStatus(chatId: string): Promise<SessionStatus |
681
800
  export interface SessionsListEntry {
682
801
  chatId: string;
683
802
  sessionId: string;
803
+ chatName: string;
684
804
  active: boolean;
685
805
  turnCount: number;
686
806
  startTime: number;
@@ -691,16 +811,20 @@ export interface SessionsListEntry {
691
811
  }
692
812
 
693
813
  export async function getAllSessionsStatus(): Promise<SessionsListEntry[]> {
694
- const entries = Array.from(sessionInfoMap.entries());
814
+ const registry = await loadSessionRegistry();
815
+ const entries = Object.values(registry)
816
+ .filter((record) => record.chatId && record.sessionId && record.tool)
817
+ .sort((a, b) => b.updatedAt - a.updatedAt)
818
+ .slice(0, 20);
695
819
  // 并行解析每个 session 的 model/effort(cursor 涉及异步 store IO)
696
820
  return Promise.all(
697
- entries.map(async ([chatId, info]) => {
698
- const active = chatSessionMap.get(chatId);
821
+ entries.map(async (info) => {
699
822
  const { model, effort } = await resolveModelEffort(info.tool, info.sessionId);
700
823
  return {
701
- chatId,
824
+ chatId: info.chatId,
702
825
  sessionId: info.sessionId,
703
- active: active !== undefined && !active.stopped,
826
+ chatName: info.chatName || "",
827
+ active: info.running === true,
704
828
  turnCount: info.turnCount,
705
829
  startTime: info.startTime,
706
830
  model,
package/src/shared.ts CHANGED
@@ -1,17 +1,17 @@
1
1
  import { execSync } from "node:child_process";
2
- import {
3
- appendFileSync,
4
- existsSync,
5
- mkdirSync,
6
- readFileSync,
7
- unlinkSync,
8
- writeFileSync,
9
- } from "node:fs";
10
- import { createServer } from "node:http";
11
- import { homedir } from "node:os";
12
- import { join } from "node:path";
13
- import { inspect } from "node:util";
14
- import { WebSocketServer, WebSocket } from "ws";
2
+ import {
3
+ appendFileSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ unlinkSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { createServer } from "node:http";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { inspect } from "node:util";
14
+ import { WebSocketServer, WebSocket } from "ws";
15
15
 
16
16
  import { printServiceDidNotStart } from "./exit-banner.ts";
17
17
 
@@ -355,56 +355,56 @@ export function installCrashLogging(opts: CrashHandlersOptions = {}): InstallCra
355
355
  // 文件日志:同时输出到控制台和日志文件
356
356
  // ---------------------------------------------------------------------------
357
357
 
358
- export function setupFileLogging(logDir: string, prefix: string): { logPath: string; flush: () => void } {
359
- mkdirSync(logDir, { recursive: true });
360
- const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
361
- const logPath = join(logDir, `${prefix}-${ts}.log`);
362
- writeFileSync(logPath, "", { flag: "a", encoding: "utf8" });
363
- const origConsoleLog = console.log.bind(console);
364
- const origConsoleError = console.error.bind(console);
365
- const formatArg = (arg: unknown): string => {
366
- if (typeof arg === "string") return arg;
367
- if (arg instanceof Error) return arg.stack ?? arg.message;
368
- return inspect(arg, { depth: 6, breakLength: Infinity, maxArrayLength: 200 });
369
- };
370
- const writeLine = (level: string, args: unknown[]) => {
371
- try {
372
- const line = args.map(formatArg).join(" ");
373
- appendFileSync(logPath, `[${new Date().toISOString()}] [${level}] ${line}\n`, "utf8");
374
- } catch (err) {
375
- try {
376
- appendStartupTrace("fileLog write failed", { level, error: err instanceof Error ? err.message : String(err) });
377
- } catch {
378
- // 日志系统自身不能影响主流程
379
- }
380
- }
381
- };
382
- console.log = (...args: unknown[]) => {
383
- writeLine("LOG", args);
384
- try {
385
- origConsoleLog(...args);
386
- } catch {
387
- // 控制台输出失败也不能拖垮服务
388
- }
389
- };
390
- console.error = (...args: unknown[]) => {
391
- writeLine("ERR", args);
392
- try {
393
- origConsoleError(...args);
394
- } catch {
395
- // 控制台输出失败也不能拖垮服务
396
- }
397
- };
398
- const flush = () => {
399
- try {
400
- appendFileSync(logPath, "", "utf8");
401
- } catch (err) {
402
- appendStartupTrace("fileLog flush failed", { error: err instanceof Error ? err.message : String(err) });
403
- }
404
- };
405
- origConsoleLog(`Log file: ${logPath}`);
406
- return { logPath, flush };
407
- }
358
+ export function setupFileLogging(logDir: string, prefix: string): { logPath: string; flush: () => void } {
359
+ mkdirSync(logDir, { recursive: true });
360
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
361
+ const logPath = join(logDir, `${prefix}-${ts}.log`);
362
+ writeFileSync(logPath, "", { flag: "a", encoding: "utf8" });
363
+ const origConsoleLog = console.log.bind(console);
364
+ const origConsoleError = console.error.bind(console);
365
+ const formatArg = (arg: unknown): string => {
366
+ if (typeof arg === "string") return arg;
367
+ if (arg instanceof Error) return arg.stack ?? arg.message;
368
+ return inspect(arg, { depth: 6, breakLength: Infinity, maxArrayLength: 200 });
369
+ };
370
+ const writeLine = (level: string, args: unknown[]) => {
371
+ try {
372
+ const line = args.map(formatArg).join(" ");
373
+ appendFileSync(logPath, `[${new Date().toISOString()}] [${level}] ${line}\n`, "utf8");
374
+ } catch (err) {
375
+ try {
376
+ appendStartupTrace("fileLog write failed", { level, error: err instanceof Error ? err.message : String(err) });
377
+ } catch {
378
+ // 日志系统自身不能影响主流程
379
+ }
380
+ }
381
+ };
382
+ console.log = (...args: unknown[]) => {
383
+ writeLine("LOG", args);
384
+ try {
385
+ origConsoleLog(...args);
386
+ } catch {
387
+ // 控制台输出失败也不能拖垮服务
388
+ }
389
+ };
390
+ console.error = (...args: unknown[]) => {
391
+ writeLine("ERR", args);
392
+ try {
393
+ origConsoleError(...args);
394
+ } catch {
395
+ // 控制台输出失败也不能拖垮服务
396
+ }
397
+ };
398
+ const flush = () => {
399
+ try {
400
+ appendFileSync(logPath, "", "utf8");
401
+ } catch (err) {
402
+ appendStartupTrace("fileLog flush failed", { error: err instanceof Error ? err.message : String(err) });
403
+ }
404
+ };
405
+ origConsoleLog(`Log file: ${logPath}`);
406
+ return { logPath, flush };
407
+ }
408
408
 
409
409
  // ---------------------------------------------------------------------------
410
410
  // 本地 WebSocket 中继服务器(同一端口、多客户端广播)