chatccc 0.2.49 → 0.2.50

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.49",
3
+ "version": "0.2.50",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -380,6 +380,14 @@ describe("buildSessionsCard", () => {
380
380
  expect(afterPrivateChat).not.toContain("(群聊)");
381
381
  });
382
382
 
383
+ it("shows chat id missing for sessions without a chat binding", () => {
384
+ const card = buildSessionsCard([
385
+ { sessionId: "orphan", chatName: "", chatId: "", active: true, turnCount: 0, elapsedSeconds: 3, model: "Claude Opus 4.7", tool: "claude" },
386
+ ]);
387
+ const content: string = JSON.parse(card).elements[0].text.content;
388
+ expect(content).toContain("chat id缺失");
389
+ });
390
+
383
391
  it("includes /session help text in non-empty card", () => {
384
392
  const card = buildSessionsCard([
385
393
  { sessionId: "abc123", chatName: "", chatId: "oc_abc123", active: false, turnCount: 2, elapsedSeconds: null, model: "Claude Opus 4.7", tool: "claude" },
@@ -446,4 +454,4 @@ describe("buildButtons", () => {
446
454
  const obj = result as any;
447
455
  expect(obj.actions[0].type).toBe("primary");
448
456
  });
449
- });
457
+ });
@@ -26,15 +26,26 @@ import {
26
26
  getSessionStatus,
27
27
  getAllSessionsStatus,
28
28
  recordSessionRegistry,
29
+ saveSessionTool,
29
30
  accumulateBlockContent,
30
31
  pickFinalReply,
31
32
  UNKNOWN_MODEL_PLACEHOLDER,
32
33
  _setSessionRegistryFileForTest,
33
34
  _resetSessionRegistryFileForTest,
35
+ _setSessionToolsFileForTest,
36
+ _resetSessionToolsFileForTest,
34
37
  _setAdapterForToolForTest,
35
38
  _clearAdapterCacheForTest,
36
39
  } from "../session.ts";
37
- import { activePrompts } from "../session-chat-binding.ts";
40
+ import {
41
+ activePrompts,
42
+ bindChatToSession,
43
+ unbindChatFromSession,
44
+ recordLastActiveChat,
45
+ getLastActiveChat,
46
+ pickDisplayChat,
47
+ resetBindingState,
48
+ } from "../session-chat-binding.ts";
38
49
  import type { AccumulatorState } from "../session.ts";
