chatccc 0.2.41 → 0.2.43

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.
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,
@@ -102,6 +103,7 @@ import {
102
103
  resetState,
103
104
  resumeAndPrompt,
104
105
  sessionInfoMap,
106
+ recordSessionRegistry,
105
107
  getAdapterForTool,
106
108
  } from "./session.ts";
107
109
 
@@ -309,7 +311,8 @@ async function handleSimInjectMessage(req: IncomingMessage, res: ServerResponse)
309
311
 
310
312
  async function handleCommand(text: string, chatId: string, openId: string, msgTimestamp: number, chatType = "group", traceId?: string): Promise<void> {
311
313
  const tid = traceId ?? makeTraceId();
312
- if (text === "/restart") {
314
+ const textLower = text.toLowerCase();
315
+ if (textLower === "/restart") {
313
316
  logTrace(tid, "BRANCH", { cmd: "/restart" });
314
317
  const restartToken = await getTenantAccessToken();
315
318
  await sendTextReply(restartToken, chatId, "正在重启...").catch(() => {});
@@ -326,7 +329,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
326
329
  return;
327
330
  }
328
331
 
329
- if (text === "/cd" || text.startsWith("/cd ")) {
332
+ if (textLower === "/cd" || textLower.startsWith("/cd ")) {
330
333
  logTrace(tid, "BRANCH", { cmd: "/cd", arg: text.slice(3).trim() || "(none)" });
331
334
  const cdToken = await getTenantAccessToken();
332
335
  const currentDir = await getDefaultCwd(chatId);
@@ -415,8 +418,8 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
415
418
  return;
416
419
  }
417
420
 
418
- if (text === "/new" || text.startsWith("/new ")) {
419
- const toolArg = text.slice(5).trim();
421
+ if (textLower === "/new" || textLower.startsWith("/new ")) {
422
+ const toolArg = text.slice(5).trim().toLowerCase();
420
423
  const tool = toolArg || "claude";
421
424
  logTrace(tid, "BRANCH", { cmd: "/new", tool });
422
425
  const validTools = ["claude", "cursor", "codex"];
@@ -483,6 +486,15 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
483
486
 
484
487
  // 让新群的默认工作目录继承当前会话的 cwd
485
488
  await setDefaultCwd(cwd, newChatId);
489
+ await recordSessionRegistry({
490
+ chatId: newChatId,
491
+ sessionId,
492
+ tool,
493
+ chatName: initialName,
494
+ turnCount: 0,
495
+ startTime: Date.now(),
496
+ running: false,
497
+ });
486
498
 
487
499
  const adapter = getAdapterForTool(tool);
488
500
  await sendCardReply(
@@ -530,12 +542,13 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
530
542
  try {
531
543
  await updateChatInfo(freshToken, chatId, newName, description);
532
544
  console.log(`[${ts()}] [RENAME] First message → group renamed to "${newName}"`);
545
+ await recordSessionRegistry({ chatId, chatName: newName }).catch(() => {});
533
546
  } catch (err) {
534
547
  console.error(`[${ts()}] [RENAME] Failed: ${(err as Error).message}`);
535
548
  }
536
549
  }
537
550
 
538
- if (text === "/stop") {
551
+ if (textLower === "/stop") {
539
552
  logTrace(tid, "BRANCH", { cmd: "/stop" });
540
553
  const cEntry = chatSessionMap.get(chatId);
541
554
  if (cEntry) {
@@ -556,12 +569,13 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
556
569
  return;
557
570
  }
558
571
 
559
- if (text === "/status") {
572
+ if (textLower === "/status") {
560
573
  logTrace(tid, "BRANCH", { cmd: "/status" });
561
574
  const status = await getSessionStatus(chatId);
562
575
  const running = chatSessionMap.get(chatId);
563
576
  const isActive = running && !running.stopped;
564
577
  const statusText = [
578
+ `**群名:** ${status?.chatName || "—"}`,
565
579
  `**Session ID:** \`${status?.sessionId ?? sessionId}\``,
566
580
  `**工具:** ${toolLabel}`,
567
581
  `**状态:** ${isActive ? "🟢 运行中" : "⚪ 空闲"}`,
@@ -590,12 +604,13 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
590
604
  return;
591
605
  }
592
606
 
593
- if (text === "/sessions") {
607
+ if (textLower === "/sessions") {
594
608
  logTrace(tid, "BRANCH", { cmd: "/sessions" });
595
609
  const allSessions = await getAllSessionsStatus();
596
610
  const now = Date.now();
597
611
  const cardData = allSessions.map(s => ({
598
612
  sessionId: s.sessionId,
613
+ chatName: s.chatName,
599
614
  active: s.active,
600
615
  turnCount: s.turnCount,
601
616
  elapsedSeconds: s.active ? Math.floor((now - s.startTime) / 1000) : null,
@@ -609,7 +624,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
609
624
  return;
610
625
  }
611
626
 
612
- if (text === "/forget") {
627
+ if (textLower === "/forget") {
613
628
  logTrace(tid, "BRANCH", { cmd: "/forget" });
614
629
  const adapter = getAdapterForTool(descriptionTool);
615
630
  let cwd: string;
@@ -643,9 +658,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
643
658
  }
644
659
 
645
660
  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}"`);
661
+ const newName = sessionChatName("新会话", cwd);
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,86 @@ 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 = sessionChatName("新会话", cwd2);
740
+ await updateChatInfo(freshToken, chatId, newName2, `${descPrefix2} ${target.sessionId}`);
741
+
742
+ sessionInfoMap.set(chatId, {
743
+ sessionId: target.sessionId,
744
+ turnCount: target.turnCount,
745
+ lastContextTokens: 0,
746
+ startTime: Date.now(),
747
+ tool: target.tool,
748
+ });
749
+ await recordSessionRegistry({
750
+ chatId,
751
+ sessionId: target.sessionId,
752
+ tool: target.tool,
753
+ chatName: newName2,
754
+ running: false,
755
+ });
756
+
757
+ setChatAvatar(freshToken, chatId, target.tool, "new").catch(() => {});
758
+
759
+ const targetToolLabel = toolDisplayName(target.tool);
760
+ await sendCardReply(
761
+ freshToken, chatId, `${targetToolLabel} Session Switched`,
762
+ `已切换到 **${targetToolLabel}** 会话。\n\n` +
763
+ `**序号:** ${index + 1}\n` +
764
+ `**Session ID:** ${target.sessionId}\n` +
765
+ `**工作目录:** \`${cwd2}\`\n\n` +
766
+ `直接在这里发消息即可继续对话。`,
767
+ "green"
768
+ );
769
+
770
+ console.log(`[${ts()}] [SESSION] Switched to session ${target.sessionId} (#${index + 1})`);
771
+ logTrace(tid, "DONE", { outcome: "session_switch", sessionId: target.sessionId, index: index + 1, cwd: cwd2 });
772
+ return;
773
+ }
774
+
674
775
  // /git <args>:在「当前会话工作目录」执行 git 命令,把输出回发到群里。
675
776
  // 注意 cwd 必须取自 adapter.getSessionInfo(会话真实 cwd),而非
676
777
  // getDefaultCwd(下一次 /new 才会使用的默认路径)。
677
- if (text.startsWith("/git ") || text === "/git") {
778
+ if (textLower.startsWith("/git ") || textLower === "/git") {
678
779
  const args = text === "/git" ? "" : text.slice(5).trim();
679
780
  logTrace(tid, "BRANCH", { cmd: "/git", args: args || "(none)" });
680
781
  if (!args) {
@@ -732,6 +833,18 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
732
833
  console.log(`[${ts()}] [SKIP] Older message (${msgTimestamp} <= ${existing.msgTimestamp}), ignoring`);
733
834
  return;
734
835
  }
836
+
837
+ if (!ALLOW_INTERRUPT) {
838
+ logTrace(tid, "BLOCKED", { outcome: "interrupt_disabled", sessionId });
839
+ console.log(`[${ts()}] [BLOCKED] allowInterrupt=false, ignoring message during generation. Hint sent to user.`);
840
+ await sendCardReply(
841
+ freshToken, chatId, "生成中",
842
+ "AI 正在生成回复中,请先点击卡片上的「停止」按钮停止当前生成后,再发送新消息。",
843
+ "yellow"
844
+ );
845
+ return;
846
+ }
847
+
735
848
  existing.stopped = true;
736
849
  if (existing.spinnerTimer) { clearInterval(existing.spinnerTimer); existing.spinnerTimer = null; }
737
850
  existing.close();
@@ -1011,7 +1124,7 @@ async function startBotServiceCore(): Promise<void> {
1011
1124
  const openId = event.sender?.sender_id?.open_id ?? "";
1012
1125
  const chatId = message.chat_id ?? "";
1013
1126
  appendChatLog(chatId, openId, text);
1014
- if (text === "/new" && openId) {
1127
+ if (text.toLowerCase() === "/new" && openId) {
1015
1128
  console.log(`[MSG] /new from ${openId}, but local relay does not handle /new yet. Use SDK mode.`);
1016
1129
  }
1017
1130
  } 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
 
@@ -620,6 +739,7 @@ export const UNKNOWN_MODEL_PLACEHOLDER = "—";
620
739
 
621
740
  export interface SessionStatus {
622
741
  sessionId: string;
742
+ chatName: string;
623
743
  running: boolean;
624
744
  turnCount: number;
625
745
  lastContextTokens: number;
@@ -666,8 +786,12 @@ export async function getSessionStatus(chatId: string): Promise<SessionStatus |
666
786
  const active = chatSessionMap.get(chatId);
667
787
  const { model, effort } = await resolveModelEffort(info.tool, info.sessionId);
668
788
 
789
+ const registry = await loadSessionRegistry();
790
+ const chatName = registry[chatId]?.chatName ?? "";
791
+
669
792
  return {
670
793
  sessionId: info.sessionId,
794
+ chatName,
671
795
  running: active !== undefined && !active.stopped,
672
796
  turnCount: info.turnCount,
673
797
  lastContextTokens: info.lastContextTokens,
@@ -681,6 +805,7 @@ export async function getSessionStatus(chatId: string): Promise<SessionStatus |
681
805
  export interface SessionsListEntry {
682
806
  chatId: string;
683
807
  sessionId: string;
808
+ chatName: string;
684
809
  active: boolean;
685
810
  turnCount: number;
686
811
  startTime: number;
@@ -691,16 +816,20 @@ export interface SessionsListEntry {
691
816
  }
692
817
 
693
818
  export async function getAllSessionsStatus(): Promise<SessionsListEntry[]> {
694
- const entries = Array.from(sessionInfoMap.entries());
819
+ const registry = await loadSessionRegistry();
820
+ const entries = Object.values(registry)
821
+ .filter((record) => record.chatId && record.sessionId && record.tool)
822
+ .sort((a, b) => b.updatedAt - a.updatedAt)
823
+ .slice(0, 20);
695
824
  // 并行解析每个 session 的 model/effort(cursor 涉及异步 store IO)
696
825
  return Promise.all(
697
- entries.map(async ([chatId, info]) => {
698
- const active = chatSessionMap.get(chatId);
826
+ entries.map(async (info) => {
699
827
  const { model, effort } = await resolveModelEffort(info.tool, info.sessionId);
700
828
  return {
701
- chatId,
829
+ chatId: info.chatId,
702
830
  sessionId: info.sessionId,
703
- active: active !== undefined && !active.stopped,
831
+ chatName: info.chatName || "",
832
+ active: info.running === true,
704
833
  turnCount: info.turnCount,
705
834
  startTime: info.startTime,
706
835
  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 中继服务器(同一端口、多客户端广播)