chatccc 0.2.228 → 0.2.230

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
@@ -28,19 +28,20 @@ import { WSClient, EventDispatcher, Domain } from "@larksuiteoapi/node-sdk";
28
28
  import WebSocket from "ws";
29
29
 
30
30
  import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRelayListenPort, installCrashLogging, waitForPortFree } from "./shared.ts";
31
- import { createUiRouter, setExtraApiHandler, setReloadConfigHook, startSetupMode } from "./web-ui.ts";
32
- import {
33
- buildWebUiUrl,
34
- createServiceLifecycleGuard,
35
- openWebUiInDefaultBrowser,
36
- shouldAutoOpenWebUi,
37
- } from "./startup-lifecycle.ts";
31
+ import { createUiRouter, setExtraApiHandler, setReloadConfigHook, startSetupMode } from "./web-ui.ts";
32
+ import {
33
+ buildWebUiUrl,
34
+ createServiceLifecycleGuard,
35
+ INTERNAL_RESTART_ENV_VAR,
36
+ openWebUiInDefaultBrowser,
37
+ shouldAutoOpenWebUi,
38
+ } from "./startup-lifecycle.ts";
38
39
  import { buildPlatformStartupPlan } from "./platform-startup.ts";
39
40
  import { makeTraceId, logTrace } from "./trace.ts";