39
50
  import type { ToolAdapter, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
40
51
 
@@ -233,6 +244,7 @@ describe("getSessionStatus", () => {
233
244
 
234
245
  describe("getAllSessionsStatus", () => {
235
246
  let registryFile = "";
247
+ let sessionsFile = "";
236
248
 
237
249
  beforeEach(async () => {
238
250
  chatSessionMap.clear();
@@ -240,12 +252,15 @@ describe("getAllSessionsStatus", () => {
240
252
  activePrompts.clear();
241
253
  const dir = await mkdtemp(join(tmpdir(), "chatccc-session-registry-"));
242
254
  registryFile = join(dir, "session-registry.json");
255
+ sessionsFile = join(dir, "sessions.json");
243
256
  _setSessionRegistryFileForTest(registryFile);
257
+ _setSessionToolsFileForTest(sessionsFile);
244
258
  });
245
259
 
246
260
  afterEach(async () => {
247
261
  _clearAdapterCacheForTest();
248
262
  _resetSessionRegistryFileForTest();
263
+ _resetSessionToolsFileForTest();
249
264
  if (registryFile) {
250
265
  await rm(dirname(registryFile), { recursive: true, force: true });
251
266
  }
@@ -298,6 +313,32 @@ describe("getAllSessionsStatus", () => {
298
313
  expect(result[1].chatName).toBe("test-chat-2");
299
314
  });
300
315
 
316
+ it("includes recent sessions without a registry chat binding", async () => {
317
+ await saveSessionTool("orphan-session", "claude");
318
+ activePrompts.set("orphan-session", {
319
+ controller: new AbortController(),
320
+ stopped: false,
321
+ startTime: 3000,
322
+ });
323
+
324
+ const result = await getAllSessionsStatus();
325
+ expect(result).toHaveLength(1);
326
+ expect(result[0].sessionId).toBe("orphan-session");
327
+ expect(result[0].chatId).toBe("");
328
+ expect(result[0].active).toBe(true);
329
+ expect(result[0].turnCount).toBe(0);
330
+ });
331
+
332
+ it("orphan sessions preserve chatName from sessions.json", async () => {
333
+ await saveSessionTool("orphan-with-name", "claude", "帮我写代码-src");
334
+ const result = await getAllSessionsStatus();
335
+ expect(result).toHaveLength(1);
336
+ expect(result[0].sessionId).toBe("orphan-with-name");
337
+ expect(result[0].chatId).toBe("");
338
+ expect(result[0].chatName).toBe("帮我写代码-src");
339
+ expect(result[0].active).toBe(false);
340
+ });
341
+
301
342
  it("returns recent disk sessions by updatedAt desc, limited to 20", async () => {
302
343
  for (let i = 0; i < 25; i++) {
303
344
  await recordSessionRegistry({
@@ -635,3 +676,79 @@ describe("pickFinalReply", () => {
635
676
  ).toBe("");
636
677
  });
637
678
  });
679
+
680
+ // ---------------------------------------------------------------------------
681
+ // pickDisplayChat — display loop 选择推送目标 chat 的纯函数
682
+ // 关键不变量:仅当某 chatId 既是 session 的"最后活跃 chat"且仍然绑定到该
683
+ // session 时才返回。否则返回 undefined(loop 当作"无活跃群",不推送)。
684
+ // 这是为了修复 /newh 后旧 session 仍向已解绑群推卡片的 bug。
685
+ // ---------------------------------------------------------------------------
686
+
687
+ describe("pickDisplayChat", () => {
688
+ beforeEach(() => {
689
+ resetBindingState();
690
+ });
691
+
692
+ it("绑定 + 记录活跃后,返回该 chatId", () => {
693
+ bindChatToSession("sid-A", "chat_X");
694
+ recordLastActiveChat("sid-A", "chat_X");
695
+ expect(pickDisplayChat("sid-A")).toBe("chat_X");
696
+ });
697
+
698
+ it("从未记录过活跃 chat 时返回 undefined", () => {
699
+ bindChatToSession("sid-A", "chat_X");
700
+ expect(pickDisplayChat("sid-A")).toBeUndefined();
701
+ });
702
+
703
+ it("最后活跃 chat 已被解绑(如 /newh 场景)时返回 undefined,避免向已离开本 session 的群推送", () => {
704
+ bindChatToSession("sid-A", "chat_X");
705
+ recordLastActiveChat("sid-A", "chat_X");
706
+ // 模拟 /newh:chat_X 被解绑,转给新 session
707
+ unbindChatFromSession("sid-A", "chat_X");
708
+ expect(pickDisplayChat("sid-A")).toBeUndefined();
709
+ });
710
+
711
+ it("session 仍绑定其他 chat 但 lastActive 是已解绑 chat 时返回 undefined(不应回退到任意绑定)", () => {
712
+ // 多群共享 session 的极端情况:lastActive 指向 chat_X,但 chat_X 已解绑
713
+ bindChatToSession("sid-A", "chat_X");
714
+ bindChatToSession("sid-A", "chat_Y");
715
+ recordLastActiveChat("sid-A", "chat_X");
716
+ unbindChatFromSession("sid-A", "chat_X");
717
+ expect(pickDisplayChat("sid-A")).toBeUndefined();
718
+ });
719
+
720
+ it("session 绑定多个 chat 且 lastActive 是仍绑定的 chat 时正确返回", () => {
721
+ bindChatToSession("sid-A", "chat_X");
722
+ bindChatToSession("sid-A", "chat_Y");
723
+ recordLastActiveChat("sid-A", "chat_Y");
724
+ expect(pickDisplayChat("sid-A")).toBe("chat_Y");
725
+ });
726
+ });
727
+
728
+ // ---------------------------------------------------------------------------
729
+ // unbindChatFromSession — 双保险:清理 lastActiveChatMap[sessionId]
730
+ // 若该 sessionId 的 lastActive 正好指向被解绑的 chatId,则一并清掉,
731
+ // 防止后续逻辑(不仅 display loop)读到悬挂的旧记录。
732
+ // ---------------------------------------------------------------------------
733
+
734
+ describe("unbindChatFromSession 同步清理 lastActiveChatMap", () => {
735
+ beforeEach(() => {
736
+ resetBindingState();
737
+ });
738
+
739
+ it("解绑的 chat 正是 lastActive 时清掉记录", () => {
740
+ bindChatToSession("sid-A", "chat_X");
741
+ recordLastActiveChat("sid-A", "chat_X");
742
+ unbindChatFromSession("sid-A", "chat_X");
743
+ expect(getLastActiveChat("sid-A")).toBeUndefined();
744
+ });
745
+
746
+ it("解绑的 chat 不是 lastActive 时保留 lastActive", () => {
747
+ // chat_X 是 lastActive,解绑 chat_Y 不应影响
748
+ bindChatToSession("sid-A", "chat_X");
749
+ bindChatToSession("sid-A", "chat_Y");
750
+ recordLastActiveChat("sid-A", "chat_X");
751
+ unbindChatFromSession("sid-A", "chat_Y");
752
+ expect(getLastActiveChat("sid-A")).toBe("chat_X");
753
+ });
754
+ });
package/src/cards.ts CHANGED
@@ -294,8 +294,8 @@ export function buildSessionsCard(sessions: Array<{
294
294
  }
295
295
  const toolLabel = s.tool === "cursor" ? "Cursor" : s.tool === "codex" ? "Codex" : "Claude Code";
296
296
  const namePart = s.chatName ? `**${s.chatName}** ` : "";
297
- const groupTag = s.chatId && s.chatId.startsWith("oc_") ? " (群聊)" : "";
298
- return `**${i + 1}.** ${namePart}${groupTag} \`${shortId}\` ${status} | 工具: ${toolLabel} | 轮数: ${s.turnCount} | ${s.model}${extra}`;
297
+ const chatTag = !s.chatId ? " (chat id缺失)" : s.chatId.startsWith("oc_") ? " (群聊)" : "";
298
+ return `**${i + 1}.** ${namePart}${chatTag} \`${shortId}\` ${status} | 工具: ${toolLabel} | 轮数: ${s.turnCount} | ${s.model}${extra}`;
299
299
  };
300
300
 
301
301
  const lines: string[] = [`共 **${sessions.length}** 个会话:`, ""];
package/src/index.ts CHANGED
@@ -30,7 +30,7 @@ import { resolve, dirname } from "node:path";
30
30
  import { WSClient, EventDispatcher } from "@larksuiteoapi/node-sdk";
31
31
  import WebSocket from "ws";
32
32
 
33
- import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRelayListenPort, installCrashLogging } from "./shared.ts";
33
+ import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRelayListenPort, installCrashLogging, waitForPortFree } from "./shared.ts";
34
34
  import { createUiRouter, setExtraApiHandler, setReloadConfigHook, startSetupMode } from "./web-ui.ts";
35
35
  import { makeTraceId, logTrace } from "./trace.ts";
36
36
  import {
@@ -105,6 +105,7 @@ import {
105
105
  stopSession,
106
106
  loadSessionRegistryForBinding,
107
107
  removeSessionRegistryRecord,
108
+ saveSessionTool,
108
109
  } from "./session.ts";
109
110
  import {
110
111
  bindChatToSession,
@@ -493,6 +494,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
493
494
  startTime: Date.now(),
494
495
  running: false,
495
496
  });
497
+ await saveSessionTool(sessionId, tool, initialName);
496
498
  await sendCardReply(
497
499
  freshToken, chatId, `${toolLabel} Session Ready`,
498
500
  `这是你的 **${toolLabel}** 私聊会话。\n\n` +
@@ -542,6 +544,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
542
544
  startTime: Date.now(),
543
545
  running: false,
544
546
  });
547
+ await saveSessionTool(sessionId, tool, initialName);
545
548
 
546
549
  const adapter = getAdapterForTool(tool);
547
550
  await sendCardReply(
@@ -628,6 +631,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
628
631
  await updateChatInfo(freshToken, chatId, newName, description!);
629
632
  console.log(`[${ts()}] [RENAME] First message → group renamed to "${newName}"`);
630
633
  await recordSessionRegistry({ chatId, sessionId, tool: descriptionTool, chatName: newName }).catch(() => {});
634
+ await saveSessionTool(sessionId, descriptionTool, newName).catch(() => {});
631
635
  } catch (err) {
632
636
  console.error(`[${ts()}] [RENAME] Failed: ${(err as Error).message}`);
633
637
  }
@@ -754,6 +758,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
754
758
  startTime: Date.now(),
755
759
  running: false,
756
760
  });
761
+ await saveSessionTool(newSessionId, descriptionTool, newName);
757
762
 
758
763
  setChatAvatar(freshToken, chatId, descriptionTool, "new").catch(() => {});
759
764
 
@@ -867,6 +872,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
867
872
  chatName: newName2,
868
873
  running: false,
869
874
  });
875
+ await saveSessionTool(target.sessionId, target.tool, newName2);
870
876
 
871
877
  setChatAvatar(freshToken, chatId, target.tool, "new").catch(() => {});
872
878
 
@@ -1405,22 +1411,14 @@ async function main(): Promise<void> {
1405
1411
  // 凭证齐全:自己起 HTTP server(同时挂 UI router),再调 startBotService
1406
1412
  // 把 WS 中继和飞书 SDK 挂上去。
1407
1413
  appendStartupTrace("main: before freeRelayListenPort", { CHATCCC_PORT });
1408
- freeRelayListenPort(CHATCCC_PORT);
1409
- appendStartupTrace("main: after freeRelayListenPort", { CHATCCC_PORT });
1414
+ const killed = freeRelayListenPort(CHATCCC_PORT);
1415
+ appendStartupTrace("main: after freeRelayListenPort", { CHATCCC_PORT, killed });
1416
+ if (killed > 0) {
1417
+ await waitForPortFree(CHATCCC_PORT);
1418
+ appendStartupTrace("main: port free confirmed", { CHATCCC_PORT });
1419
+ }
1410
1420
  const httpServer = createServer(createUiRouter());
1411
- await new Promise<void>((resolveListen, rejectListen) => {
1412
- const onError = (err: NodeJS.ErrnoException): void => {
1413
- httpServer.removeListener("listening", onListening);
1414
- rejectListen(err);
1415
- };
1416
- const onListening = (): void => {
1417
- httpServer.removeListener("error", onError);
1418
- resolveListen();
1419
- };
1420
- httpServer.once("error", onError);
1421
- httpServer.once("listening", onListening);
1422
- httpServer.listen(CHATCCC_PORT, "127.0.0.1");
1423
- }).catch((err: NodeJS.ErrnoException) => {
1421
+ await listenWithRetry(httpServer, CHATCCC_PORT, "127.0.0.1").catch((err: NodeJS.ErrnoException) => {
1424
1422
  console.error(`\n[启动] 本地中继 WebSocket 监听失败:端口 ${CHATCCC_PORT}(${err.code ?? "?"} — ${err.message})`);
1425
1423
  console.error(
1426
1424
  " 处理建议: 关闭占用该端口的其它程序,或在 config.json 的 port 字段里改成其它未占用端口(如 18081)。"
@@ -1439,6 +1437,36 @@ async function main(): Promise<void> {
1439
1437
  installShutdownHandlers(httpServer);
1440
1438
  }
1441
1439
 
1440
+ /**
1441
+ * 带重试的 server.listen,Windows 端口释放有延迟时自动重试。
1442
+ */
1443
+ async function listenWithRetry(
1444
+ server: ReturnType<typeof createServer>,
1445
+ port: number,
1446
+ host: string,
1447
+ maxRetries = 3,
1448
+ ): Promise<void> {
1449
+ for (let i = 0; i < maxRetries; i++) {
1450
+ try {
1451
+ await new Promise<void>((resolve, reject) => {
1452
+ server.once("error", reject);
1453
+ server.once("listening", resolve);
1454
+ server.listen(port, host);
1455
+ });
1456
+ return;
1457
+ } catch (err: unknown) {
1458
+ const e = err as NodeJS.ErrnoException;
1459
+ server.removeAllListeners("listening");
1460
+ server.removeAllListeners("error");
1461
+ if (e.code === "EADDRINUSE" && i < maxRetries - 1) {
1462
+ await new Promise((r) => setTimeout(r, 500 * (i + 1)));
1463
+ continue;
1464
+ }
1465
+ throw err;
1466
+ }
1467
+ }
1468
+ }
1469
+
1442
1470
  /**
1443
1471
  * 注册 SIGINT / SIGTERM 清理:把 relay/setup 共用的 httpServer 关掉再退出。
1444
1472
  *
@@ -31,6 +31,13 @@ export function unbindChatFromSession(sessionId: string, chatId: string): void {
31
31
  chats.delete(chatId);
32
32
  if (chats.size === 0) sessionChatsMap.delete(sessionId);
33
33
  }
34
+ // 双保险:lastActiveChatMap 是 sessionId → 最后活跃 chatId 的快照,
35
+ // 若被解绑的 chatId 正好是该 session 的 lastActive(典型场景:/newh
36
+ // 把当前群从旧 session 改嫁到新 session),不清理会让旧 session 的
37
+ // display loop 继续向已离开的群推送卡片。
38
+ if (lastActiveChatMap.get(sessionId)?.chatId === chatId) {
39
+ lastActiveChatMap.delete(sessionId);
40
+ }
34
41
  }
35
42
 
36
43
  export function getChatsForSession(sessionId: string): string[] {
@@ -75,6 +82,22 @@ export function getLastActiveChat(sessionId: string): string | undefined {
75
82
  return lastActiveChatMap.get(sessionId)?.chatId;
76
83
  }
77
84
 
85
+ /**
86
+ * display loop 决定推送目标 chat 的纯函数:
87
+ * 仅当 lastActiveChat 仍然绑定到该 session 时才返回,否则返回 undefined。
88
+ *
89
+ * 修复 bug:用户 `/newh` 把当前群从旧 session 改嫁到新 session 后,旧
90
+ * session 的 lastActiveChatMap 残留旧 chatId 快照,display loop 会继续
91
+ * 在该群创建/更新卡片。这里通过交叉验证 sessionChatsMap 把这种悬挂记录
92
+ * 排除掉——loop 拿到 undefined 就走"无活跃群"分支自然停推。
93
+ */
94
+ export function pickDisplayChat(sessionId: string): string | undefined {
95
+ const candidate = lastActiveChatMap.get(sessionId)?.chatId;
96
+ if (!candidate) return undefined;
97
+ const chats = sessionChatsMap.get(sessionId);
98
+ return chats?.has(candidate) ? candidate : undefined;
99
+ }
100
+
78
101
  // ---------------------------------------------------------------------------
79
102
  // displayCards: chatId → 展示卡片状态(display loop 用)
80
103
  // ---------------------------------------------------------------------------
package/src/session.ts CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  rebuildSessionChatsFromRegistry,
46
46
  recordLastActiveChat,
47
47
  getLastActiveChat,
48
+ pickDisplayChat,
48
49
  } from "./session-chat-binding.ts";
49
50
 
50
51
  // ---------------------------------------------------------------------------
@@ -136,11 +137,14 @@ export function getAdapterForTool(tool: string): ToolAdapter {
136
137
  interface SessionToolRecord {
137
138
  tool: string;
138
139
  createdAt: number;
140
+ chatName?: string;
139
141
  }
140
142
 
143
+ let sessionToolsFile = SESSIONS_FILE;
144
+
141
145
  async function loadSessionTools(): Promise<Record<string, SessionToolRecord>> {
142
146
  try {
143
- const raw = await readFile(SESSIONS_FILE, "utf-8");
147
+ const raw = await readFile(sessionToolsFile, "utf-8");
144
148
  return JSON.parse(raw);
145
149
  } catch {
146
150
  return {};
@@ -149,17 +153,23 @@ async function loadSessionTools(): Promise<Record<string, SessionToolRecord>> {
149
153
 
150
154
  async function saveSessionTools(data: Record<string, SessionToolRecord>): Promise<void> {
151
155
  try {
152
- await mkdir(dirname(SESSIONS_FILE), { recursive: true });
153
- await writeFile(SESSIONS_FILE, JSON.stringify(data, null, 2), "utf-8");
156
+ await mkdir(dirname(sessionToolsFile), { recursive: true });
157
+ await writeFile(sessionToolsFile, JSON.stringify(data, null, 2), "utf-8");
154
158
  } catch (err) {
155
159
  console.error(`[${ts()}] Failed to save sessions.json: ${(err as Error).message}`);
156
160
  fileLog.flush();
157
161
  }
158
162
  }
159
163
 
160
- export async function saveSessionTool(sessionId: string, tool: string): Promise<void> {
164
+ export async function saveSessionTool(sessionId: string, tool: string, chatName?: string): Promise<void> {
161
165
  const data = await loadSessionTools();
162
- data[sessionId] = { tool, createdAt: Date.now() };
166
+ const existing = data[sessionId];
167
+ const mergedChatName = chatName ?? existing?.chatName;
168
+ data[sessionId] = {
169
+ tool,
170
+ createdAt: existing?.createdAt ?? Date.now(),
171
+ ...(mergedChatName ? { chatName: mergedChatName } : {}),
172
+ };
163
173
  await saveSessionTools(data);
164
174
  }
165
175
 
@@ -169,6 +179,14 @@ export async function getSessionTool(sessionId: string): Promise<string | null>
169
179
  return record?.tool ?? null;
170
180
  }
171
181
 
182
+ export function _setSessionToolsFileForTest(filePath: string): void {
183
+ sessionToolsFile = filePath;
184
+ }
185
+
186
+ export function _resetSessionToolsFileForTest(): void {
187
+ sessionToolsFile = SESSIONS_FILE;
188
+ }
189
+
172
190
  // ---------------------------------------------------------------------------
173
191
  // Conversation session registry for /sessions
174
192
  // ---------------------------------------------------------------------------
@@ -658,7 +676,9 @@ export function ensureDisplayLoop(sessionId: string): void {
658
676
  const state = await readStreamState(sessionId);
659
677
  if (!state) return;
660
678
 
661
- const chatId = getLastActiveChat(sessionId);
679
+ // pickDisplayChat lastActiveChat 已与本 session 解绑时返回 undefined,
680
+ // 避免 /newh 等改嫁场景下旧 session 仍向已离开群推卡片。
681
+ const chatId = pickDisplayChat(sessionId);
662
682
  if (!chatId) {
663
683
  // 无活跃群,若 session 已结束则停止 loop
664
684
  if (state.status !== "running") {
@@ -901,9 +921,31 @@ export interface SessionsListEntry {
901
921
 
902
922
  export async function getAllSessionsStatus(): Promise<SessionsListEntry[]> {
903
923
  const registry = await loadSessionRegistry();
904
- const entries = Object.values(registry)
924
+ const registryEntries = Object.values(registry)
905
925
  .filter((record) => record.chatId && record.sessionId && record.tool)
906
- .sort((a, b) => b.updatedAt - a.updatedAt)
926
+ .map((record) => ({ ...record, sortTime: record.updatedAt }));
927
+ const registeredSessionIds = new Set(registryEntries.map((record) => record.sessionId));
928
+ const sessionTools = await loadSessionTools();
929
+ const orphanEntries = Object.entries(sessionTools)
930
+ .filter(([sessionId, record]) => sessionId && record?.tool && !registeredSessionIds.has(sessionId))
931
+ .map(([sessionId, record]) => {
932
+ const createdAt = Number.isFinite(record.createdAt) ? record.createdAt : 0;
933
+ const active = activePrompts.get(sessionId);
934
+ return {
935
+ chatId: "",
936
+ sessionId,
937
+ tool: record.tool,
938
+ chatName: record.chatName ?? "",
939
+ turnCount: 0,
940
+ lastContextTokens: 0,
941
+ startTime: active?.startTime ?? createdAt,
942
+ updatedAt: createdAt,
943
+ running: false,
944
+ sortTime: active?.startTime ?? createdAt,
945
+ };
946
+ });
947
+ const entries = [...registryEntries, ...orphanEntries]
948
+ .sort((a, b) => b.sortTime - a.sortTime)
907
949
  .slice(0, 20);
908
950
  // 并行解析每个 session 的 model/effort(cursor 涉及异步 store IO)
909
951
  return Promise.all(
package/src/shared.ts CHANGED
@@ -1,17 +1,17 @@
1
1
  import { execSync } from "node:child_process";
2
- import {
3
- appendFileSync,
4
- existsSync,
5
- mkdirSync,
6
- readFileSync,
7
- unlinkSync,
8
- writeFileSync,
9
- } from "node:fs";
10
- import { createServer } from "node:http";
11
- import { homedir } from "node:os";
12
- import { join } from "node:path";
13
- import { inspect } from "node:util";
14
- import { WebSocketServer, WebSocket } from "ws";
2
+ import {
3
+ appendFileSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ unlinkSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { createServer } from "node:http";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { inspect } from "node:util";
14
+ import { WebSocketServer, WebSocket } from "ws";
15
15
 
16
16
  import { printServiceDidNotStart } from "./exit-banner.ts";
17
17
 
@@ -42,8 +42,11 @@ export function appendStartupTrace(message: string, extra?: Record<string, unkno
42
42
  // 进程树:当前进程及其所有祖先(用于避免 taskkill /T 误杀启动链)
43
43
  // ---------------------------------------------------------------------------
44
44
 
45
- /** 自当前 PID 沿父链上溯直到系统进程,包含自身 */
45
+ let _cachedAncestorSet: Set<number> | undefined;
46
+
47
+ /** 自当前 PID 沿父链上溯直到系统进程,包含自身。结果会被缓存,同一进程内多次调用复用。 */
46
48
  export function getProcessAncestorPidSet(): Set<number> {
49
+ if (_cachedAncestorSet) return _cachedAncestorSet;
47
50
  const ancestors = new Set<number>();
48
51
  let cur: number = process.pid;
49
52
  const maxHops = 48;
@@ -74,6 +77,7 @@ export function getProcessAncestorPidSet(): Set<number> {
74
77
  if (parent === undefined || Number.isNaN(parent) || parent === cur) break;
75
78
  cur = parent;
76
79
  }
80
+ _cachedAncestorSet = ancestors;
77
81
  return ancestors;
78
82
  }
79
83
 
@@ -137,8 +141,9 @@ function collectListeningPidsOnPortWindows(port: number, netstatOut: string): nu
137
141
  return out;
138
142
  }
139
143
 
140
- /** 在 createRelayServer 之前调用:清掉本机该端口上仍在监听的旧进程(不杀当前进程树祖先) */
141
- export function freeRelayListenPort(port: number): void {
144
+ /** 在 createRelayServer 之前调用:清掉本机该端口上仍在监听的旧进程(不杀当前进程树祖先)。
145
+ * 返回实际 kill 的进程数。 */
146
+ export function freeRelayListenPort(port: number): number {
142
147
  const ancestors = getProcessAncestorPidSet();
143
148
  appendStartupTrace("freeRelayListenPort: begin", {
144
149
  port,
@@ -147,9 +152,10 @@ export function freeRelayListenPort(port: number): void {
147
152
 
148
153
  if (process.platform !== "win32") {
149
154
  appendStartupTrace("freeRelayListenPort: skip (non-Windows)", { port });
150
- return;
155
+ return 0;
151
156
  }
152
157
 
158
+ let killed = 0;
153
159
  try {
154
160
  const portOut = execSync(`netstat -ano | findstr :${port}`, { encoding: "utf8", windowsHide: true });
155
161
  const pids = collectListeningPidsOnPortWindows(port, portOut);
@@ -159,11 +165,32 @@ export function freeRelayListenPort(port: number): void {
159
165
  console.log(`[KILL] Free port ${port}: killing LISTENING/UDP holder PID ${pidNum}...`);
160
166
  appendStartupTrace("freeRelayListenPort: killByPid", { port, pid: pidNum });
161
167
  killByPid(pidNum);
168
+ killed++;
162
169
  }
163
170
  } catch {
164
171
  appendStartupTrace("freeRelayListenPort: netstat/findstr (no rows or error)", { port });
165
172
  }
166
- appendStartupTrace("freeRelayListenPort: end", { port });
173
+ appendStartupTrace("freeRelayListenPort: end", { port, killed });
174
+ return killed;
175
+ }
176
+
177
+ /**
178
+ * 轮询等待端口释放(Windows 下 taskkill 后端口可能不会立即释放)。
179
+ * 在 freeRelayListenPort kill 了进程后调用,确认端口真正空闲再 listen。
180
+ */
181
+ export async function waitForPortFree(port: number, timeoutMs = 3000): Promise<void> {
182
+ if (process.platform !== "win32") return;
183
+ const deadline = Date.now() + timeoutMs;
184
+ while (Date.now() < deadline) {
185
+ try {
186
+ const out = execSync(`netstat -ano | findstr :${port}`, { encoding: "utf8", windowsHide: true });
187
+ const pids = collectListeningPidsOnPortWindows(port, out);
188
+ if (pids.length === 0) return;
189
+ } catch {
190
+ return;
191
+ }
192
+ await new Promise((r) => setTimeout(r, 200));
193
+ }
167
194
  }
168
195
 
169
196
  // ---------------------------------------------------------------------------
@@ -355,56 +382,56 @@ export function installCrashLogging(opts: CrashHandlersOptions = {}): InstallCra
355
382
  // 文件日志:同时输出到控制台和日志文件
356
383
  // ---------------------------------------------------------------------------
357
384
 
358
- export function setupFileLogging(logDir: string, prefix: string): { logPath: string; flush: () => void } {
359
- mkdirSync(logDir, { recursive: true });
360
- const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
361
- const logPath = join(logDir, `${prefix}-${ts}.log`);
362
- writeFileSync(logPath, "", { flag: "a", encoding: "utf8" });
363
- const origConsoleLog = console.log.bind(console);
364
- const origConsoleError = console.error.bind(console);
365
- const formatArg = (arg: unknown): string => {
366
- if (typeof arg === "string") return arg;
367
- if (arg instanceof Error) return arg.stack ?? arg.message;
368
- return inspect(arg, { depth: 6, breakLength: Infinity, maxArrayLength: 200 });
369
- };
370
- const writeLine = (level: string, args: unknown[]) => {
371
- try {
372
- const line = args.map(formatArg).join(" ");
373
- appendFileSync(logPath, `[${new Date().toISOString()}] [${level}] ${line}\n`, "utf8");
374
- } catch (err) {
375
- try {
376
- appendStartupTrace("fileLog write failed", { level, error: err instanceof Error ? err.message : String(err) });
377
- } catch {
378
- // 日志系统自身不能影响主流程
379
- }
380
- }
381
- };
382
- console.log = (...args: unknown[]) => {
383
- writeLine("LOG", args);
384
- try {
385
- origConsoleLog(...args);
386
- } catch {
387
- // 控制台输出失败也不能拖垮服务
388
- }
389
- };
390
- console.error = (...args: unknown[]) => {
391
- writeLine("ERR", args);
392
- try {
393
- origConsoleError(...args);
394
- } catch {
395
- // 控制台输出失败也不能拖垮服务
396
- }
397
- };
398
- const flush = () => {
399
- try {
400
- appendFileSync(logPath, "", "utf8");
401
- } catch (err) {
402
- appendStartupTrace("fileLog flush failed", { error: err instanceof Error ? err.message : String(err) });
403
- }
404
- };
405
- origConsoleLog(`Log file: ${logPath}`);
406
- return { logPath, flush };
407
- }
385
+ export function setupFileLogging(logDir: string, prefix: string): { logPath: string; flush: () => void } {
386
+ mkdirSync(logDir, { recursive: true });
387
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
388
+ const logPath = join(logDir, `${prefix}-${ts}.log`);
389
+ writeFileSync(logPath, "", { flag: "a", encoding: "utf8" });
390
+ const origConsoleLog = console.log.bind(console);
391
+ const origConsoleError = console.error.bind(console);
392
+ const formatArg = (arg: unknown): string => {
393
+ if (typeof arg === "string") return arg;
394
+ if (arg instanceof Error) return arg.stack ?? arg.message;
395
+ return inspect(arg, { depth: 6, breakLength: Infinity, maxArrayLength: 200 });
396
+ };
397
+ const writeLine = (level: string, args: unknown[]) => {
398
+ try {
399
+ const line = args.map(formatArg).join(" ");
400
+ appendFileSync(logPath, `[${new Date().toISOString()}] [${level}] ${line}\n`, "utf8");
401
+ } catch (err) {
402
+ try {
403
+ appendStartupTrace("fileLog write failed", { level, error: err instanceof Error ? err.message : String(err) });
404
+ } catch {
405
+ // 日志系统自身不能影响主流程
406
+ }
407
+ }
408
+ };
409
+ console.log = (...args: unknown[]) => {
410
+ writeLine("LOG", args);
411
+ try {
412
+ origConsoleLog(...args);
413
+ } catch {
414
+ // 控制台输出失败也不能拖垮服务
415
+ }
416
+ };
417
+ console.error = (...args: unknown[]) => {
418
+ writeLine("ERR", args);
419
+ try {
420
+ origConsoleError(...args);
421
+ } catch {
422
+ // 控制台输出失败也不能拖垮服务
423
+ }
424
+ };
425
+ const flush = () => {
426
+ try {
427
+ appendFileSync(logPath, "", "utf8");
428
+ } catch (err) {
429
+ appendStartupTrace("fileLog flush failed", { error: err instanceof Error ? err.message : String(err) });
430
+ }
431
+ };
432
+ origConsoleLog(`Log file: ${logPath}`);
433
+ return { logPath, flush };
434
+ }
408
435
 
409
436
  // ---------------------------------------------------------------------------
410
437
  // 本地 WebSocket 中继服务器(同一端口、多客户端广播)