chatccc 0.2.16 → 0.2.17
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/README.md +105 -111
- package/bin/chatccc.mjs +24 -36
- package/config.sample.json +26 -0
- package/images/avatars/status_busy.png +0 -0
- package/images/avatars/status_idle.png +0 -0
- package/images/avatars/status_new.png +0 -0
- package/images/img_readme_0.jpg +0 -0
- package/images/img_readme_1.jpg +0 -0
- package/images/img_readme_callback.png +0 -0
- package/images/img_readme_event.png +0 -0
- package/images/img_readme_permission.png +0 -0
- package/package.json +13 -12
- package/src/__tests__/cards.test.ts +3 -3
- package/src/__tests__/claude-adapter.test.ts +301 -10
- package/src/__tests__/config-reload.test.ts +171 -0
- package/src/__tests__/config.test.ts +259 -2
- package/src/__tests__/crash-logging.test.ts +210 -0
- package/src/__tests__/session.test.ts +2 -2
- package/src/__tests__/web-ui.test.ts +178 -0
- package/src/adapters/claude-adapter.ts +57 -8
- package/src/adapters/codex-adapter.ts +9 -8
- package/src/adapters/cursor-adapter.ts +1 -1
- package/src/config-utils.ts +177 -0
- package/src/config.ts +616 -382
- package/src/exit-banner.ts +12 -2
- package/src/feishu-api.ts +4 -15
- package/src/index.ts +178 -64
- package/src/session.ts +27 -12
- package/src/shared.ts +189 -16
- package/src/web-ui.ts +1591 -0
- package/.env.example +0 -44
package/src/exit-banner.ts
CHANGED
|
@@ -12,12 +12,22 @@ export function printServiceDidNotStart(summary: string): void {
|
|
|
12
12
|
console.error(bar + "\n\n");
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
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:
|
|
266
|
-
busy:
|
|
267
|
-
idle:
|
|
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
|
-
|
|
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,
|
|
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", "
|
|
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
|
-
//
|
|
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
|
|
679
|
-
|
|
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[启动
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
757
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
935
|
-
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
263
|
-
const e =
|
|
264
|
-
const modelStr = m
|
|
265
|
-
const effortStr = e
|
|
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
|
-
|
|
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
|
|
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 =
|
|
567
|
-
const e =
|
|
581
|
+
const m = config.codex.model;
|
|
582
|
+
const e = config.codex.effort;
|
|
568
583
|
return {
|
|
569
|
-
model: m
|
|
570
|
-
effort: e
|
|
584
|
+
model: m.trim() !== "" ? m : UNKNOWN_MODEL_PLACEHOLDER,
|
|
585
|
+
effort: e.trim() !== "" ? e : UNKNOWN_MODEL_PLACEHOLDER,
|
|
571
586
|
};
|
|
572
587
|
}
|
|
573
588
|
return {
|