40
41
  import {
41
- CHATCCC_PORT,
42
- config,
43
- APP_ID,
42
+ CHATCCC_PORT,
43
+ config,
44
+ APP_ID,
44
45
  APP_SECRET,
45
46
  FEISHU_ENABLED,
46
47
  FEISHU_PLATFORM_TYPE,
@@ -96,9 +97,9 @@ import {
96
97
  sendCardKitMessage,
97
98
  updateCardKitCard,
98
99
  } from "./cardkit.ts";
99
- import {
100
- loadSessionRegistryForBinding,
101
- rebuildBindingsFromRegistry,
100
+ import {
101
+ loadSessionRegistryForBinding,
102
+ rebuildBindingsFromRegistry,
102
103
  resetState,
103
104
  setSessionPlatform,
104
105
  startUnifiedDisplayLoop,
@@ -111,13 +112,13 @@ import {
111
112
  import { fixStaleStreamStates } from "./stream-state.ts";
112
113
  import { handleCommand, type PlatformAdapter } from "./orchestrator.ts";
113
114
  import { createWechatAdapter, startWechatPlatform } from "./wechat-platform.ts";
114
- import { handleCodexResetCardAction } from "./codex-reset-actions.ts";
115
- import { resolveFeishuCardActionChatType } from "./card-action-routing.ts";
116
- import { reloadRuntimeConfig } from "./runtime-reload.ts";
117
- import {
118
- createAckFirstEventHandler,
119
- feishuMessageLedger,
120
- } from "./feishu-message-ingress.ts";
115
+ import { handleCodexResetCardAction } from "./codex-reset-actions.ts";
116
+ import { resolveFeishuCardActionChatType } from "./card-action-routing.ts";
117
+ import { reloadRuntimeConfig } from "./runtime-reload.ts";
118
+ import {
119
+ createAckFirstEventHandler,
120
+ feishuMessageLedger,
121
+ } from "./feishu-message-ingress.ts";
121
122
 
122
123
  // ---------------------------------------------------------------------------
123
124
  // Feishu 平台适配器
@@ -199,21 +200,21 @@ function getInnerEvent(data: Evt): InnerEvent {
199
200
  return (data.event ?? data) as InnerEvent;
200
201
  }
201
202
 
202
- import { formatMessageContent } from "./format-message.ts";
203
- import {
204
- buildUpdateCommandId,
205
- extractFeishuEventId,
206
- } from "./update-command-guard.ts";
203
+ import { formatMessageContent } from "./format-message.ts";
204
+ import {
205
+ buildUpdateCommandId,
206
+ extractFeishuEventId,
207
+ } from "./update-command-guard.ts";
207
208
 
208
209
  // ---------------------------------------------------------------------------
209
210
  // Card action helper: parse button click into text command
210
211
  // ---------------------------------------------------------------------------
211
212
 
212
- interface CardActionResult {
213
- text: string;
214
- chatId: string;
215
- openId: string;
216
- commandId?: string;
213
+ interface CardActionResult {
214
+ text: string;
215
+ chatId: string;
216
+ openId: string;
217
+ commandId?: string;
217
218
  }
218
219
 
219
220
  function parseCardAction(data: unknown): CardActionResult | null {
@@ -252,130 +253,130 @@ function parseCardAction(data: unknown): CardActionResult | null {
252
253
  ((raw as Record<string, unknown>).operator as Record<string, unknown>)?.open_id as string ??
253
254
  "";
254
255
 
255
- return {
256
- text,
257
- chatId,
258
- openId,
259
- commandId: buildUpdateCommandId("card", extractFeishuEventId(data)),
260
- };
256
+ return {
257
+ text,
258
+ chatId,
259
+ openId,
260
+ commandId: buildUpdateCommandId("card", extractFeishuEventId(data)),
261
+ };
261
262
  }
262
263
 
263
264
  // ---------------------------------------------------------------------------
264
265
  // WebSocket relay broadcast
265
266
  // ---------------------------------------------------------------------------
266
267
 
267
- let broadcastToRelay: (data: unknown) => void = () => {};
268
- const wechatSignal = { stopped: false };
269
-
270
- async function processFeishuMessageEvent(data: Evt): Promise<void> {
271
- const traceId = makeTraceId();
272
- try {
273
- broadcastToRelay(data);
274
-
275
- const event = getInnerEvent(data);
276
- const message = event.message;
277
- if (!message) return;
278
-
279
- const messageId = message.message_id;
280
- const chatId = message.chat_id ?? "";
281
- const chatType = message.chat_type ?? "group";
282
- const msgTimestamp = parseInt(message.create_time ?? "0", 10) || Date.now();
283
- const disposition = await feishuMessageLedger.accept({
284
- messageId,
285
- chatId,
286
- createTime: msgTimestamp,
287
- });
288
-
289
- if (disposition === "duplicate") {
290
- console.log(`[MSG] Duplicate message ignored: ${messageId ?? "(missing id)"}`);
291
- return;
292
- }
293
- if (disposition === "stale") {
294
- logTrace(traceId, "DONE", {
295
- outcome: "skip_stale_feishu_message",
296
- messageId,
297
- chatId,
298
- msgTimestamp,
299
- });
300
- console.log(
301
- `[${ts()}] [SKIP] Stale Feishu message ignored: messageId=${messageId ?? "(missing id)"} chatId=${chatId} createTime=${msgTimestamp}`,
302
- );
303
- return;
304
- }
305
-
306
- const text = await formatMessageContent(message);
307
- const openId = event.sender?.sender_id?.open_id ?? "";
308
-
309
- console.log(
310
- `[MSG] id=${messageId ?? "(missing)"} sender=${openId} chat=${chatId} type=${chatType} text="${text}"`,
311
- );
312
- appendChatLog(chatId, openId, text);
313
-
314
- if (messageId) {
315
- getTenantAccessToken().then((freshToken) =>
316
- addReaction(freshToken, messageId).catch((err) =>
317
- console.error(`[${ts()}] Reaction failed: ${(err as Error).message}`)
318
- )
319
- ).catch((err) =>
320
- console.error(`[${ts()}] Reaction token failed: ${(err as Error).message}`)
321
- );
322
- }
323
-
324
- if (!text) return;
325
- logTrace(traceId, "RECV", {
326
- messageId,
327
- chatId,
328
- chatType,
329
- text: text.slice(0, 100),
330
- });
331
-
332
- const delayNotice = formatDelayNotice(msgTimestamp, text);
333
- if (delayNotice) {
334
- const delayToken = await getTenantAccessToken();
335
- await sendCardReply(delayToken, chatId, "延迟送达", delayNotice, "yellow").catch(() => {});
336
- // 延迟送达的旧消息只发提醒、不转发给 agent 执行:
337
- // 断线重连或补推时,早已过期的消息不应未经确认就触发新任务。
338
- // 用户如需执行可自行重发。
339
- logTrace(traceId, "DONE", {
340
- outcome: "skip_delayed_feishu_message",
341
- chatId,
342
- msgTimestamp,
343
- delayMinutes: Math.floor((Date.now() - msgTimestamp) / 60000),
344
- });
345
- console.log(
346
- `[${ts()}] [SKIP] Delayed Feishu message not forwarded to agent: messageId=${messageId ?? "(missing id)"} chatId=${chatId} createTime=${msgTimestamp}`,
347
- );
348
- return;
349
- }
350
-
351
- await handleCommand(
352
- feishuPlatform,
353
- text,
354
- chatId,
355
- openId,
356
- msgTimestamp,
357
- chatType,
358
- traceId,
359
- buildUpdateCommandId("message", messageId),
360
- );
361
- } catch (err) {
362
- logTrace(traceId, "ERROR", { message: (err as Error).message });
363
- console.error(
364
- `[${ts()}] [FATAL] im.message.receive_v1 worker crashed: ${(err as Error).message}`,
365
- );
366
- }
367
- }
368
-
369
- const handleFeishuMessageEventAckFirst = createAckFirstEventHandler(
370
- processFeishuMessageEvent,
371
- (err) => console.error(
372
- `[${ts()}] [FATAL] Detached Feishu event worker failed: ${(err as Error).message}`,
373
- ),
374
- );
375
-
376
- // ---------------------------------------------------------------------------
377
- // Simulate mode: inject message via HTTP
378
- // ---------------------------------------------------------------------------
268
+ let broadcastToRelay: (data: unknown) => void = () => {};
269
+ const wechatSignal = { stopped: false };
270
+
271
+ async function processFeishuMessageEvent(data: Evt): Promise<void> {
272
+ const traceId = makeTraceId();
273
+ try {
274
+ broadcastToRelay(data);
275
+
276
+ const event = getInnerEvent(data);
277
+ const message = event.message;
278
+ if (!message) return;
279
+
280
+ const messageId = message.message_id;
281
+ const chatId = message.chat_id ?? "";
282
+ const chatType = message.chat_type ?? "group";
283
+ const msgTimestamp = parseInt(message.create_time ?? "0", 10) || Date.now();
284
+ const disposition = await feishuMessageLedger.accept({
285
+ messageId,
286
+ chatId,
287
+ createTime: msgTimestamp,
288
+ });
289
+
290
+ if (disposition === "duplicate") {
291
+ console.log(`[MSG] Duplicate message ignored: ${messageId ?? "(missing id)"}`);
292
+ return;
293
+ }
294
+ if (disposition === "stale") {
295
+ logTrace(traceId, "DONE", {
296
+ outcome: "skip_stale_feishu_message",
297
+ messageId,
298
+ chatId,
299
+ msgTimestamp,
300
+ });
301
+ console.log(
302
+ `[${ts()}] [SKIP] Stale Feishu message ignored: messageId=${messageId ?? "(missing id)"} chatId=${chatId} createTime=${msgTimestamp}`,
303
+ );
304
+ return;
305
+ }
306
+
307
+ const text = await formatMessageContent(message);
308
+ const openId = event.sender?.sender_id?.open_id ?? "";
309
+
310
+ console.log(
311
+ `[MSG] id=${messageId ?? "(missing)"} sender=${openId} chat=${chatId} type=${chatType} text="${text}"`,
312
+ );
313
+ appendChatLog(chatId, openId, text);
314
+
315
+ if (messageId) {
316
+ getTenantAccessToken().then((freshToken) =>
317
+ addReaction(freshToken, messageId).catch((err) =>
318
+ console.error(`[${ts()}] Reaction failed: ${(err as Error).message}`)
319
+ )
320
+ ).catch((err) =>
321
+ console.error(`[${ts()}] Reaction token failed: ${(err as Error).message}`)
322
+ );
323
+ }
324
+
325
+ if (!text) return;
326
+ logTrace(traceId, "RECV", {
327
+ messageId,
328
+ chatId,
329
+ chatType,
330
+ text: text.slice(0, 100),
331
+ });
332
+
333
+ const delayNotice = formatDelayNotice(msgTimestamp, text);
334
+ if (delayNotice) {
335
+ const delayToken = await getTenantAccessToken();
336
+ await sendCardReply(delayToken, chatId, "延迟送达", delayNotice, "yellow").catch(() => {});
337
+ // 延迟送达的旧消息只发提醒、不转发给 agent 执行:
338
+ // 断线重连或补推时,早已过期的消息不应未经确认就触发新任务。
339
+ // 用户如需执行可自行重发。
340
+ logTrace(traceId, "DONE", {
341
+ outcome: "skip_delayed_feishu_message",
342
+ chatId,
343
+ msgTimestamp,
344
+ delayMinutes: Math.floor((Date.now() - msgTimestamp) / 60000),
345
+ });
346
+ console.log(
347
+ `[${ts()}] [SKIP] Delayed Feishu message not forwarded to agent: messageId=${messageId ?? "(missing id)"} chatId=${chatId} createTime=${msgTimestamp}`,
348
+ );
349
+ return;
350
+ }
351
+
352
+ await handleCommand(
353
+ feishuPlatform,
354
+ text,
355
+ chatId,
356
+ openId,
357
+ msgTimestamp,
358
+ chatType,
359
+ traceId,
360
+ buildUpdateCommandId("message", messageId),
361
+ );
362
+ } catch (err) {
363
+ logTrace(traceId, "ERROR", { message: (err as Error).message });
364
+ console.error(
365
+ `[${ts()}] [FATAL] im.message.receive_v1 worker crashed: ${(err as Error).message}`,
366
+ );
367
+ }
368
+ }
369
+
370
+ const handleFeishuMessageEventAckFirst = createAckFirstEventHandler(
371
+ processFeishuMessageEvent,
372
+ (err) => console.error(
373
+ `[${ts()}] [FATAL] Detached Feishu event worker failed: ${(err as Error).message}`,
374
+ ),
375
+ );
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // Simulate mode: inject message via HTTP
379
+ // ---------------------------------------------------------------------------
379
380
 
380
381
  async function handleSimInjectMessage(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
381
382
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
@@ -507,9 +508,9 @@ async function startBotServiceCore(): Promise<void> {
507
508
 
508
509
  console.log(`[${ts()}] [AUTH] Token obtained`);
509
510
 
510
- const eventDispatcher = new EventDispatcher({});
511
- eventDispatcher.register({
512
- "im.message.receive_v1": handleFeishuMessageEventAckFirst,
511
+ const eventDispatcher = new EventDispatcher({});
512
+ eventDispatcher.register({
513
+ "im.message.receive_v1": handleFeishuMessageEventAckFirst,
513
514
 
514
515
  "card.action.trigger": async (data: Evt) => {
515
516
  try {
@@ -552,21 +553,21 @@ async function startBotServiceCore(): Promise<void> {
552
553
  });
553
554
  if (handledCodexReset) return;
554
555
 
555
- const result = parseCardAction(data);
556
- if (!result) return;
557
- const registry = await loadSessionRegistryForBinding();
558
- const chatType = resolveFeishuCardActionChatType(result.chatId, registry);
559
- console.log(`[BTN] chat=${result.chatId} chatType=${chatType} text="${result.text}"`);
560
- handleCommand(
561
- feishuPlatform,
562
- result.text,
563
- result.chatId,
564
- result.openId,
565
- Date.now(),
566
- chatType,
567
- undefined,
568
- result.commandId,
569
- ).catch((err) =>
556
+ const result = parseCardAction(data);
557
+ if (!result) return;
558
+ const registry = await loadSessionRegistryForBinding();
559
+ const chatType = resolveFeishuCardActionChatType(result.chatId, registry);
560
+ console.log(`[BTN] chat=${result.chatId} chatType=${chatType} text="${result.text}"`);
561
+ handleCommand(
562
+ feishuPlatform,
563
+ result.text,
564
+ result.chatId,
565
+ result.openId,
566
+ Date.now(),
567
+ chatType,
568
+ undefined,
569
+ result.commandId,
570
+ ).catch((err) =>
570
571
  console.error(`[${ts()}] [BTN] handleCommand failed: ${(err as Error).message}`)
571
572
  );
572
573
  } catch (err) {
@@ -589,20 +590,20 @@ async function startBotServiceCore(): Promise<void> {
589
590
  ws.on("message", async (raw: Buffer) => {
590
591
  try {
591
592
  const data = JSON.parse(raw.toString()) as Evt;
592
- const action = parseCardAction(data);
593
- if (action) {
594
- const registry = await loadSessionRegistryForBinding();
595
- const chatType = resolveFeishuCardActionChatType(action.chatId, registry);
596
- handleCommand(
597
- feishuPlatform,
598
- action.text,
599
- action.chatId,
600
- action.openId,
601
- Date.now(),
602
- chatType,
603
- undefined,
604
- action.commandId,
605
- ).catch((err) =>
593
+ const action = parseCardAction(data);
594
+ if (action) {
595
+ const registry = await loadSessionRegistryForBinding();
596
+ const chatType = resolveFeishuCardActionChatType(action.chatId, registry);
597
+ handleCommand(
598
+ feishuPlatform,
599
+ action.text,
600
+ action.chatId,
601
+ action.openId,
602
+ Date.now(),
603
+ chatType,
604
+ undefined,
605
+ action.commandId,
606
+ ).catch((err) =>
606
607
  console.error(`[${ts()}] [BTN] handleCommand failed: ${(err as Error).message}`)
607
608
  );
608
609
  return;
@@ -636,8 +637,8 @@ async function startBotServiceCore(): Promise<void> {
636
637
  } else {
637
638
  // 进程首次启动:此时所有 Map 都是空的,resetState 主要是打个 LOG 标识"开始
638
639
  // 干净状态"。修正残留的 running stream-state 并重建 session→chat 映射。
639
- resetState();
640
- await feishuMessageLedger.load();
640
+ resetState();
641
+ await feishuMessageLedger.load();
641
642
  startUnifiedDisplayLoop();
642
643
  fixStaleStreamStates().then(async () => {
643
644
  const registry = await loadSessionRegistryForBinding();
@@ -776,32 +777,32 @@ async function startConfiguredPlatforms(
776
777
  // Main
777
778
  // ---------------------------------------------------------------------------
778
779
 
779
- async function main(): Promise<void> {
780
- // 用户直接运行 chatccc 时打开系统默认浏览器;由 `/restart`、`/update`
781
- // 或 Web UI 拉起的替代进程会携带内部标记,不重复打扰用户。
782
- const autoOpenWebUi = shouldAutoOpenWebUi({
783
- openOnStart: config.webUi.openOnStart,
784
- });
785
- appendStartupTrace("main: entered", {
786
- argv: process.argv.join(" ").slice(0, 400),
787
- CHATCCC_PORT,
788
- PROJECT_ROOT,
789
- autoOpenWebUi,
780
+ async function main(): Promise<void> {
781
+ // 用户直接运行 chatccc 时打开系统默认浏览器;由 `/restart`、`/update`
782
+ // 或 Web UI 拉起的替代进程会携带内部标记,不重复打扰用户。
783
+ const autoOpenWebUi = shouldAutoOpenWebUi({
784
+ openOnStart: config.webUi.openOnStart,
785
+ });
786
+ appendStartupTrace("main: entered", {
787
+ argv: process.argv.join(" ").slice(0, 400),
788
+ CHATCCC_PORT,
789
+ PROJECT_ROOT,
790
+ autoOpenWebUi,
790
791
  });
791
792
 
792
- // ChatCCC 是常驻服务,不能把“当前刚好有没有 Agent session”当作进程生命周期。
793
- // referenced timer 明确锚定服务进程;HTTP Server 接入后还会定期 ref/健康检查。
794
- // `/restart`、`/update` 都使用 process.exit(),不会被 timer 阻止。
795
- const serviceLifecycle = createServiceLifecycleGuard({ tracer: appendStartupTrace });
796
- serviceLifecycle.start();
797
-
798
- // 黑匣子:所有未捕获异常 / 信号 / beforeExit 都同步写入 startup-trace.log(appendFileSync)。
793
+ // ChatCCC 是常驻服务,不能把“当前刚好有没有 Agent session”当作进程生命周期。
794
+ // referenced timer 明确锚定服务进程;HTTP Server 接入后还会定期 ref/健康检查。
795
+ // `/restart`、`/update` 都使用 process.exit(),不会被 timer 阻止。
796
+ const serviceLifecycle = createServiceLifecycleGuard({ tracer: appendStartupTrace });
797
+ serviceLifecycle.start();
798
+
799
+ // 黑匣子:所有未捕获异常 / 信号 / beforeExit 都同步写入 startup-trace.log(appendFileSync)。
799
800
  // 越早装越好——后续任何一行抛错都有兜底;它独立于 SIGINT 清理(见末尾的
800
801
  // server.close)——只负责诊断与默认致命退出,不替代清理逻辑。
801
- installCrashLogging({
802
- flush: () => fileLog.flush(),
803
- onBeforeExit: (code) => serviceLifecycle.handleBeforeExit(code),
804
- });
802
+ installCrashLogging({
803
+ flush: () => fileLog.flush(),
804
+ onBeforeExit: (code) => serviceLifecycle.handleBeforeExit(code),
805
+ });
805
806
 
806
807
  // 模拟模式:独立端口 18079,不与 SDK 实例冲突,不走飞书凭证/权限/WSClient
807
808
  if (USE_SIMULATE) {
@@ -841,33 +842,33 @@ async function main(): Promise<void> {
841
842
  simServer.once("error", onError);
842
843
  simServer.once("listening", onListening);
843
844
  simServer.listen(SIM_PORT, "127.0.0.1");
844
- }).catch((err: NodeJS.ErrnoException) => {
845
+ }).catch((err: NodeJS.ErrnoException) => {
845
846
  console.error(`\n[启动] 监听失败:端口 ${SIM_PORT}(${err.code ?? "?"} — ${err.message})`);
846
- process.exit(1);
847
- });
848
- serviceLifecycle.attachServer(
849
- simServer,
850
- () => recoverHttpServer(simServer, SIM_PORT),
851
- );
847
+ process.exit(1);
848
+ });
849
+ serviceLifecycle.attachServer(
850
+ simServer,
851
+ () => recoverHttpServer(simServer, SIM_PORT),
852
+ );
852
853
 
853
854
  console.log(`\n${"=".repeat(60)}`);
854
855
  console.log(` ChatCCC — 模拟飞书环境模式`);
855
856
  console.log(`${"=".repeat(60)}`);
856
857
  console.log(` 发送消息: POST http://127.0.0.1:${SIM_PORT}/api/sim/inject-message`);
857
858
  console.log(` 消息日志: ~/.chatccc/sim/messages.jsonl`);
858
- console.log(`${"=".repeat(60)}\n`);
859
-
860
- if (autoOpenWebUi) {
861
- const url = buildWebUiUrl(SIM_PORT);
862
- const opened = openWebUiInDefaultBrowser(SIM_PORT);
863
- appendStartupTrace(
864
- opened ? "web-ui: opening simulate browser" : "web-ui: simulate browser unavailable",
865
- { url },
866
- );
867
- }
868
-
869
- installShutdownHandlers(simServer, serviceLifecycle);
870
- return;
859
+ console.log(`${"=".repeat(60)}\n`);
860
+
861
+ if (autoOpenWebUi) {
862
+ const url = buildWebUiUrl(SIM_PORT);
863
+ const opened = openWebUiInDefaultBrowser(SIM_PORT);
864
+ appendStartupTrace(
865
+ opened ? "web-ui: opening simulate browser" : "web-ui: simulate browser unavailable",
866
+ { url },
867
+ );
868
+ }
869
+
870
+ installShutdownHandlers(simServer, serviceLifecycle);
871
+ return;
871
872
  }
872
873
 
873
874
  if (Number.isNaN(CHATCCC_PORT) || CHATCCC_PORT < 1 || CHATCCC_PORT > 65535) {
@@ -914,32 +915,32 @@ async function main(): Promise<void> {
914
915
  if (!APP_ID.trim() || !APP_SECRET.trim()) {
915
916
  // 凭证不全:进 setup 向导。注入 onActivate 回调让用户点"保存并启动"
916
917
  // 时,原地(同进程)调用 startBotService,复用 setup HTTP server。
917
- const setupServer = startSetupMode(CHATCCC_PORT, {
918
- openBrowser: autoOpenWebUi,
919
- onActivate: async (httpServer: Server) => {
918
+ const setupServer = startSetupMode(CHATCCC_PORT, {
919
+ openBrowser: autoOpenWebUi,
920
+ onActivate: async (httpServer: Server) => {
920
921
  reloadRuntimeConfig("setup-activate");
921
922
  appendStartupTrace("setup-activate: reloaded config from disk", {
922
923
  appIdMaskAfterReload: maskAppId(APP_ID),
923
924
  });
924
925
  try {
925
926
  await startConfiguredPlatforms(httpServer, { failOnFeishuError: true });
926
- return { ok: true };
927
+ return { ok: true };
927
928
  } catch (err) {
928
929
  appendStartupTrace("setup-activate: startConfiguredPlatforms failed", {
929
930
  message: (err as Error).message,
930
931
  });
931
932
  return { ok: false, error: (err as Error).message };
932
933
  }
933
- },
934
- });
935
- setupServer.once("listening", () => {
936
- serviceLifecycle.attachServer(
937
- setupServer,
938
- () => recoverHttpServer(setupServer, CHATCCC_PORT),
939
- );
940
- });
941
- installShutdownHandlers(setupServer, serviceLifecycle);
942
- return;
934
+ },
935
+ });
936
+ setupServer.once("listening", () => {
937
+ serviceLifecycle.attachServer(
938
+ setupServer,
939
+ () => recoverHttpServer(setupServer, CHATCCC_PORT),
940
+ );
941
+ });
942
+ installShutdownHandlers(setupServer, serviceLifecycle);
943
+ return;
943
944
  }
944
945
  console.log(` 必填项校验通过(App ID 摘要: ${maskAppId(APP_ID)})。\n`);
945
946
  appendStartupTrace("main: feishu credentials ok", { appIdMask: maskAppId(APP_ID) });
@@ -953,64 +954,68 @@ async function main(): Promise<void> {
953
954
  // 启动 HTTP server(同时挂 UI router,供 dashboard / setup / agent image/file 使用)
954
955
  appendStartupTrace("main: before freeRelayListenPort", { CHATCCC_PORT });
955
956
  const killed = freeRelayListenPort(CHATCCC_PORT);
956
- appendStartupTrace("main: after freeRelayListenPort", { CHATCCC_PORT, killed });
957
- if (killed > 0) {
958
- await waitForPortFree(CHATCCC_PORT);
959
- appendStartupTrace("main: port free confirmed", { CHATCCC_PORT });
957
+ const isInternalRestart = process.env[INTERNAL_RESTART_ENV_VAR] === "1";
958
+ appendStartupTrace("main: after freeRelayListenPort", { CHATCCC_PORT, killed, isInternalRestart });
959
+ // 内部自重启(/restart、/update)时父进程还占着端口(祖先链 PID 不会被杀):
960
+ // 无条件等待端口释放再 listen,避免子进程一上来就 EADDRINUSE 秒退
961
+ // (Windows 端口释放有额外延迟,重试窗口容易不够)。
962
+ if (killed > 0 || isInternalRestart) {
963
+ await waitForPortFree(CHATCCC_PORT, isInternalRestart ? 5000 : undefined);
964
+ appendStartupTrace("main: port free confirmed", { CHATCCC_PORT, isInternalRestart });
960
965
  }
961
- const httpServer = createServer(createUiRouter());
962
- await listenWithRetry(httpServer, CHATCCC_PORT, "127.0.0.1").catch((err: NodeJS.ErrnoException) => {
966
+ const httpServer = createServer(createUiRouter());
967
+ await listenWithRetry(httpServer, CHATCCC_PORT, "127.0.0.1").catch((err: NodeJS.ErrnoException) => {
963
968
  console.error(`\n[启动] 本地中继 WebSocket 监听失败:端口 ${CHATCCC_PORT}(${err.code ?? "?"} — ${err.message})`);
964
969
  console.error(
965
970
  " 处理建议: 关闭占用该端口的其它程序,或在 config.json 的 port 字段里改成其它未占用端口(如 18081)。"
966
971
  );
967
972
  printServiceDidNotStart(`本地中继端口 ${CHATCCC_PORT} 无法监听(${err.code ?? "?"} — ${err.message})`);
968
- process.exit(1);
969
- });
970
- serviceLifecycle.attachServer(
971
- httpServer,
972
- () => recoverHttpServer(httpServer, CHATCCC_PORT),
973
- );
974
- // 平台鉴权/长连接启动可能耗时;此时也必须允许 Ctrl+C / SIGTERM 正常退出,
975
- // 不能只留下更早安装的信号日志 listener 把默认退出行为吞掉。
976
- installShutdownHandlers(httpServer, serviceLifecycle);
977
-
978
- // 必须等 HTTP server 真正监听后再发起打开请求,避免浏览器先到一步看到
979
- // ERR_CONNECTION_REFUSED。Chrome CDP 守护仍保持自己原有的独立行为。
980
- if (autoOpenWebUi) {
981
- const url = buildWebUiUrl(CHATCCC_PORT);
982
- const opened = openWebUiInDefaultBrowser(CHATCCC_PORT);
983
- if (opened) {
984
- console.log(`[WEB-UI] 已请求系统默认浏览器打开: ${url}`);
985
- appendStartupTrace("web-ui: opening default browser", { url });
986
- } else {
987
- appendStartupTrace("web-ui: default browser unavailable", { url });
988
- }
989
- } else {
990
- appendStartupTrace("web-ui: default browser skipped by lifecycle or preference", {
991
- url: buildWebUiUrl(CHATCCC_PORT),
992
- openOnStart: config.webUi.openOnStart,
993
- });
994
- }
995
-
996
- await startConfiguredPlatforms(httpServer, { failOnFeishuError: false });
997
- }
998
-
999
- /**
1000
- * 生命周期健康检查发现 HTTP Server 已停止监听时,优先原地恢复同一个 Server。
1001
- * router、WebSocket upgrade listener 都挂在这个对象上,复用它能保留现有服务绑定;
1002
- * 恢复失败会被 lifecycle guard 记录,并在下一轮检查重试。
1003
- */
1004
- async function recoverHttpServer(httpServer: Server, port: number): Promise<void> {
1005
- if (httpServer.listening) {
1006
- httpServer.ref();
1007
- return;
1008
- }
1009
- appendStartupTrace("service-lifecycle: HTTP recovery begin", { port });
1010
- await listenWithRetry(httpServer, port, "127.0.0.1");
1011
- httpServer.ref();
1012
- appendStartupTrace("service-lifecycle: HTTP recovery listen succeeded", { port });
1013
- }
973
+ process.exit(1);
974
+ });
975
+ serviceLifecycle.attachServer(
976
+ httpServer,
977
+ () => recoverHttpServer(httpServer, CHATCCC_PORT),
978
+ );
979
+ // 平台鉴权/长连接启动可能耗时;此时也必须允许 Ctrl+C / SIGTERM 正常退出,
980
+ // 不能只留下更早安装的信号日志 listener 把默认退出行为吞掉。
981
+ installShutdownHandlers(httpServer, serviceLifecycle);
982
+
983
+ // 必须等 HTTP server 真正监听后再发起打开请求,避免浏览器先到一步看到
984
+ // ERR_CONNECTION_REFUSED。Chrome CDP 守护仍保持自己原有的独立行为。
985
+ if (autoOpenWebUi) {
986
+ const url = buildWebUiUrl(CHATCCC_PORT);
987
+ const opened = openWebUiInDefaultBrowser(CHATCCC_PORT);
988
+ if (opened) {
989
+ console.log(`[WEB-UI] 已请求系统默认浏览器打开: ${url}`);
990
+ appendStartupTrace("web-ui: opening default browser", { url });
991
+ } else {
992
+ appendStartupTrace("web-ui: default browser unavailable", { url });
993
+ }
994
+ } else {
995
+ appendStartupTrace("web-ui: default browser skipped by lifecycle or preference", {
996
+ url: buildWebUiUrl(CHATCCC_PORT),
997
+ openOnStart: config.webUi.openOnStart,
998
+ });
999
+ }
1000
+
1001
+ await startConfiguredPlatforms(httpServer, { failOnFeishuError: false });
1002
+ }
1003
+
1004
+ /**
1005
+ * 生命周期健康检查发现 HTTP Server 已停止监听时,优先原地恢复同一个 Server。
1006
+ * router、WebSocket upgrade listener 都挂在这个对象上,复用它能保留现有服务绑定;
1007
+ * 恢复失败会被 lifecycle guard 记录,并在下一轮检查重试。
1008
+ */
1009
+ async function recoverHttpServer(httpServer: Server, port: number): Promise<void> {
1010
+ if (httpServer.listening) {
1011
+ httpServer.ref();
1012
+ return;
1013
+ }
1014
+ appendStartupTrace("service-lifecycle: HTTP recovery begin", { port });
1015
+ await listenWithRetry(httpServer, port, "127.0.0.1");
1016
+ httpServer.ref();
1017
+ appendStartupTrace("service-lifecycle: HTTP recovery listen succeeded", { port });
1018
+ }
1014
1019
 
1015
1020
  /**
1016
1021
  * 带重试的 server.listen,Windows 端口释放有延迟时自动重试。
@@ -1048,26 +1053,26 @@ async function listenWithRetry(
1048
1053
  * Node EventEmitter 按注册顺序触发,installCrashLogging 装得更早 → 同步 trace
1049
1054
  * 先写盘,再走这里。
1050
1055
  */
1051
- function installShutdownHandlers(
1052
- httpServer: Server,
1053
- serviceLifecycle: ReturnType<typeof createServiceLifecycleGuard>,
1054
- ): void {
1055
- process.on("SIGINT", () => {
1056
- console.log("\nShutting down...");
1057
- serviceLifecycle.beginShutdown("SIGINT");
1058
- wechatSignal.stopped = true;
1059
- stopChromeDevtoolsGuard();
1060
- httpServer.close();
1061
- process.exit(0);
1062
- });
1063
- process.on("SIGTERM", () => {
1064
- serviceLifecycle.beginShutdown("SIGTERM");
1065
- wechatSignal.stopped = true;
1066
- stopChromeDevtoolsGuard();
1067
- httpServer.close();
1068
- process.exit(0);
1069
- });
1070
- }
1056
+ function installShutdownHandlers(
1057
+ httpServer: Server,
1058
+ serviceLifecycle: ReturnType<typeof createServiceLifecycleGuard>,
1059
+ ): void {
1060
+ process.on("SIGINT", () => {
1061
+ console.log("\nShutting down...");
1062
+ serviceLifecycle.beginShutdown("SIGINT");
1063
+ wechatSignal.stopped = true;
1064
+ stopChromeDevtoolsGuard();
1065
+ httpServer.close();
1066
+ process.exit(0);
1067
+ });
1068
+ process.on("SIGTERM", () => {
1069
+ serviceLifecycle.beginShutdown("SIGTERM");
1070
+ wechatSignal.stopped = true;
1071
+ stopChromeDevtoolsGuard();
1072
+ httpServer.close();
1073
+ process.exit(0);
1074
+ });
1075
+ }
1071
1076
 
1072
1077
  main().catch((err: Error) => {
1073
1078
  appendStartupTrace("main: catch fatal", { message: err.message, stack: err.stack?.slice(0, 800) });