chatccc 0.2.207 → 0.2.209

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.207",
3
+ "version": "0.2.209",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { resolveFeishuCardActionChatType } from "../card-action-routing.ts";
4
+
5
+ describe("resolveFeishuCardActionChatType", () => {
6
+ it("keeps card commands in a persisted private chat on the p2p route", () => {
7
+ expect(resolveFeishuCardActionChatType("private-chat", {
8
+ "private-chat": { chatType: "p2p" },
9
+ })).toBe("p2p");
10
+ });
11
+
12
+ it("defaults unknown and group chats to the group route", () => {
13
+ expect(resolveFeishuCardActionChatType("group-chat", {
14
+ "group-chat": { chatType: "group" },
15
+ })).toBe("group");
16
+ expect(resolveFeishuCardActionChatType("unknown-chat", {})).toBe("group");
17
+ });
18
+ });
@@ -342,6 +342,43 @@ describe("handleCommand WeChat processing ack", () => {
342
342
  expect(registry["feishu-p2p"]?.sessionId).toBe("sid-feishu-private");
343
343
  });
344
344
 
345
+ it("sends the normal session state card in an established Feishu p2p chat", async () => {
346
+ const platform = mockPlatform("feishu");
347
+ _setAdapterForToolForTest("claude", mockAdapter("sid-feishu-state"));
348
+ await recordSessionRegistry({
349
+ chatId: "feishu-p2p-state",
350
+ sessionId: "sid-feishu-state",
351
+ tool: "claude",
352
+ chatType: "p2p",
353
+ chatName: "飞书私聊",
354
+ turnCount: 2,
355
+ running: false,
356
+ });
357
+
358
+ await handleCommand(platform, "/state", "feishu-p2p-state", "ou-user", Date.now(), "p2p");
359
+
360
+ expect(platform.getChatInfo).not.toHaveBeenCalled();
361
+ expect(platform.sendRawCard).toHaveBeenCalledTimes(1);
362
+ const cardText = vi.mocked(platform.sendRawCard).mock.calls[0][1];
363
+ expect(cardText).toContain("sid-feishu-state");
364
+ expect(cardText).toContain("Claude Code");
365
+ expect(cardText).toContain("2");
366
+ });
367
+
368
+ it("shows an explicit state card without creating an Agent when a Feishu p2p chat is not bound yet", async () => {
369
+ const platform = mockPlatform("feishu");
370
+ const adapter = mockAdapter("should-not-be-created");
371
+ _setAdapterForToolForTest("claude", adapter);
372
+
373
+ await handleCommand(platform, "/state", "feishu-p2p-empty", "ou-user", Date.now(), "p2p");
374
+
375
+ expect(adapter.createSession).not.toHaveBeenCalled();
376
+ expect(platform.sendRawCard).toHaveBeenCalledTimes(1);
377
+ const cardText = vi.mocked(platform.sendRawCard).mock.calls[0][1];
378
+ expect(cardText).toContain("未建立会话");
379
+ expect(cardText).toContain("Claude Code");
380
+ });
381
+
345
382
  it("switches an idle Feishu p2p chat to a fresh session when the default Agent changes", async () => {
346
383
  const platform = mockPlatform("feishu");
347
384
  const oldPrompt = vi.fn(async function* () {
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
3
3
  import {
4
4
  INTERNAL_RESTART_ENV_VAR,
5
5
  buildWebUiUrl,
6
+ createServiceLifecycleGuard,
6
7
  createInternalRestartEnv,
7
8
  openWebUiInDefaultBrowser,
8
9
  shouldAutoOpenWebUi,
@@ -96,3 +97,135 @@ describe("ChatCCC startup lifecycle", () => {
96
97
  expect(onInfo).toHaveBeenCalledWith(expect.stringContaining("http://localhost:18080/"));
97
98
  });
98
99
  });
100
+
101
+ describe("ChatCCC service lifecycle guard", () => {
102
+ function createHarness() {
103
+ let intervalCallback: (() => void) | undefined;
104
+ const timer = { ref: vi.fn() };
105
+ const setIntervalImpl = vi.fn((callback: () => void) => {
106
+ intervalCallback = callback;
107
+ return timer;
108
+ });
109
+ const clearIntervalImpl = vi.fn();
110
+ const tracer = vi.fn();
111
+ const server = {
112
+ listening: true,
113
+ address: vi.fn(() => ({ address: "127.0.0.1", port: 18080 })),
114
+ ref: vi.fn(),
115
+ };
116
+ const recoverServer = vi.fn(async () => {
117
+ server.listening = true;
118
+ });
119
+ const guard = createServiceLifecycleGuard({
120
+ intervalMs: 10_000,
121
+ setIntervalImpl,
122
+ clearIntervalImpl,
123
+ tracer,
124
+ getActiveResourcesInfo: () => ["TCPServerWrap", "Timeout"],
125
+ });
126
+
127
+ return {
128
+ clearIntervalImpl,
129
+ getIntervalCallback: () => intervalCallback,
130
+ guard,
131
+ recoverServer,
132
+ server,
133
+ setIntervalImpl,
134
+ timer,
135
+ tracer,
136
+ };
137
+ }
138
+
139
+ it("starts exactly one referenced keep-alive timer", () => {
140
+ const h = createHarness();
141
+
142
+ h.guard.start();
143
+ h.guard.start();
144
+
145
+ expect(h.setIntervalImpl).toHaveBeenCalledOnce();
146
+ expect(h.setIntervalImpl).toHaveBeenCalledWith(expect.any(Function), 10_000);
147
+ expect(h.timer.ref).toHaveBeenCalledOnce();
148
+ });
149
+
150
+ it("keeps a listening HTTP server referenced", async () => {
151
+ const h = createHarness();
152
+ h.guard.attachServer(h.server, h.recoverServer);
153
+ h.guard.start();
154
+
155
+ await h.guard.checkNow();
156
+
157
+ expect(h.server.ref).toHaveBeenCalled();
158
+ expect(h.recoverServer).not.toHaveBeenCalled();
159
+ });
160
+
161
+ it("coalesces concurrent recovery when the HTTP server is not listening", async () => {
162
+ const h = createHarness();
163
+ h.server.listening = false;
164
+ let finishRecovery: (() => void) | undefined;
165
+ h.recoverServer.mockImplementation(() => new Promise<void>((resolve) => {
166
+ finishRecovery = () => {
167
+ h.server.listening = true;
168
+ resolve();
169
+ };
170
+ }));
171
+ h.guard.attachServer(h.server, h.recoverServer);
172
+ h.guard.start();
173
+
174
+ const first = h.guard.checkNow();
175
+ const second = h.guard.checkNow();
176
+ await Promise.resolve();
177
+ expect(h.recoverServer).toHaveBeenCalledOnce();
178
+
179
+ finishRecovery?.();
180
+ await Promise.all([first, second]);
181
+ expect(h.tracer).toHaveBeenCalledWith(
182
+ "service-lifecycle: HTTP server recovered",
183
+ expect.objectContaining({ serverListening: true }),
184
+ );
185
+ });
186
+
187
+ it("re-arms on unexpected beforeExit and records public diagnostics", async () => {
188
+ const h = createHarness();
189
+ h.guard.attachServer(h.server, h.recoverServer);
190
+
191
+ h.guard.handleBeforeExit(0);
192
+ await h.guard.checkNow();
193
+
194
+ expect(h.setIntervalImpl).toHaveBeenCalledOnce();
195
+ expect(h.server.ref).toHaveBeenCalled();
196
+ expect(h.tracer).toHaveBeenCalledWith(
197
+ "service-lifecycle: unexpected beforeExit",
198
+ expect.objectContaining({
199
+ code: 0,
200
+ activeResources: ["TCPServerWrap", "Timeout"],
201
+ serverListening: true,
202
+ }),
203
+ );
204
+ });
205
+
206
+ it("stays stopped after an intentional shutdown", () => {
207
+ const h = createHarness();
208
+ h.guard.start();
209
+
210
+ h.guard.beginShutdown("SIGTERM");
211
+ h.guard.handleBeforeExit(0);
212
+
213
+ expect(h.clearIntervalImpl).toHaveBeenCalledWith(h.timer);
214
+ expect(h.setIntervalImpl).toHaveBeenCalledOnce();
215
+ expect(h.tracer).toHaveBeenCalledWith(
216
+ "service-lifecycle: shutdown requested",
217
+ { reason: "SIGTERM" },
218
+ );
219
+ });
220
+
221
+ it("the timer callback runs a health check", async () => {
222
+ const h = createHarness();
223
+ h.guard.attachServer(h.server, h.recoverServer);
224
+ h.guard.start();
225
+
226
+ h.getIntervalCallback()?.();
227
+ await Promise.resolve();
228
+
229
+ expect(h.server.ref).toHaveBeenCalled();
230
+ });
231
+ });
@@ -0,0 +1,14 @@
1
+ export type FeishuCommandChatType = "p2p" | "group";
2
+
3
+ type SessionRegistryForRouting = Record<string, { chatType?: string }>;
4
+
5
+ /**
6
+ * Feishu card action callbacks do not include the chat type. Recover it from
7
+ * the persisted binding so buttons clicked in a private chat stay in p2p.
8
+ */
9
+ export function resolveFeishuCardActionChatType(
10
+ chatId: string,
11
+ registry: SessionRegistryForRouting,
12
+ ): FeishuCommandChatType {
13
+ return registry[chatId]?.chatType === "p2p" ? "p2p" : "group";
14
+ }
package/src/index.ts CHANGED
@@ -31,6 +31,7 @@ import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRel
31
31
  import { createUiRouter, setExtraApiHandler, setReloadConfigHook, startSetupMode } from "./web-ui.ts";
32
32
  import {
33
33
  buildWebUiUrl,
34
+ createServiceLifecycleGuard,
34
35
  openWebUiInDefaultBrowser,
35
36
  shouldAutoOpenWebUi,
36
37
  } from "./startup-lifecycle.ts";
@@ -112,7 +113,8 @@ import {
112
113
  import { fixStaleStreamStates } from "./stream-state.ts";
113
114
  import { handleCommand, type PlatformAdapter } from "./orchestrator.ts";
114
115
  import { createWechatAdapter, startWechatPlatform } from "./wechat-platform.ts";
115
- import { handleCodexResetCardAction } from "./codex-reset-actions.ts";
116
+ import { handleCodexResetCardAction } from "./codex-reset-actions.ts";
117
+ import { resolveFeishuCardActionChatType } from "./card-action-routing.ts";
116
118
  import { reloadRuntimeConfig } from "./runtime-reload.ts";
117
119
 
118
120
  // ---------------------------------------------------------------------------
@@ -507,16 +509,18 @@ async function startBotServiceCore(): Promise<void> {
507
509
  });
508
510
  if (handledCodexReset) return;
509
511
 
510
- const result = parseCardAction(data);
511
- if (!result) return;
512
- console.log(`[BTN] chat=${result.chatId} text="${result.text}"`);
512
+ const result = parseCardAction(data);
513
+ if (!result) return;
514
+ const registry = await loadSessionRegistryForBinding();
515
+ const chatType = resolveFeishuCardActionChatType(result.chatId, registry);
516
+ console.log(`[BTN] chat=${result.chatId} chatType=${chatType} text="${result.text}"`);
513
517
  handleCommand(
514
518
  feishuPlatform,
515
519
  result.text,
516
520
  result.chatId,
517
521
  result.openId,
518
522
  Date.now(),
519
- "group",
523
+ chatType,
520
524
  undefined,
521
525
  result.commandId,
522
526
  ).catch((err) =>
@@ -542,15 +546,17 @@ async function startBotServiceCore(): Promise<void> {
542
546
  ws.on("message", async (raw: Buffer) => {
543
547
  try {
544
548
  const data = JSON.parse(raw.toString()) as Evt;
545
- const action = parseCardAction(data);
546
- if (action) {
549
+ const action = parseCardAction(data);
550
+ if (action) {
551
+ const registry = await loadSessionRegistryForBinding();
552
+ const chatType = resolveFeishuCardActionChatType(action.chatId, registry);
547
553
  handleCommand(
548
554
  feishuPlatform,
549
555
  action.text,
550
556
  action.chatId,
551
557
  action.openId,
552
558
  Date.now(),
553
- "group",
559
+ chatType,
554
560
  undefined,
555
561
  action.commandId,
556
562
  ).catch((err) =>
@@ -739,10 +745,19 @@ async function main(): Promise<void> {
739
745
  autoOpenWebUi,
740
746
  });
741
747
 
742
- // 黑匣子:所有未捕获异常 / 信号 / beforeExit 都同步写入 startup-trace.log(appendFileSync)。
748
+ // ChatCCC 是常驻服务,不能把“当前刚好有没有 Agent session”当作进程生命周期。
749
+ // referenced timer 明确锚定服务进程;HTTP Server 接入后还会定期 ref/健康检查。
750
+ // `/restart`、`/update` 都使用 process.exit(),不会被 timer 阻止。
751
+ const serviceLifecycle = createServiceLifecycleGuard({ tracer: appendStartupTrace });
752
+ serviceLifecycle.start();
753
+
754
+ // 黑匣子:所有未捕获异常 / 信号 / beforeExit 都同步写入 startup-trace.log(appendFileSync)。
743
755
  // 越早装越好——后续任何一行抛错都有兜底;它独立于 SIGINT 清理(见末尾的
744
756
  // server.close)——只负责诊断与默认致命退出,不替代清理逻辑。
745
- installCrashLogging({ flush: () => fileLog.flush() });
757
+ installCrashLogging({
758
+ flush: () => fileLog.flush(),
759
+ onBeforeExit: (code) => serviceLifecycle.handleBeforeExit(code),
760
+ });
746
761
 
747
762
  // 模拟模式:独立端口 18079,不与 SDK 实例冲突,不走飞书凭证/权限/WSClient
748
763
  if (USE_SIMULATE) {
@@ -782,10 +797,14 @@ async function main(): Promise<void> {
782
797
  simServer.once("error", onError);
783
798
  simServer.once("listening", onListening);
784
799
  simServer.listen(SIM_PORT, "127.0.0.1");
785
- }).catch((err: NodeJS.ErrnoException) => {
800
+ }).catch((err: NodeJS.ErrnoException) => {
786
801
  console.error(`\n[启动] 监听失败:端口 ${SIM_PORT}(${err.code ?? "?"} — ${err.message})`);
787
- process.exit(1);
788
- });
802
+ process.exit(1);
803
+ });
804
+ serviceLifecycle.attachServer(
805
+ simServer,
806
+ () => recoverHttpServer(simServer, SIM_PORT),
807
+ );
789
808
 
790
809
  console.log(`\n${"=".repeat(60)}`);
791
810
  console.log(` ChatCCC — 模拟飞书环境模式`);
@@ -803,8 +822,8 @@ async function main(): Promise<void> {
803
822
  );
804
823
  }
805
824
 
806
- installShutdownHandlers(simServer);
807
- return;
825
+ installShutdownHandlers(simServer, serviceLifecycle);
826
+ return;
808
827
  }
809
828
 
810
829
  if (Number.isNaN(CHATCCC_PORT) || CHATCCC_PORT < 1 || CHATCCC_PORT > 65535) {
@@ -851,7 +870,7 @@ async function main(): Promise<void> {
851
870
  if (!APP_ID.trim() || !APP_SECRET.trim()) {
852
871
  // 凭证不全:进 setup 向导。注入 onActivate 回调让用户点"保存并启动"
853
872
  // 时,原地(同进程)调用 startBotService,复用 setup HTTP server。
854
- startSetupMode(CHATCCC_PORT, {
873
+ const setupServer = startSetupMode(CHATCCC_PORT, {
855
874
  openBrowser: autoOpenWebUi,
856
875
  onActivate: async (httpServer: Server) => {
857
876
  reloadRuntimeConfig("setup-activate");
@@ -860,17 +879,23 @@ async function main(): Promise<void> {
860
879
  });
861
880
  try {
862
881
  await startConfiguredPlatforms(httpServer, { failOnFeishuError: true });
863
- installShutdownHandlers(httpServer);
864
- return { ok: true };
882
+ return { ok: true };
865
883
  } catch (err) {
866
884
  appendStartupTrace("setup-activate: startConfiguredPlatforms failed", {
867
885
  message: (err as Error).message,
868
886
  });
869
887
  return { ok: false, error: (err as Error).message };
870
888
  }
871
- },
872
- });
873
- return;
889
+ },
890
+ });
891
+ setupServer.once("listening", () => {
892
+ serviceLifecycle.attachServer(
893
+ setupServer,
894
+ () => recoverHttpServer(setupServer, CHATCCC_PORT),
895
+ );
896
+ });
897
+ installShutdownHandlers(setupServer, serviceLifecycle);
898
+ return;
874
899
  }
875
900
  console.log(` 必填项校验通过(App ID 摘要: ${maskAppId(APP_ID)})。\n`);
876
901
  appendStartupTrace("main: feishu credentials ok", { appIdMask: maskAppId(APP_ID) });
@@ -898,6 +923,13 @@ async function main(): Promise<void> {
898
923
  printServiceDidNotStart(`本地中继端口 ${CHATCCC_PORT} 无法监听(${err.code ?? "?"} — ${err.message})`);
899
924
  process.exit(1);
900
925
  });
926
+ serviceLifecycle.attachServer(
927
+ httpServer,
928
+ () => recoverHttpServer(httpServer, CHATCCC_PORT),
929
+ );
930
+ // 平台鉴权/长连接启动可能耗时;此时也必须允许 Ctrl+C / SIGTERM 正常退出,
931
+ // 不能只留下更早安装的信号日志 listener 把默认退出行为吞掉。
932
+ installShutdownHandlers(httpServer, serviceLifecycle);
901
933
 
902
934
  // 必须等 HTTP server 真正监听后再发起打开请求,避免浏览器先到一步看到
903
935
  // ERR_CONNECTION_REFUSED。Chrome CDP 守护仍保持自己原有的独立行为。
@@ -918,9 +950,23 @@ async function main(): Promise<void> {
918
950
  }
919
951
 
920
952
  await startConfiguredPlatforms(httpServer, { failOnFeishuError: false });
921
-
922
- installShutdownHandlers(httpServer);
923
- }
953
+ }
954
+
955
+ /**
956
+ * 生命周期健康检查发现 HTTP Server 已停止监听时,优先原地恢复同一个 Server。
957
+ * router、WebSocket upgrade listener 都挂在这个对象上,复用它能保留现有服务绑定;
958
+ * 恢复失败会被 lifecycle guard 记录,并在下一轮检查重试。
959
+ */
960
+ async function recoverHttpServer(httpServer: Server, port: number): Promise<void> {
961
+ if (httpServer.listening) {
962
+ httpServer.ref();
963
+ return;
964
+ }
965
+ appendStartupTrace("service-lifecycle: HTTP recovery begin", { port });
966
+ await listenWithRetry(httpServer, port, "127.0.0.1");
967
+ httpServer.ref();
968
+ appendStartupTrace("service-lifecycle: HTTP recovery listen succeeded", { port });
969
+ }
924
970
 
925
971
  /**
926
972
  * 带重试的 server.listen,Windows 端口释放有延迟时自动重试。
@@ -958,10 +1004,26 @@ async function listenWithRetry(
958
1004
  * Node EventEmitter 按注册顺序触发,installCrashLogging 装得更早 → 同步 trace
959
1005
  * 先写盘,再走这里。
960
1006
  */
961
- function installShutdownHandlers(httpServer: Server): void {
962
- process.on("SIGINT", () => { console.log("\nShutting down..."); wechatSignal.stopped = true; stopChromeDevtoolsGuard(); httpServer.close(); process.exit(0); });
963
- process.on("SIGTERM", () => { wechatSignal.stopped = true; stopChromeDevtoolsGuard(); httpServer.close(); process.exit(0); });
964
- }
1007
+ function installShutdownHandlers(
1008
+ httpServer: Server,
1009
+ serviceLifecycle: ReturnType<typeof createServiceLifecycleGuard>,
1010
+ ): void {
1011
+ process.on("SIGINT", () => {
1012
+ console.log("\nShutting down...");
1013
+ serviceLifecycle.beginShutdown("SIGINT");
1014
+ wechatSignal.stopped = true;
1015
+ stopChromeDevtoolsGuard();
1016
+ httpServer.close();
1017
+ process.exit(0);
1018
+ });
1019
+ process.on("SIGTERM", () => {
1020
+ serviceLifecycle.beginShutdown("SIGTERM");
1021
+ wechatSignal.stopped = true;
1022
+ stopChromeDevtoolsGuard();
1023
+ httpServer.close();
1024
+ process.exit(0);
1025
+ });
1026
+ }
965
1027
 
966
1028
  main().catch((err: Error) => {
967
1029
  appendStartupTrace("main: catch fatal", { message: err.message, stack: err.stack?.slice(0, 800) });
@@ -416,6 +416,48 @@ function isFeishuP2p(platform: PlatformAdapter, chatType: string): boolean {
416
416
  return chatType === "p2p" && platform.kind === "feishu";
417
417
  }
418
418
 
419
+ async function sendStateCard(
420
+ platform: PlatformAdapter,
421
+ chatId: string,
422
+ sessionId: string | null,
423
+ toolLabel: string,
424
+ traceId: string,
425
+ ): Promise<void> {
426
+ const status = sessionId ? await getSessionStatus(chatId) : null;
427
+ const isActive = sessionId ? isSessionRunning(sessionId) : false;
428
+ const stateLabel = sessionId
429
+ ? (isActive ? "🟢 运行中" : "⚪ 空闲")
430
+ : "⚪ 未建立会话";
431
+ const statusText = [
432
+ `**群名:** ${status?.chatName || "—"}`,
433
+ `**Session ID:** ${sessionId ? `\`${status?.sessionId ?? sessionId}\`` : "—"}`,
434
+ `**工具:** ${toolLabel}`,
435
+ `**状态:** ${stateLabel}`,
436
+ `**已对话轮数:** ${status?.turnCount ?? 0}`,
437
+ `**模型:** ${sessionId ? (status?.model ?? anthropicConfigDisplay(CLAUDE_MODEL)) : "—"}`,
438
+ ];
439
+ if (status?.effort != null) {
440
+ statusText.push(`**Effort:** ${status.effort}`);
441
+ }
442
+ if (isActive && status) {
443
+ const elapsed = Math.floor((Date.now() - status.startTime) / 1000);
444
+ const mins = Math.floor(elapsed / 60);
445
+ const secs = elapsed % 60;
446
+ statusText.push(`**本轮已运行:** ${mins}分${secs}秒`);
447
+ statusText.push(`**已产出总字符:** ${status.accumulatedLength.toLocaleString()}`);
448
+ }
449
+ if (status?.lastContextTokens) {
450
+ statusText.push(`**上下文 Token 数:** ~${status.lastContextTokens.toLocaleString()}`);
451
+ }
452
+ const card = buildStatusCard(statusText.join("\n"), isActive ? "blue" : "green");
453
+ const ok = await platform.sendRawCard(chatId, card);
454
+ console.log(`[${ts()}] [STATUS] card sent, ok=${ok}`);
455
+ logTrace(traceId, "DONE", {
456
+ outcome: sessionId ? "status" : "status_no_session",
457
+ ok,
458
+ });
459
+ }
460
+
419
461
  interface FeishuP2pRegistryRecord {
420
462
  sessionId: string;
421
463
  tool: string;
@@ -1336,44 +1378,11 @@ export async function handleCommand(
1336
1378
  return;
1337
1379
  }
1338
1380
 
1339
- if (isCommandText && textLower === "/state") {
1340
- logTrace(tid, "BRANCH", { cmd: "/state" });
1341
- const status = await getSessionStatus(chatId);
1342
- const isActive = isSessionRunning(sessionId);
1343
- const statusText = [
1344
- `**群名:** ${status?.chatName || "—"}`,
1345
- `**Session ID:** \`${status?.sessionId ?? sessionId}\``,
1346
- `**工具:** ${toolLabel}`,
1347
- `**状态:** ${isActive ? "🟢 运行中" : "⚪ 空闲"}`,
1348
- `**已对话轮数:** ${status?.turnCount ?? 0}`,
1349
- `**模型:** ${status?.model ?? anthropicConfigDisplay(CLAUDE_MODEL)}`,
1350
- ];
1351
- if (status?.effort != null) {
1352
- statusText.push(`**Effort:** ${status.effort}`);
1353
- }
1354
- if (isActive) {
1355
- const elapsed = Math.floor((Date.now() - status!.startTime) / 1000);
1356
- const mins = Math.floor(elapsed / 60);
1357
- const secs = elapsed % 60;
1358
- statusText.push(`**本轮已运行:** ${mins}分${secs}秒`);
1359
- statusText.push(
1360
- `**已产出总字符:** ${status!.accumulatedLength.toLocaleString()}`,
1361
- );
1362
- }
1363
- if (status?.lastContextTokens) {
1364
- statusText.push(
1365
- `**上下文 Token 数:** ~${status.lastContextTokens.toLocaleString()}`,
1366
- );
1367
- }
1368
- const card = buildStatusCard(
1369
- statusText.join("\n"),
1370
- isActive ? "blue" : "green",
1371
- );
1372
- const ok = await platform.sendRawCard(chatId, card);
1373
- console.log(`[${ts()}] [STATUS] card sent, ok=${ok}`);
1374
- logTrace(tid, "DONE", { outcome: "status", ok });
1375
- return;
1376
- }
1381
+ if (isCommandText && textLower === "/state") {
1382
+ logTrace(tid, "BRANCH", { cmd: "/state" });
1383
+ await sendStateCard(platform, chatId, sessionId, toolLabel, tid);
1384
+ return;
1385
+ }
1377
1386
 
1378
1387
  if (isCommandText && textLower === "/sessions") {
1379
1388
  logTrace(tid, "BRANCH", { cmd: "/sessions" });
@@ -2000,9 +2009,23 @@ export async function handleCommand(
2000
2009
  const card = buildModelCard(currentModel, models, defaultTool);
2001
2010
  await platform.sendRawCard(chatId, card);
2002
2011
  }
2003
- logTrace(tid, "DONE", { outcome: "model_query", defaultTool });
2004
- return;
2005
- }
2012
+ logTrace(tid, "DONE", { outcome: "model_query", defaultTool });
2013
+ return;
2014
+ }
2015
+
2016
+ // A private /state query is useful even before the first Agent session exists.
2017
+ // Keep it read-only and render the same status-card shape as established chats.
2018
+ if (isCommandText && textLower === "/state" && isFeishuP2p(platform, chatType)) {
2019
+ logTrace(tid, "BRANCH", { cmd: "/state", scope: "unbound_p2p" });
2020
+ await sendStateCard(
2021
+ platform,
2022
+ chatId,
2023
+ null,
2024
+ toolDisplayName(resolveDefaultAgentTool()),
2025
+ tid,
2026
+ );
2027
+ return;
2028
+ }
2006
2029
 
2007
2030
  // 无会话上下文 → /sessions 仍是有效指令,不触发飞书私聊自动建群。
2008
2031
  if (isCommandText && textLower === "/effort") {
@@ -1,5 +1,159 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
2
 
3
+ const DEFAULT_SERVICE_HEALTH_INTERVAL_MS = 10_000;
4
+
5
+ interface RefTimer {
6
+ ref?: () => unknown;
7
+ }
8
+
9
+ export interface ServiceLifecycleServer {
10
+ listening: boolean;
11
+ address: () => unknown;
12
+ ref: () => unknown;
13
+ }
14
+
15
+ interface ServiceLifecycleGuardOptions {
16
+ intervalMs?: number;
17
+ setIntervalImpl?: (callback: () => void, delayMs: number) => RefTimer;
18
+ clearIntervalImpl?: (timer: RefTimer) => void;
19
+ tracer?: (message: string, extra?: Record<string, unknown>) => void;
20
+ getActiveResourcesInfo?: () => string[];
21
+ }
22
+
23
+ export interface ServiceLifecycleGuard {
24
+ start: () => void;
25
+ attachServer: (
26
+ server: ServiceLifecycleServer,
27
+ recoverServer?: () => void | Promise<void>,
28
+ ) => void;
29
+ checkNow: () => Promise<void>;
30
+ handleBeforeExit: (code: number) => void;
31
+ beginShutdown: (reason: string) => void;
32
+ }
33
+
34
+ /**
35
+ * 为 ChatCCC 这种常驻服务建立一个明确的进程生命周期锚点。
36
+ *
37
+ * 正常情况下,正在 listen 的 HTTP Server 自己就足以维持事件循环;额外的
38
+ * referenced timer 是最后一道保险,避免某个依赖升级或异常 close/unref 让进程在
39
+ * 没有信号、异常或退出码的情况下静默消失。定时检查同时会重新 ref Server,并在
40
+ * Server 确实停止监听时串行触发恢复,避免只把一个失去服务能力的僵尸进程留下来。
41
+ */
42
+ export function createServiceLifecycleGuard(
43
+ options: ServiceLifecycleGuardOptions = {},
44
+ ): ServiceLifecycleGuard {
45
+ const intervalMs = options.intervalMs ?? DEFAULT_SERVICE_HEALTH_INTERVAL_MS;
46
+ const setIntervalImpl = options.setIntervalImpl
47
+ ?? ((callback, delayMs) => setInterval(callback, delayMs));
48
+ const clearIntervalImpl = options.clearIntervalImpl
49
+ ?? ((timer) => clearInterval(timer as NodeJS.Timeout));
50
+ const tracer = options.tracer ?? (() => {});
51
+ const getActiveResourcesInfo = options.getActiveResourcesInfo
52
+ ?? (() => process.getActiveResourcesInfo());
53
+
54
+ let timer: RefTimer | null = null;
55
+ let server: ServiceLifecycleServer | null = null;
56
+ let recoverServer: (() => void | Promise<void>) | undefined;
57
+ let recoveryPromise: Promise<void> | null = null;
58
+ let shuttingDown = false;
59
+
60
+ const trace = (message: string, extra?: Record<string, unknown>): void => {
61
+ try { tracer(message, extra); } catch { /* 诊断路径不能反过来打断服务 */ }
62
+ };
63
+
64
+ const serverAddress = (): unknown => {
65
+ try { return server?.address() ?? null; } catch { return null; }
66
+ };
67
+
68
+ const activeResources = (): string[] => {
69
+ try { return getActiveResourcesInfo(); } catch { return []; }
70
+ };
71
+
72
+ const diagnostics = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({
73
+ ...extra,
74
+ uptimeSeconds: Math.floor(process.uptime()),
75
+ activeResources: activeResources(),
76
+ serverAttached: server !== null,
77
+ serverListening: server?.listening ?? false,
78
+ serverAddress: serverAddress(),
79
+ });
80
+
81
+ const start = (): void => {
82
+ if (shuttingDown || timer) return;
83
+ timer = setIntervalImpl(() => { void checkNow(); }, intervalMs);
84
+ // Node 的 Timeout 默认就是 ref 状态;显式 ref 让常驻服务契约不会依赖默认值。
85
+ try { timer.ref?.(); } catch { /* ignore */ }
86
+ trace("service-lifecycle: guard started", { intervalMs });
87
+ };
88
+
89
+ const checkNow = async (): Promise<void> => {
90
+ if (shuttingDown || !server) return;
91
+ if (server.listening) {
92
+ try { server.ref(); } catch (err) {
93
+ trace("service-lifecycle: HTTP server ref failed", diagnostics({
94
+ error: (err as Error).message,
95
+ }));
96
+ }
97
+ return;
98
+ }
99
+
100
+ if (recoveryPromise) return recoveryPromise;
101
+ trace("service-lifecycle: HTTP server inactive", diagnostics());
102
+ if (!recoverServer) return;
103
+
104
+ recoveryPromise = Promise.resolve()
105
+ .then(() => recoverServer?.())
106
+ .then(() => {
107
+ if (server?.listening) {
108
+ try { server.ref(); } catch { /* 下一轮健康检查会再次尝试 */ }
109
+ trace("service-lifecycle: HTTP server recovered", diagnostics());
110
+ } else {
111
+ trace("service-lifecycle: HTTP recovery completed without listening", diagnostics());
112
+ }
113
+ })
114
+ .catch((err: unknown) => {
115
+ trace("service-lifecycle: HTTP server recovery failed", diagnostics({
116
+ error: err instanceof Error ? err.message : String(err),
117
+ }));
118
+ })
119
+ .finally(() => {
120
+ recoveryPromise = null;
121
+ });
122
+ return recoveryPromise;
123
+ };
124
+
125
+ const attachServer = (
126
+ nextServer: ServiceLifecycleServer,
127
+ nextRecoverServer?: () => void | Promise<void>,
128
+ ): void => {
129
+ server = nextServer;
130
+ recoverServer = nextRecoverServer;
131
+ if (server.listening) {
132
+ try { server.ref(); } catch { /* 下一轮健康检查会记录 */ }
133
+ }
134
+ trace("service-lifecycle: HTTP server attached", diagnostics());
135
+ };
136
+
137
+ const handleBeforeExit = (code: number): void => {
138
+ if (shuttingDown) return;
139
+ trace("service-lifecycle: unexpected beforeExit", diagnostics({ code }));
140
+ start();
141
+ void checkNow();
142
+ };
143
+
144
+ const beginShutdown = (reason: string): void => {
145
+ if (shuttingDown) return;
146
+ shuttingDown = true;
147
+ if (timer) {
148
+ try { clearIntervalImpl(timer); } catch { /* process 即将退出 */ }
149
+ timer = null;
150
+ }
151
+ trace("service-lifecycle: shutdown requested", { reason });
152
+ };
153
+
154
+ return { start, attachServer, checkNow, handleBeforeExit, beginShutdown };
155
+ }
156
+
3
157
  /**
4
158
  * ChatCCC 自己拉起替代进程时使用的内部标记。
5
159
  *
package/src/web-ui.ts CHANGED
@@ -2131,7 +2131,10 @@ export function setExtraApiHandler(handler: ExtraApiHandler): void {
2131
2131
  extraApiHandler = handler;
2132
2132
  }
2133
2133
 
2134
- export function startSetupMode(port: number, options: StartSetupModeOptions = {}): void {
2134
+ export function startSetupMode(
2135
+ port: number,
2136
+ options: StartSetupModeOptions = {},
2137
+ ): ReturnType<typeof createServer> {
2135
2138
  const router = createUiRouter();
2136
2139
  const server = createServer(router);
2137
2140
  setupHttpServer = server;
@@ -2167,4 +2170,5 @@ export function startSetupMode(port: number, options: StartSetupModeOptions = {}
2167
2170
  console.log("");
2168
2171
  if (options.openBrowser !== false) openWebUiInDefaultBrowser(port);
2169
2172
  });
2173
+ return server;
2170
2174
  }