chatccc 0.2.16 → 0.2.18

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.
@@ -12,12 +12,22 @@ export function printServiceDidNotStart(summary: string): void {
12
12
  console.error(bar + "\n\n");
13
13
  }
14
14
 
15
- /** 启动成功、进程将常驻时提示用户不要关窗口 */
16
- export function printServiceRunningHint(mode: "sdk" | "local"): void {
15
+ /**
16
+ * 启动成功、进程将常驻时提示用户不要关窗口。
17
+ * 同时打印前端管理面板 URL,方便用户随时打开浏览器查看状态、修改配置(reload 路径
18
+ * 无需重启即可生效,详见 web-ui.ts 的 chooseStartPath 注释)。
19
+ */
20
+ export function printServiceRunningHint(
21
+ mode: "sdk" | "local",
22
+ configUrl?: string,
23
+ ): void {
17
24
  const bar = "-".repeat(68);
18
25
  const tail = mode === "sdk" ? "飞书长连接与本地中继已就绪。" : "本地中继客户端已就绪。";
19
26
  console.log("\n" + bar);
20
27
  console.log(` [ 运行中 ] ${tail}`);
21
28
  console.log(" [ 提示 ] 请保持本窗口打开;要停止请按 Ctrl+C。");
29
+ if (configUrl) {
30
+ console.log(` [ 配置面板 ] ${configUrl} (查看状态 / 修改配置,多数改动无需重启)`);
31
+ }
22
32
  console.log(bar + "\n");
23
33
  }
package/src/feishu-api.ts CHANGED
@@ -262,26 +262,15 @@ export function extractSessionId(description: string): string | null {
262
262
 
263
263
  const AVATAR_DIR = resolvePath(PROJECT_ROOT, "images", "avatars");
264
264
  const AVATAR_SOURCES: Record<string, string> = {
265
- new: process.env.CHATCCC_AVATAR_NEW_URL?.trim() || resolvePath(AVATAR_DIR, "status_new.png"),
266
- busy: process.env.CHATCCC_AVATAR_BUSY_URL?.trim() || resolvePath(AVATAR_DIR, "status_busy.png"),
267
- idle: process.env.CHATCCC_AVATAR_IDLE_URL?.trim() || resolvePath(AVATAR_DIR, "status_idle.png"),
265
+ new: resolvePath(AVATAR_DIR, "status_new.png"),
266
+ busy: resolvePath(AVATAR_DIR, "status_busy.png"),
267
+ idle: resolvePath(AVATAR_DIR, "status_idle.png"),
268
268
  };
269
269
 
270
270
  const avatarKeyCache = new Map<string, string>();
271
271
 
272
- function isHttpUrl(source: string): boolean {
273
- return /^https?:\/\//i.test(source);
274
- }
275
-
276
272
  async function loadAvatarSource(source: string): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
277
- let input: Buffer;
278
- if (isHttpUrl(source)) {
279
- const resp = await fetch(source);
280
- if (!resp.ok) throw new Error(`download avatar url failed: status=${resp.status}`);
281
- input = Buffer.from(await resp.arrayBuffer());
282
- } else {
283
- input = await readFile(source);
284
- }
273
+ const input = await readFile(source);
285
274
 
286
275
  const jpeg = await sharp(input)
287
276
  .resize(256, 256, { fit: "cover", position: "center" })
package/src/index.ts CHANGED
@@ -24,12 +24,14 @@
24
24
 
25
25
  import { spawn } from "node:child_process";
26
26
  import { readdir, stat } from "node:fs/promises";
27
+ import { createServer, type Server } from "node:http";
27
28
  import { resolve, dirname } from "node:path";
28
29
 
29
30
  import { WSClient, EventDispatcher } from "@larksuiteoapi/node-sdk";
30
31
  import WebSocket from "ws";
31
32
 
32
- import { appendStartupTrace, ensureSingleInstance, createRelayServer, freeRelayListenPort } from "./shared.ts";
33
+ import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRelayListenPort, installCrashLogging } from "./shared.ts";
34
+ import { createUiRouter, setReloadConfigHook, startSetupMode } from "./web-ui.ts";
33
35
  import {
34
36
  CHATCCC_PORT,
35
37
  APP_ID,
@@ -38,6 +40,7 @@ import {
38
40
  CLAUDE_EFFORT,
39
41
  CLAUDE_MODEL,
40
42
  GIT_TIMEOUT_MS,
43
+ reloadConfigFromDisk,
41
44
  anthropicConfigDisplay,
42
45
  LOCAL_RELAY_URL,
43
46
  PID_FILE,
@@ -227,7 +230,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
227
230
  const restartToken = await getTenantAccessToken();
228
231
  await sendTextReply(restartToken, chatId, "正在重启...").catch(() => {});
229
232
  console.log(`[${ts()}] [RESTART] Spawning new process...`);
230
- const child = spawn("npx", ["tsx", "--env-file=.env", "src/index.ts"], {
233
+ const child = spawn("npx", ["tsx", "src/index.ts"], {
231
234
  cwd: PROJECT_ROOT,
232
235
  detached: true,
233
236
  stdio: "ignore",
@@ -672,53 +675,48 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
672
675
  }
673
676
 
674
677
  // ---------------------------------------------------------------------------
675
- // Main
678
+ // startBotService — 飞书 service 的启动逻辑(独立于 main())
676
679
  // ---------------------------------------------------------------------------
680
+ //
681
+ // 抽出原因:setup → service「在线切换」需要原地(同进程)启动飞书 service,
682
+ // 不希望走 spawn 子进程那条 path(chatccc 主进程已经占着 PID 文件)。
683
+ //
684
+ // 设计契约:
685
+ // - 入参 httpServer 必须**已经在监听 port**;本函数只负责把 WS 中继和
686
+ // UI router 挂上去,再起 EventDispatcher / WSClient。
687
+ // - 内部任何失败一律 throw,调用方决定是 process.exit(1)(main 模式)
688
+ // 还是把错误回报前端 toast(setup-activate 模式,不退出 chatccc 进程)。
689
+ // - 成功返回后 service 就绪;调用方负责注册 SIGINT/SIGTERM 清理。
690
+
691
+ interface StartBotServiceOptions {
692
+ httpServer: Server;
693
+ port: number;
694
+ }
677
695
 
678
- async function main(): Promise<void> {
679
- appendStartupTrace("main: entered", {
680
- argv: process.argv.join(" ").slice(0, 400),
681
- CHATCCC_PORT,
682
- PROJECT_ROOT,
683
- });
684
-
685
- if (Number.isNaN(CHATCCC_PORT) || CHATCCC_PORT < 1 || CHATCCC_PORT > 65535) {
686
- console.error("\n[启动] 预检失败: CHATCCC_PORT 不是有效端口号(1–65535)。");
687
- console.error(` 当前解析结果: ${String(process.env.CHATCCC_PORT)} → ${CHATCCC_PORT}`);
688
- reportEnvironmentVariableReadout();
689
- printServiceDidNotStart("CHATCCC_PORT 配置无效(须为 1–65535 的整数)");
690
- process.exit(1);
691
- }
696
+ async function startBotService(opts: StartBotServiceOptions): Promise<void> {
697
+ const { httpServer, port } = opts;
692
698
 
693
- console.log(`\n[启动 1/7] 单实例:按 PID 文件清理旧 ChatCCC 进程`);
694
- console.log(` PID 文件: ${PID_FILE}`);
695
- appendStartupTrace("main: before ensureSingleInstance", { PID_FILE, CHATCCC_PORT });
696
- ensureSingleInstance(PID_FILE);
697
- appendStartupTrace("main: after ensureSingleInstance");
699
+ console.log(`\n[启动 3/7] http://127.0.0.1:${port} 上挂载本地 WebSocket 中继 …`);
700
+ appendStartupTrace("startBotService: before attachRelayWebSocket", { port });
701
+ const wsAttachment = attachRelayWebSocket(httpServer);
702
+ broadcastToRelay = wsAttachment.broadcast;
703
+ // UI router 在 setup 模式下已经挂在 httpServer 上;main 直入模式由 main() 负责挂
704
+ // —— 这里不再额外 attach,避免重复触发 createUiRouter。
698
705
  console.log(" 完成。\n");
699
706
 
700
- console.log(`[启动 2/7] 环境与凭证检查`);
701
- reportEnvironmentVariableReadout();
702
- console.log(` 工作目录: ${process.cwd()}`);
703
- console.log(` 包根目录: ${PROJECT_ROOT}`);
704
- appendStartupTrace("main: before feishu credential check", {
705
- hasAppId: Boolean(APP_ID.trim()),
706
- hasAppSecret: Boolean(APP_SECRET.trim()),
707
- });
708
- if (!APP_ID.trim() || !APP_SECRET.trim()) {
709
- explainMissingFeishuCredentialsAndExit();
707
+ // setup-activate 模式下,token / permissions / wsClient 任一阶段失败都需要
708
+ // 回滚已挂的 WSServer,否则用户重试 onActivate 时会重复挂载导致句柄泄漏。
709
+ // 用 try / catch 统一 wrap 后续启动逻辑。
710
+ try {
711
+ await startBotServiceCore();
712
+ } catch (err) {
713
+ wsAttachment.close();
714
+ broadcastToRelay = () => { /* noop after rollback */ };
715
+ throw err;
710
716
  }
711
- console.log(` 必填项校验通过(App ID 摘要: ${maskAppId(APP_ID)})。\n`);
712
- appendStartupTrace("main: feishu credentials ok", { appIdMask: maskAppId(APP_ID) });
713
-
714
- console.log(`[启动 3/7] 启动本地 WebSocket 中继(ws://127.0.0.1:${CHATCCC_PORT})…`);
715
- appendStartupTrace("main: before freeRelayListenPort", { CHATCCC_PORT });
716
- freeRelayListenPort(CHATCCC_PORT);
717
- appendStartupTrace("main: after freeRelayListenPort", { CHATCCC_PORT });
718
- const { server: relayServer, broadcast } = createRelayServer(CHATCCC_PORT);
719
- broadcastToRelay = broadcast;
720
- console.log(" 完成。\n");
717
+ }
721
718
 
719
+ async function startBotServiceCore(): Promise<void> {
722
720
  const modeTag = USE_LOCAL ? " (local relay mode)" : "";
723
721
  console.log(`${"=".repeat(60)}`);
724
722
  console.log(` ChatCCC — Feishu Bot Bridge for Claude Code${modeTag}`);
@@ -742,8 +740,7 @@ async function main(): Promise<void> {
742
740
  console.error(" - App ID / App Secret 与开放平台「凭证与基础信息」不一致");
743
741
  console.error(" - 自建应用尚未创建/发布可用版本");
744
742
  console.error(` 详情: ${msg}`);
745
- printServiceDidNotStart("无法从飞书开放平台获取 tenant_access_token(凭证或网络问题)");
746
- process.exit(1);
743
+ throw new Error(`无法从飞书开放平台获取 tenant_access_token: ${msg}`);
747
744
  }
748
745
  console.log(` 完成。当前 App ID 摘要: ${maskAppId(APP_ID)}\n`);
749
746
 
@@ -753,15 +750,11 @@ async function main(): Promise<void> {
753
750
  console.log(msg);
754
751
  });
755
752
  if (hasFailed) {
756
- appendStartupTrace("main: permissions check failed", {
757
- failed: permResults.filter((r) => !r.ok).map((r) => r.scope).join(", "),
758
- });
759
- printServiceDidNotStart(
760
- `飞书权限不足: ${permResults.filter((r) => !r.ok).map((r) => r.scope).join(", ")}`
761
- );
762
- process.exit(1);
753
+ const failedScopes = permResults.filter((r) => !r.ok).map((r) => r.scope).join(", ");
754
+ appendStartupTrace("startBotService: permissions check failed", { failed: failedScopes });
755
+ throw new Error(`飞书权限不足: ${failedScopes}`);
763
756
  }
764
- appendStartupTrace("main: permissions check ok");
757
+ appendStartupTrace("startBotService: permissions check ok");
765
758
  console.log(` 完成。所有必需权限已验证通过。\n`);
766
759
 
767
760
  console.log(`[${ts()}] [AUTH] Token obtained`);
@@ -866,7 +859,7 @@ async function main(): Promise<void> {
866
859
  localRelayOpened = true;
867
860
  console.log("[WS] Connected to local relay");
868
861
  console.log("[启动 7/7] 已连接本地中继,可接收转发事件。\n");
869
- printServiceRunningHint("local");
862
+ printServiceRunningHint("local", `http://127.0.0.1:${CHATCCC_PORT}`);
870
863
  });
871
864
  ws.on("message", (raw: Buffer) => {
872
865
  try {
@@ -896,6 +889,9 @@ async function main(): Promise<void> {
896
889
  console.error(`[启动 6/7] 失败:无法连接本地中继。`);
897
890
  console.error(` ${err.message}`);
898
891
  console.error(` 目标: ${LOCAL_RELAY_URL}`);
892
+ // 注意:local relay 模式下连接是异步的;此处 throw 出 startBotService
893
+ // 已经返回的事件循环外,调用方拿不到。约定:local 模式连接失败一律
894
+ // 直接退出进程(与 setup-activate 模式无关 —— setup 模式不会用 local relay)。
899
895
  printServiceDidNotStart(`无法连接本地中继 ${LOCAL_RELAY_URL}`);
900
896
  process.exit(1);
901
897
  }
@@ -919,29 +915,147 @@ async function main(): Promise<void> {
919
915
  console.error(" 失败:飞书 WebSocket 未能启动。");
920
916
  console.error(" 常见原因: 应用权限未开通、事件订阅未配置、网络问题、或 SDK 内部错误。");
921
917
  console.error(` 详情: ${msg}`);
922
- printServiceDidNotStart("飞书 SDK WebSocket 未能建立(权限、事件订阅或网络)");
923
- process.exit(1);
918
+ throw new Error(`飞书 SDK WebSocket 未能建立: ${msg}`);
924
919
  }
925
920
  console.log("[WS] Feishu WebSocket connected (SDK)");
926
921
  console.log("[启动 7/7] 服务已就绪,等待飞书消息(群聊 / 卡片回调)。\n");
927
- printServiceRunningHint("sdk");
922
+ printServiceRunningHint("sdk", `http://127.0.0.1:${CHATCCC_PORT}`);
928
923
 
929
924
  sendRestartCard(token).catch((err) =>
930
925
  console.error(`[${ts()}] [RESTART] sendRestartCard failed: ${(err as Error).message}`)
931
926
  );
932
927
  }
928
+ }
929
+
930
+ // ---------------------------------------------------------------------------
931
+ // Main
932
+ // ---------------------------------------------------------------------------
933
+
934
+ async function main(): Promise<void> {
935
+ appendStartupTrace("main: entered", {
936
+ argv: process.argv.join(" ").slice(0, 400),
937
+ CHATCCC_PORT,
938
+ PROJECT_ROOT,
939
+ });
940
+
941
+ // 黑匣子:所有未捕获异常 / 信号 / beforeExit 都同步写入 startup-trace.log(appendFileSync)。
942
+ // 越早装越好——后续任何一行抛错都有兜底;它独立于 SIGINT 清理(见末尾的
943
+ // server.close)——只负责诊断与默认致命退出,不替代清理逻辑。
944
+ installCrashLogging({ flush: () => fileLog.flush() });
945
+
946
+ if (Number.isNaN(CHATCCC_PORT) || CHATCCC_PORT < 1 || CHATCCC_PORT > 65535) {
947
+ console.error("\n[启动] 预检失败: config.json 的 port 字段不是有效端口号(1–65535)。");
948
+ console.error(` 当前配置: ${CHATCCC_PORT}`);
949
+ reportEnvironmentVariableReadout();
950
+ printServiceDidNotStart("config.json 的 port 字段配置无效(须为 1–65535 的整数)");
951
+ process.exit(1);
952
+ }
953
+
954
+ console.log(`\n[启动 1/7] 单实例:按 PID 文件清理旧 ChatCCC 进程`);
955
+ console.log(` PID 文件: ${PID_FILE}`);
956
+ appendStartupTrace("main: before ensureSingleInstance", { PID_FILE, CHATCCC_PORT });
957
+ ensureSingleInstance(PID_FILE);
958
+ appendStartupTrace("main: after ensureSingleInstance");
959
+ console.log(" 完成。\n");
933
960
 
934
- process.on("SIGINT", () => { console.log("\nShutting down..."); relayServer.close(); process.exit(0); });
935
- process.on("SIGTERM", () => { relayServer.close(); process.exit(0); });
961
+ // 注册 reload hook:dashboard 模式(或 setup 激活后再点向导)下用户点
962
+ // "保存并启动" 时,web-ui 会调用本回调,把磁盘上刚保存的 config.json
963
+ // 刷进进程内的 export let 常量(live binding 让 CLAUDE_MODEL 等下次创建
964
+ // 会话时自动看到新值)。setup 首次激活走 onActivate 路径,不依赖此 hook。
965
+ setReloadConfigHook(() => {
966
+ reloadConfigFromDisk();
967
+ appendStartupTrace("reload-from-ui: config reloaded", {
968
+ appIdMask: maskAppId(APP_ID),
969
+ });
970
+ });
936
971
 
937
- process.on("uncaughtException", (err) => {
938
- console.error(`[FATAL] uncaughtException: ${err.message}\n${err.stack}`);
939
- fileLog.flush();
972
+ console.log(`[启动 2/7] 环境与凭证检查`);
973
+ reportEnvironmentVariableReadout();
974
+ console.log(` 工作目录: ${process.cwd()}`);
975
+ console.log(` 包根目录: ${PROJECT_ROOT}`);
976
+ appendStartupTrace("main: before feishu credential check", {
977
+ hasAppId: Boolean(APP_ID.trim()),
978
+ hasAppSecret: Boolean(APP_SECRET.trim()),
940
979
  });
941
- process.on("unhandledRejection", (reason) => {
942
- console.error(`[FATAL] unhandledRejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}`);
943
- fileLog.flush();
980
+ if (!APP_ID.trim() || !APP_SECRET.trim()) {
981
+ // 凭证不全:进 setup 向导。注入 onActivate 回调让用户点"保存并启动"
982
+ // 时,原地(同进程)调用 startBotService,复用 setup HTTP server。
983
+ startSetupMode(CHATCCC_PORT, {
984
+ onActivate: async (httpServer: Server) => {
985
+ // 关键:用户刚把新凭证写入 config.json,需要先把进程内的 APP_ID 等
986
+ // 常量同步到磁盘最新值,否则 startBotService 拿到的是 chatccc 启动时
987
+ // 加载的(空)凭证。reloadConfigFromDisk 利用 ES module live binding
988
+ // 让所有"export let"消费方自动看到新值。
989
+ reloadConfigFromDisk();
990
+ appendStartupTrace("setup-activate: reloaded config from disk", {
991
+ appIdMaskAfterReload: maskAppId(APP_ID),
992
+ });
993
+ try {
994
+ await startBotService({ httpServer, port: CHATCCC_PORT });
995
+ // 切换成功:注册 SIGINT/SIGTERM 让 Ctrl-C 也能优雅退出
996
+ installShutdownHandlers(httpServer);
997
+ return { ok: true };
998
+ } catch (err) {
999
+ appendStartupTrace("setup-activate: startBotService failed", {
1000
+ message: (err as Error).message,
1001
+ });
1002
+ // 不退出 chatccc 进程——setup HTTP server 还在监听,让用户改完
1003
+ // config 再点一次。
1004
+ return { ok: false, error: (err as Error).message };
1005
+ }
1006
+ },
1007
+ });
1008
+ return;
1009
+ }
1010
+ console.log(` 必填项校验通过(App ID 摘要: ${maskAppId(APP_ID)})。\n`);
1011
+ appendStartupTrace("main: feishu credentials ok", { appIdMask: maskAppId(APP_ID) });
1012
+
1013
+ // 凭证齐全:自己起 HTTP server(同时挂 UI router),再调 startBotService
1014
+ // 把 WS 中继和飞书 SDK 挂上去。
1015
+ appendStartupTrace("main: before freeRelayListenPort", { CHATCCC_PORT });
1016
+ freeRelayListenPort(CHATCCC_PORT);
1017
+ appendStartupTrace("main: after freeRelayListenPort", { CHATCCC_PORT });
1018
+ const httpServer = createServer(createUiRouter());
1019
+ await new Promise<void>((resolveListen, rejectListen) => {
1020
+ const onError = (err: NodeJS.ErrnoException): void => {
1021
+ httpServer.removeListener("listening", onListening);
1022
+ rejectListen(err);
1023
+ };
1024
+ const onListening = (): void => {
1025
+ httpServer.removeListener("error", onError);
1026
+ resolveListen();
1027
+ };
1028
+ httpServer.once("error", onError);
1029
+ httpServer.once("listening", onListening);
1030
+ httpServer.listen(CHATCCC_PORT, "127.0.0.1");
1031
+ }).catch((err: NodeJS.ErrnoException) => {
1032
+ console.error(`\n[启动] 本地中继 WebSocket 监听失败:端口 ${CHATCCC_PORT}(${err.code ?? "?"} — ${err.message})`);
1033
+ console.error(
1034
+ " 处理建议: 关闭占用该端口的其它程序,或在 config.json 的 port 字段里改成其它未占用端口(如 18081)。"
1035
+ );
1036
+ printServiceDidNotStart(`本地中继端口 ${CHATCCC_PORT} 无法监听(${err.code ?? "?"} — ${err.message})`);
1037
+ process.exit(1);
944
1038
  });
1039
+
1040
+ try {
1041
+ await startBotService({ httpServer, port: CHATCCC_PORT });
1042
+ } catch (err) {
1043
+ printServiceDidNotStart((err as Error).message);
1044
+ process.exit(1);
1045
+ }
1046
+
1047
+ installShutdownHandlers(httpServer);
1048
+ }
1049
+
1050
+ /**
1051
+ * 注册 SIGINT / SIGTERM 清理:把 relay/setup 共用的 httpServer 关掉再退出。
1052
+ *
1053
+ * Node EventEmitter 按注册顺序触发,installCrashLogging 装得更早 → 同步 trace
1054
+ * 先写盘,再走这里。
1055
+ */
1056
+ function installShutdownHandlers(httpServer: Server): void {
1057
+ process.on("SIGINT", () => { console.log("\nShutting down..."); httpServer.close(); process.exit(0); });
1058
+ process.on("SIGTERM", () => { httpServer.close(); process.exit(0); });
945
1059
  }
946
1060
 
947
1061
  main().catch((err: Error) => {
package/src/session.ts CHANGED
@@ -2,14 +2,17 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
 
4
4
  import {
5
+ CLAUDE_API_KEY,
6
+ CLAUDE_BASE_URL,
5
7
  CLAUDE_EFFORT,
6
8
  CLAUDE_MODEL,
7
9
  SESSIONS_FILE,
8
10
  addRecentDir,
9
11
  anthropicConfigDisplay,
12
+ config,
10
13
  fileLog,
11
14
  getDefaultCwd,
12
- isSdkAnthropicDefault,
15
+ isAnthropicConfigEmpty,
13
16
  toolDisplayName,
14
17
  ts,
15
18
  } from "./config.ts";
@@ -98,7 +101,9 @@ export function getAdapterForTool(tool: string): ToolAdapter {
98
101
  adapter = createClaudeAdapter({
99
102
  model: CLAUDE_MODEL,
100
103
  effort: CLAUDE_EFFORT,
101
- isDefault: isSdkAnthropicDefault,
104
+ isEmpty: isAnthropicConfigEmpty,
105
+ apiKey: CLAUDE_API_KEY,
106
+ baseUrl: CLAUDE_BASE_URL,
102
107
  });
103
108
  }
104
109
  adapterCache.set(tool, adapter);
@@ -259,10 +264,10 @@ function formatToolConfigForLog(tool: string, sessionModel?: string): string {
259
264
  return `model=${sessionModel ?? "(由 cursor-agent 决定,init 事件后学习)"}`;
260
265
  }
261
266
  if (tool === "codex") {
262
- const m = process.env.CHATCCC_CODEX_MODEL?.trim();
263
- const e = process.env.CHATCCC_CODEX_EFFORT?.trim();
264
- const modelStr = m && m !== "default" ? m : "(由 codex config.toml 决定)";
265
- const effortStr = e && e !== "default"
267
+ const m = config.codex.model;
268
+ const e = config.codex.effort;
269
+ const modelStr = m.trim() !== "" ? m : "(由 codex config.toml 决定)";
270
+ const effortStr = e.trim() !== ""
266
271
  ? `effort=${e}`
267
272
  : "effort=(由 codex config.toml 决定)";
268
273
  return `model=${modelStr}, ${effortStr}`;
@@ -367,7 +372,11 @@ export async function resumeAndPrompt(
367
372
  let lastSentContent = "";
368
373
  let streamErrorNotified = false;
369
374
  let healthLogTicks = 0;
370
- const sendInterval = cardId ? setInterval(async () => {
375
+ // 兜底:setInterval await 异步回调,回调内任何漏接的异常都会变成
376
+ // unhandledRejection 进而(在 Node 默认策略下)让进程崩。这里用 IIFE + .catch
377
+ // 整体兜一层,配合内部两个细粒度 try/catch 一起守住。
378
+ const sendInterval = cardId ? setInterval(() => {
379
+ void (async () => {
371
380
  const cEntry = chatSessionMap.get(chatId);
372
381
  if (!cEntry || cEntry.stopped || cEntry.cardBusy) return;
373
382
  if (cEntry.cardId !== cardId) return;
@@ -423,6 +432,12 @@ export async function resumeAndPrompt(
423
432
  } finally {
424
433
  cEntry.cardBusy = false;
425
434
  }
435
+ })().catch((err: unknown) => {
436
+ const e = err instanceof Error ? err : new Error(String(err));
437
+ console.error(`[${ts()}] [CARDIKT] spinner tick uncaught: ${e.message}\n${e.stack ?? ""}`);
438
+ const entry = chatSessionMap.get(chatId);
439
+ if (entry) entry.cardBusy = false;
440
+ });
426
441
  }, 3000) : null;
427
442
  if (sendInterval) {
428
443
  const entry = chatSessionMap.get(chatId);
@@ -532,7 +547,7 @@ export async function resumeAndPrompt(
532
547
  // effort:anthropicConfigDisplay(CLAUDE_EFFORT)
533
548
  // ---------------------------------------------------------------------------
534
549
 
535
- /** 未知/未学习到时的 model 占位符(卡片可视提示,区别于"显示成 default"的旧 bug) */
550
+ /** 未知/未学习到时的 model 占位符(卡片可视提示,避免在 UI 上显示空字符串) */
536
551
  export const UNKNOWN_MODEL_PLACEHOLDER = "—";
537
552
 
538
553
  export interface SessionStatus {
@@ -563,11 +578,11 @@ async function resolveModelEffort(
563
578
  return { model, effort: null };
564
579
  }
565
580
  if (tool === "codex") {
566
- const m = process.env.CHATCCC_CODEX_MODEL?.trim();
567
- const e = process.env.CHATCCC_CODEX_EFFORT?.trim();
581
+ const m = config.codex.model;
582
+ const e = config.codex.effort;
568
583
  return {
569
- model: m && m !== "default" ? m : UNKNOWN_MODEL_PLACEHOLDER,
570
- effort: e && e !== "default" ? e : UNKNOWN_MODEL_PLACEHOLDER,
584
+ model: m.trim() !== "" ? m : UNKNOWN_MODEL_PLACEHOLDER,
585
+ effort: e.trim() !== "" ? e : UNKNOWN_MODEL_PLACEHOLDER,
571
586
  };
572
587
  }
573
588
  return {