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 +1 -1
- package/src/__tests__/cards.test.ts +9 -1
- package/src/__tests__/session.test.ts +118 -1
- package/src/cards.ts +2 -2
- package/src/index.ts +44 -16
- package/src/session-chat-binding.ts +23 -0
- package/src/session.ts +50 -8
- package/src/shared.ts +95 -68
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
|
298
|
-
return `**${i + 1}.** ${namePart}${
|
|
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
|
|
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(
|
|
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(
|
|
153
|
-
await writeFile(
|
|
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]
|
|
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
|
-
|
|
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
|
|
924
|
+
const registryEntries = Object.values(registry)
|
|
905
925
|
.filter((record) => record.chatId && record.sessionId && record.tool)
|
|
906
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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 中继服务器(同一端口、多客户端广播)
|