chatccc 0.2.42 → 0.2.44

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.
@@ -5,6 +5,7 @@
5
5
  },
6
6
  "port": 18080,
7
7
  "gitTimeoutSeconds": 180,
8
+ "allowInterrupt": false,
8
9
  "claude": {
9
10
  "enabled": false,
10
11
  "defaultAgent": true,
@@ -1,222 +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
- });
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,59 +1,59 @@
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
- }
1
+ {
2
+ "name": "chatccc",
3
+ "version": "0.2.44",
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
+ }
@@ -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
+ });
package/src/config.ts CHANGED
@@ -98,6 +98,8 @@ export interface AppConfig {
98
98
  feishu: FeishuConfig;
99
99
  port: number;
100
100
  gitTimeoutSeconds: number;
101
+ /** 若为 false,AI 生成过程中用户发送消息不会打断,须先点「停止」再发送新消息 */
102
+ allowInterrupt: boolean;
101
103
  claude: ClaudeConfig;
102
104
  cursor: CursorConfig;
103
105
  codex: CodexConfig;
@@ -287,6 +289,7 @@ function loadConfig(): AppConfig {
287
289
  feishu: { appId: "", appSecret: "" },
288
290
  port: 18080,
289
291
  gitTimeoutSeconds: 180,
292
+ allowInterrupt: false,
290
293
  claude: { enabled: false, defaultAgent: true, model: "", effort: "", apiKey: "", baseUrl: "" },
291
294
  cursor: { enabled: false, defaultAgent: false, path: "", model: "claude-opus-4-7-max" },
292
295
  codex: { enabled: false, defaultAgent: false, path: "", model: "", effort: "" },
@@ -403,6 +406,7 @@ function loadConfig(): AppConfig {
403
406
  },
404
407
  port: typeof parsed.port === "number" ? parsed.port : 18080,
405
408
  gitTimeoutSeconds: typeof parsed.gitTimeoutSeconds === "number" ? parsed.gitTimeoutSeconds : 180,
409
+ allowInterrupt: typeof parsed.allowInterrupt === "boolean" ? parsed.allowInterrupt : false,
406
410
  claude: {
407
411
  enabled: claudeEnabled,
408
412
  defaultAgent: defaultTool === "claude",
@@ -476,6 +480,7 @@ export let CLAUDE_BASE_URL = config.claude.baseUrl;
476
480
 
477
481
  export let GIT_TIMEOUT_SECONDS = config.gitTimeoutSeconds;
478
482
  export let GIT_TIMEOUT_MS = GIT_TIMEOUT_SECONDS * 1000;
483
+ export let ALLOW_INTERRUPT = config.allowInterrupt;
479
484
 
480
485
  /** 探测 cursor-agent 安装路径(优先配置,其次 LocalAppData,最后默认 agent) */
481
486
  function detectCursorAgent(): string {
@@ -540,6 +545,7 @@ export function applyLoadedConfig(next: AppConfig): void {
540
545
  CLAUDE_BASE_URL = next.claude.baseUrl;
541
546
  GIT_TIMEOUT_SECONDS = next.gitTimeoutSeconds;
542
547
  GIT_TIMEOUT_MS = GIT_TIMEOUT_SECONDS * 1000;
548
+ ALLOW_INTERRUPT = next.allowInterrupt;
543
549
  CURSOR_AGENT_COMMAND = detectCursorAgent();
544
550
  CURSOR_AGENT_ARGS = resolveCursorAgentArgs();
545
551
  }
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  CLAUDE_EFFORT,
42
42
  CLAUDE_MODEL,
43
43
  GIT_TIMEOUT_MS,
44
+ ALLOW_INTERRUPT,
44
45
  reloadConfigFromDisk,
45
46
  anthropicConfigDisplay,
46
47
  LOCAL_RELAY_URL,
@@ -574,6 +575,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
574
575
  const running = chatSessionMap.get(chatId);
575
576
  const isActive = running && !running.stopped;
576
577
  const statusText = [
578
+ `**群名:** ${status?.chatName || "—"}`,
577
579
  `**Session ID:** \`${status?.sessionId ?? sessionId}\``,
578
580
  `**工具:** ${toolLabel}`,
579
581
  `**状态:** ${isActive ? "🟢 运行中" : "⚪ 空闲"}`,
@@ -606,7 +608,8 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
606
608
  logTrace(tid, "BRANCH", { cmd: "/sessions" });
607
609
  const allSessions = await getAllSessionsStatus();
608
610
  const now = Date.now();
609
- const cardData = allSessions.map(s => ({
611
+ const others = allSessions.filter(s => s.chatId !== chatId);
612
+ const cardData = others.map(s => ({
610
613
  sessionId: s.sessionId,
611
614
  chatName: s.chatName,
612
615
  active: s.active,
@@ -617,8 +620,8 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
617
620
  }));
618
621
  const card = buildSessionsCard(cardData);
619
622
  const ok = await sendRawCard(freshToken, chatId, card);
620
- console.log(`[${ts()}] [SESSIONS] card sent, ok=${ok}, count=${allSessions.length}`);
621
- logTrace(tid, "DONE", { outcome: "sessions", ok, count: allSessions.length });
623
+ console.log(`[${ts()}] [SESSIONS] card sent, ok=${ok}, count=${others.length}`);
624
+ logTrace(tid, "DONE", { outcome: "sessions", ok, count: others.length });
622
625
  return;
623
626
  }
624
627
 
@@ -656,9 +659,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
656
659
  }
657
660
 
658
661
  const descPrefix = sessionPrefixForTool(descriptionTool);
659
- const newName = isUntitledSessionChatName(chatInfo.name)
660
- ? sessionChatName("新会话", cwd)
661
- : chatInfo.name;
662
+ const newName = sessionChatName("新会话", cwd);
662
663
  await updateChatInfo(freshToken, chatId, newName, `${descPrefix} ${newSessionId}`);
663
664
  console.log(`[${ts()}] [FORGET] Group updated: name="${newName}" desc="${descPrefix} ${newSessionId}"`);
664
665
 
@@ -702,17 +703,22 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
702
703
  const index = parseInt(sessionMatch[1], 10) - 1;
703
704
  logTrace(tid, "BRANCH", { cmd: "/session", index: index + 1 });
704
705
  const allSessions = await getAllSessionsStatus();
705
- if (allSessions.length === 0) {
706
+ // buildSessionsCard 保持一致的排序:Claude Code → Cursor → Codex,组内保持 updatedAt 降序
707
+ const claudeOrdered = allSessions.filter(s => s.tool !== "cursor" && s.tool !== "codex");
708
+ const cursorOrdered = allSessions.filter(s => s.tool === "cursor");
709
+ const codexOrdered = allSessions.filter(s => s.tool === "codex");
710
+ const ordered = [...claudeOrdered, ...cursorOrdered, ...codexOrdered].filter(s => s.chatId !== chatId);
711
+ if (ordered.length === 0) {
706
712
  await sendCardReply(freshToken, chatId, "/session", "暂无历史会话。", "yellow");
707
713
  logTrace(tid, "DONE", { outcome: "session_no_sessions" });
708
714
  return;
709
715
  }
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 });
716
+ if (index < 0 || index >= ordered.length) {
717
+ await sendCardReply(freshToken, chatId, "/session", `序号超出范围,当前共 ${ordered.length} 个会话。`, "yellow");
718
+ logTrace(tid, "DONE", { outcome: "session_out_of_range", index: index + 1, total: ordered.length });
713
719
  return;
714
720
  }
715
- const target = allSessions[index];
721
+ const target = ordered[index];
716
722
 
717
723
  const existing2 = chatSessionMap.get(chatId);
718
724
  if (existing2) {
@@ -736,10 +742,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
736
742
  }
737
743
 
738
744
  const descPrefix2 = sessionPrefixForTool(target.tool);
739
- const newName2 = isUntitledSessionChatName(chatInfo.name)
740
- ? sessionChatName("新会话", cwd2)
741
- : chatInfo.name;
745
+ const newName2 = target.chatName || sessionChatName("新会话", cwd2);
742
746
  await updateChatInfo(freshToken, chatId, newName2, `${descPrefix2} ${target.sessionId}`);
747
+ console.log(`[${ts()}] [SESSION] Switched to session ${target.sessionId} (#${index + 1}), name="${newName2}"`);
743
748
 
744
749
  sessionInfoMap.set(chatId, {
745
750
  sessionId: target.sessionId,
@@ -769,7 +774,6 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
769
774
  "green"
770
775
  );
771
776
 
772
- console.log(`[${ts()}] [SESSION] Switched to session ${target.sessionId} (#${index + 1})`);
773
777
  logTrace(tid, "DONE", { outcome: "session_switch", sessionId: target.sessionId, index: index + 1, cwd: cwd2 });
774
778
  return;
775
779
  }
@@ -835,6 +839,18 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
835
839
  console.log(`[${ts()}] [SKIP] Older message (${msgTimestamp} <= ${existing.msgTimestamp}), ignoring`);
836
840
  return;
837
841
  }
842
+
843
+ if (!ALLOW_INTERRUPT) {
844
+ logTrace(tid, "BLOCKED", { outcome: "interrupt_disabled", sessionId });
845
+ console.log(`[${ts()}] [BLOCKED] allowInterrupt=false, ignoring message during generation. Hint sent to user.`);
846
+ await sendCardReply(
847
+ freshToken, chatId, "生成中",
848
+ "AI 正在生成回复中,Agent 不会遗忘当前进度,停止后仍可从断点继续。\n请先点击卡片上的「停止」按钮,再发送新消息。",
849
+ "yellow"
850
+ );
851
+ return;
852
+ }
853
+
838
854
  existing.stopped = true;
839
855
  if (existing.spinnerTimer) { clearInterval(existing.spinnerTimer); existing.spinnerTimer = null; }
840
856
  existing.close();
package/src/session.ts CHANGED
@@ -739,6 +739,7 @@ export const UNKNOWN_MODEL_PLACEHOLDER = "—";
739
739
 
740
740
  export interface SessionStatus {
741
741
  sessionId: string;
742
+ chatName: string;
742
743
  running: boolean;
743
744
  turnCount: number;
744
745
  lastContextTokens: number;
@@ -785,8 +786,12 @@ export async function getSessionStatus(chatId: string): Promise<SessionStatus |
785
786
  const active = chatSessionMap.get(chatId);
786
787
  const { model, effort } = await resolveModelEffort(info.tool, info.sessionId);
787
788
 
789
+ const registry = await loadSessionRegistry();
790
+ const chatName = registry[chatId]?.chatName ?? "";
791
+
788
792
  return {
789
793
  sessionId: info.sessionId,
794
+ chatName,
790
795
  running: active !== undefined && !active.stopped,
791
796
  turnCount: info.turnCount,
792
797
  lastContextTokens: info.lastContextTokens,
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 中继服务器(同一端口、多客户端广播)