chatccc 0.2.45 → 0.2.46

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.
@@ -1,123 +1,30 @@
1
- import { randomBytes } from "node:crypto";
2
1
  import type { IncomingMessage, ServerResponse } from "node:http";
3
2
  import { extname, isAbsolute, resolve } from "node:path";
4
3
  import { stat } from "node:fs/promises";
5
4
 
6
5
  import { getTenantAccessToken, sendImageReply, sendTextReply } from "./feishu-platform.ts";
7
6
  import { ts } from "./config.ts";
8
- import { logTrace } from "./trace.ts";
9
7
  import { readUtf8JsonBody } from "./agent-rpc-body.ts";
8
+ import { getAdapterForTool } from "./session.ts";
9
+ import { getChatsForSession } from "./session-chat-binding.ts";
10
10
 
11
11
  export const AGENT_SEND_IMAGE_PATH = "/api/agent/send-image";
12
12
 
13
- const DEFAULT_GRANT_TTL_MS = 30 * 60 * 1000;
14
13
  const MAX_REQUEST_BYTES = 64 * 1024;
15
14
  const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
16
15
  const ALLOWED_IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"]);
17
16
 
18
- export interface AgentImageGrant {
19
- token: string;
20
- url: string;
21
- chatId: string;
22
- sessionId: string;
23
- cwd: string;
24
- expiresAt: number;
25
- traceId: string;
26
- }
27
-
28
- interface CreateAgentImageGrantInput {
29
- chatId: string;
30
- sessionId: string;
31
- cwd: string;
32
- port: number;
33
- traceId?: string;
34
- nowMs?: number;
35
- ttlMs?: number;
36
- }
37
-
38
- const imageGrants = new Map<string, AgentImageGrant>();
39
-
40
- function createToken(): string {
41
- return randomBytes(24).toString("base64url");
42
- }
43
-
44
- export function createAgentImageGrant(input: CreateAgentImageGrantInput): AgentImageGrant {
45
- const now = input.nowMs ?? Date.now();
46
- const token = createToken();
47
- const grant: AgentImageGrant = {
48
- token,
49
- url: `http://127.0.0.1:${input.port}${AGENT_SEND_IMAGE_PATH}`,
50
- chatId: input.chatId,
51
- sessionId: input.sessionId,
52
- cwd: input.cwd,
53
- expiresAt: now + (input.ttlMs ?? DEFAULT_GRANT_TTL_MS),
54
- traceId: input.traceId ?? "",
55
- };
56
- imageGrants.set(token, grant);
57
- if (grant.traceId) logTrace(grant.traceId, "IMAGE_GRANT_CREATED", { sessionId: grant.sessionId, ttlMs: input.ttlMs ?? DEFAULT_GRANT_TTL_MS });
58
- return grant;
59
- }
60
-
61
- export function revokeAgentImageGrant(token: string): void {
62
- const grant = imageGrants.get(token);
63
- if (grant?.traceId) logTrace(grant.traceId, "IMAGE_GRANT_REVOKED", {});
64
- imageGrants.delete(token);
65
- }
66
-
67
- export function getAgentImageGrantFromAuthorization(
68
- authorization: string | undefined,
69
- nowMs = Date.now(),
70
- ): AgentImageGrant | null {
71
- const match = (authorization ?? "").match(/^Bearer\s+(.+)$/i);
72
- if (!match) return null;
73
- const grant = imageGrants.get(match[1]);
74
- if (!grant) return null;
75
- if (grant.expiresAt <= nowMs) {
76
- imageGrants.delete(match[1]);
77
- return null;
78
- }
79
- return grant;
80
- }
81
-
82
- export function buildAgentImageCapabilityPrompt(input: {
83
- url: string;
84
- token: string;
85
- cwd?: string;
86
- }): string {
87
- const lines = [
88
- "[ChatCCC local capability: send image]",
89
- "You can send an image to the current Feishu chat in real time by calling this local endpoint.",
90
- "",
91
- `POST ${input.url}`,
92
- `Authorization: Bearer ${input.token}`,
93
- "Content-Type: application/json; charset=utf-8",
94
- "",
95
- 'Body: {"path":"absolute image file path","caption":"optional caption"}',
96
- "",
97
- "Rules:",
98
- "- Save or choose a local image file first, then call the endpoint.",
99
- "- Use an absolute local file path. Do not call Feishu Open Platform directly.",
100
- "- Request body must be UTF-8 encoded JSON bytes; caption supports Unicode text, including Chinese.",
101
- "- Only call this endpoint when the user asked for an image or when an image is useful to the answer.",
102
- "[/ChatCCC local capability: send image]",
103
- ];
104
- if (input.cwd) {
105
- lines.splice(2, 0, `Current working directory: ${input.cwd}`);
106
- }
107
- return lines.join("\n");
108
- }
109
-
110
17
  function jsonReply(res: ServerResponse, status: number, data: unknown): void {
111
18
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
112
19
  res.end(JSON.stringify(data));
113
20
  }
114
21
 
115
- async function resolveAndValidateImagePath(grant: AgentImageGrant, rawPath: unknown): Promise<string> {
22
+ async function resolveAndValidateImagePath(cwd: string, rawPath: unknown): Promise<string> {
116
23
  if (typeof rawPath !== "string" || rawPath.trim() === "") {
117
24
  throw new Error("path must be a non-empty string");
118
25
  }
119
26
 
120
- const sessionRoot = resolve(grant.cwd);
27
+ const sessionRoot = resolve(cwd);
121
28
  const imagePath = isAbsolute(rawPath)
122
29
  ? resolve(rawPath)
123
30
  : resolve(sessionRoot, rawPath);
@@ -146,43 +53,101 @@ export async function handleAgentImageRequest(
146
53
  return true;
147
54
  }
148
55
 
149
- const grant = getAgentImageGrantFromAuthorization(req.headers.authorization);
150
- if (!grant) {
151
- jsonReply(res, 401, { ok: false, error: "Invalid or expired image-send token" });
56
+ let payload: { session_id?: unknown; path?: unknown; caption?: unknown };
57
+ try {
58
+ payload = await readUtf8JsonBody(req, MAX_REQUEST_BYTES);
59
+ } catch (err) {
60
+ jsonReply(res, 400, { ok: false, error: (err as Error).message || "Invalid JSON" });
61
+ return true;
62
+ }
63
+
64
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id : "";
65
+ if (!sessionId) {
66
+ jsonReply(res, 400, { ok: false, error: "Missing session_id" });
152
67
  return true;
153
68
  }
154
- const tid = grant.traceId;
155
69
 
156
- let payload: { path?: unknown; caption?: unknown };
70
+ // 获取 cwd 以校验路径
71
+ let cwd: string;
157
72
  try {
158
- payload = await readUtf8JsonBody(req, MAX_REQUEST_BYTES);
73
+ const { getSessionTool } = await import("./session.ts");
74
+ const tool = await getSessionTool(sessionId);
75
+ const adapter = getAdapterForTool(tool ?? "claude");
76
+ const info = await adapter.getSessionInfo(sessionId);
77
+ if (!info?.cwd) {
78
+ jsonReply(res, 400, { ok: false, error: "Cannot determine cwd for session" });
79
+ return true;
80
+ }
81
+ cwd = info.cwd;
159
82
  } catch (err) {
160
- if (tid) logTrace(tid, "IMAGE_REQ", { outcome: "invalid_json", error: (err as Error).message });
161
- jsonReply(res, 400, { ok: false, error: (err as Error).message || "Invalid JSON" });
83
+ jsonReply(res, 500, { ok: false, error: `Failed to get session info: ${(err as Error).message}` });
162
84
  return true;
163
85
  }
164
86
 
165
87
  let imagePath: string;
166
88
  try {
167
- imagePath = await resolveAndValidateImagePath(grant, payload.path);
89
+ imagePath = await resolveAndValidateImagePath(cwd, payload.path);
168
90
  } catch (err) {
169
- if (tid) logTrace(tid, "IMAGE_REQ", { outcome: "invalid_path", path: String(payload.path), error: (err as Error).message });
170
91
  jsonReply(res, 400, { ok: false, error: (err as Error).message });
171
92
  return true;
172
93
  }
173
94
 
95
+ // 发送到所有绑定该 session 的群
96
+ const chatIds = getChatsForSession(sessionId);
97
+ if (chatIds.length === 0) {
98
+ jsonReply(res, 404, { ok: false, error: "No chats bound to this session" });
99
+ return true;
100
+ }
101
+
174
102
  try {
175
103
  const token = await getTenantAccessToken();
176
104
  const caption = typeof payload.caption === "string" ? payload.caption.trim() : "";
177
- await sendImageReply(token, grant.chatId, imagePath);
178
- if (caption) await sendTextReply(token, grant.chatId, caption);
179
- if (tid) logTrace(tid, "IMAGE_REQ", { outcome: "sent", path: imagePath, hasCaption: !!caption });
180
- console.log(`[${ts()}] [AGENT-IMAGE] sent image chat=${grant.chatId} session=${grant.sessionId} path=${imagePath}`);
181
- jsonReply(res, 200, { ok: true });
105
+ let sentCount = 0;
106
+ for (const cid of chatIds) {
107
+ try {
108
+ await sendImageReply(token, cid, imagePath);
109
+ if (caption) await sendTextReply(token, cid, caption);
110
+ sentCount++;
111
+ } catch (err) {
112
+ console.error(`[${ts()}] [AGENT-IMAGE] send to ${cid} failed: ${(err as Error).message}`);
113
+ }
114
+ }
115
+ console.log(`[${ts()}] [AGENT-IMAGE] sent image to ${sentCount}/${chatIds.length} chats, session=${sessionId} path=${imagePath}`);
116
+ jsonReply(res, 200, { ok: true, sentTo: sentCount, total: chatIds.length });
182
117
  } catch (err) {
183
- if (tid) logTrace(tid, "IMAGE_REQ", { outcome: "send_failed", path: imagePath!, error: (err as Error).message });
184
118
  console.error(`[${ts()}] [AGENT-IMAGE] send failed: ${(err as Error).message}`);
185
119
  jsonReply(res, 500, { ok: false, error: (err as Error).message });
186
120
  }
187
121
  return true;
188
122
  }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // 兼容旧版 buildAgentImageCapabilityPrompt(供 im-skills 使用)
126
+ // ---------------------------------------------------------------------------
127
+
128
+ export function buildAgentImageCapabilityPrompt(input: {
129
+ url: string;
130
+ sessionId?: string;
131
+ cwd?: string;
132
+ }): string {
133
+ const lines = [
134
+ "[ChatCCC local capability: send image]",
135
+ "You can send an image to all chats bound to this session by calling this local endpoint.",
136
+ "",
137
+ `POST ${input.url}`,
138
+ "Content-Type: application/json; charset=utf-8",
139
+ "",
140
+ `Body: {"session_id":"${input.sessionId ?? "YOUR_SESSION_ID"}","path":"absolute image file path","caption":"optional caption"}`,
141
+ "",
142
+ "Rules:",
143
+ "- Save or choose a local image file first, then call the endpoint.",
144
+ "- Use an absolute local file path. Do not call Feishu Open Platform directly.",
145
+ "- Request body must be UTF-8 encoded JSON bytes; caption supports Unicode text, including Chinese.",
146
+ "- Only call this endpoint when the user asked for an image or when an image is useful to the answer.",
147
+ "[/ChatCCC local capability: send image]",
148
+ ];
149
+ if (input.cwd) {
150
+ lines.splice(2, 0, `Current working directory: ${input.cwd}`);
151
+ }
152
+ return lines.join("\n");
153
+ }
package/src/index.ts CHANGED
@@ -41,7 +41,6 @@ import {
41
41
  CLAUDE_EFFORT,
42
42
  CLAUDE_MODEL,
43
43
  GIT_TIMEOUT_MS,
44
- ALLOW_INTERRUPT,
45
44
  reloadConfigFromDisk,
46
45
  anthropicConfigDisplay,
47
46
  LOCAL_RELAY_URL,
@@ -84,17 +83,14 @@ import {
84
83
  reportPermissionResults,
85
84
  setPlatform,
86
85
  } from "./feishu-platform.ts";
87
- import { buildHelpCard, buildStatusCard, buildProgressCard, buildCdContent, buildCdCard, buildSessionsCard } from "./cards.ts";
88
- import { updateCardKitCard } from "./cardkit.ts";
86
+ import { buildHelpCard, buildStatusCard, buildCdContent, buildCdCard, buildSessionsCard } from "./cards.ts";
89
87
  import { handleAgentImageRequest } from "./agent-image-rpc.ts";
90
88
  import { handleAgentFileRequest } from "./agent-file-rpc.ts";
91
- import { handleAgentGrantsRequest } from "./agent-grants-rpc.ts";
92
89
  import { SimulatedPlatform, SIM_DEFAULT_CHAT_ID } from "./sim-platform.ts";
93
90
  import { setMessageHandler } from "./sim-store.ts";
94
91
  import { formatGitResult, gitResultHeaderTemplate, runGitCommand } from "./git-command.ts";
95
92
  import {
96
93
  MAX_PROCESSED,
97
- chatSessionMap,
98
94
  getSessionStatus,
99
95
  getAllSessionsStatus,
100
96
  initClaudeSession,
@@ -105,7 +101,18 @@ import {
105
101
  sessionInfoMap,
106
102
  recordSessionRegistry,
107
103
  getAdapterForTool,
104
+ stopSession,
105
+ loadSessionRegistryForBinding,
108
106
  } from "./session.ts";
107
+ import {
108
+ bindChatToSession,
109
+ unbindChatFromSession,
110
+ getChatsForSession,
111
+ isSessionRunning,
112
+ activePrompts,
113
+ rebuildSessionChatsFromRegistry,
114
+ } from "./session-chat-binding.ts";
115
+ import { fixStaleStreamStates } from "./stream-state.ts";
109
116
 
110
117
  export function cwdDisplayName(cwd: string): string {
111
118
  const trimmed = cwd.trim().replace(/[\\/]+$/, "");
@@ -462,6 +469,41 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
462
469
  const cwd = sessionCwd;
463
470
  const initialName = sessionChatName("新会话", cwd);
464
471
 
472
+ // 私聊:不创建群,直接绑定 session 到当前私聊
473
+ if (chatType === "p2p") {
474
+ bindChatToSession(sessionId, chatId);
475
+ sessionInfoMap.set(chatId, {
476
+ sessionId,
477
+ turnCount: 0,
478
+ lastContextTokens: 0,
479
+ startTime: Date.now(),
480
+ tool,
481
+ });
482
+ await setDefaultCwd(cwd, chatId);
483
+ await recordSessionRegistry({
484
+ chatId,
485
+ sessionId,
486
+ tool,
487
+ chatName: initialName,
488
+ turnCount: 0,
489
+ startTime: Date.now(),
490
+ running: false,
491
+ });
492
+ await sendCardReply(
493
+ freshToken, chatId, `${toolLabel} Session Ready`,
494
+ `这是你的 **${toolLabel}** 私聊会话。\n\n` +
495
+ `**Session ID:** ${sessionId}\n` +
496
+ `**工作目录:** \`${cwd}\`\n\n` +
497
+ `直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
498
+ `发送 **/sessions** 查看所有会话状态。\n` +
499
+ `发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5\`。`,
500
+ "green"
501
+ );
502
+ console.log(`[${ts()}] [NEW] P2P session created: ${sessionId} (${toolLabel})`);
503
+ logTrace(tid, "DONE", { outcome: "session_ready_p2p", chatId, sessionId, tool });
504
+ return;
505
+ }
506
+
465
507
  let newChatId: string;
466
508
  try {
467
509
  newChatId = await createGroupChat(freshToken, initialName, [openId]);
@@ -515,24 +557,62 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
515
557
  return;
516
558
  }
517
559
 
518
- // 私聊没有群 description,无法存储/获取 session ID,跳过群聊检测直接发 help card
560
+ // 检测会话上下文:群聊从 description 获取,私聊从 session-registry 获取
561
+ let sessionId: string | null = null;
562
+ let descriptionTool: string | null = null;
563
+ let toolLabel: string | null = null;
564
+ let chatInfo: Awaited<ReturnType<typeof getChatInfo>> | undefined;
565
+ let description: string | undefined;
566
+
519
567
  if (chatType !== "p2p") {
520
- try {
521
- const token = await getTenantAccessToken();
522
- const chatInfo = await getChatInfo(token, chatId);
523
- const description = chatInfo.description;
524
- const sessionInfo = extractSessionInfo(description);
525
-
526
- if (sessionInfo) {
527
- const sessionId = sessionInfo.sessionId;
528
- const descriptionTool = sessionInfo.tool;
529
- const toolLabel = toolDisplayName(descriptionTool);
530
- logTrace(tid, "BRANCH", { sessionId, tool: descriptionTool });
531
- console.log(`[${ts()}] [RESUME] ${toolLabel} session group detected, session=${sessionId} tool=${descriptionTool}`);
568
+ try {
569
+ const token = await getTenantAccessToken();
570
+ chatInfo = await getChatInfo(token, chatId);
571
+ description = chatInfo.description;
572
+ const sessionInfo = extractSessionInfo(description);
573
+ if (sessionInfo) {
574
+ sessionId = sessionInfo.sessionId;
575
+ descriptionTool = sessionInfo.tool;
576
+ toolLabel = toolDisplayName(descriptionTool);
577
+ }
578
+ } catch (err) {
579
+ logTrace(tid, "BRANCH", { reason: "get_chat_info_failed", error: (err as Error).message });
580
+ console.log(`[${ts()}] [INFO] Cannot get chat info for ${chatId}: ${(err as Error).message}`);
581
+ }
582
+ } else {
583
+ // 私聊:从 session-registry.json 获取绑定的 session
584
+ try {
585
+ const registry = await loadSessionRegistryForBinding();
586
+ const record = registry[chatId];
587
+ if (record && record.sessionId && record.tool) {
588
+ sessionId = record.sessionId;
589
+ descriptionTool = record.tool;
590
+ toolLabel = toolDisplayName(descriptionTool);
591
+ // 确保 sessionInfoMap 中有该私聊的信息
592
+ if (!sessionInfoMap.has(chatId)) {
593
+ sessionInfoMap.set(chatId, {
594
+ sessionId,
595
+ turnCount: record.turnCount ?? 0,
596
+ lastContextTokens: record.lastContextTokens ?? 0,
597
+ startTime: record.startTime ?? Date.now(),
598
+ tool: descriptionTool,
599
+ });
600
+ }
601
+ bindChatToSession(sessionId, chatId);
602
+ }
603
+ } catch (err) {
604
+ console.log(`[${ts()}] [INFO] Cannot load registry for p2p ${chatId}: ${(err as Error).message}`);
605
+ }
606
+ }
607
+
608
+ if (sessionId && descriptionTool && toolLabel) {
609
+ // 有会话上下文 — 路由到命令处理或 prompt
610
+ logTrace(tid, "BRANCH", { sessionId, tool: descriptionTool });
611
+ console.log(`[${ts()}] [RESUME] ${toolLabel} session group detected, session=${sessionId} tool=${descriptionTool}`);
532
612
 
533
613
  const freshToken = await getTenantAccessToken();
534
614
 
535
- if (isUntitledSessionChatName(chatInfo.name)) {
615
+ if (chatType !== "p2p" && isUntitledSessionChatName(chatInfo!.name) && !textLower.startsWith("/")) {
536
616
  const MAX_PREFIX = 10;
537
617
  const prefix = text.slice(0, MAX_PREFIX);
538
618
  const adapter = getAdapterForTool(descriptionTool);
@@ -540,9 +620,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
540
620
  const sessionCwd = info?.cwd ?? (await getDefaultCwd(chatId));
541
621
  const newName = sessionChatName(prefix, sessionCwd);
542
622
  try {
543
- await updateChatInfo(freshToken, chatId, newName, description);
623
+ await updateChatInfo(freshToken, chatId, newName, description!);
544
624
  console.log(`[${ts()}] [RENAME] First message → group renamed to "${newName}"`);
545
- await recordSessionRegistry({ chatId, chatName: newName }).catch(() => {});
625
+ await recordSessionRegistry({ chatId, sessionId, tool: descriptionTool, chatName: newName }).catch(() => {});
546
626
  } catch (err) {
547
627
  console.error(`[${ts()}] [RENAME] Failed: ${(err as Error).message}`);
548
628
  }
@@ -550,15 +630,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
550
630
 
551
631
  if (textLower === "/stop") {
552
632
  logTrace(tid, "BRANCH", { cmd: "/stop" });
553
- const cEntry = chatSessionMap.get(chatId);
554
- if (cEntry) {
555
- cEntry.stopped = true;
556
- if (cEntry.spinnerTimer) { clearInterval(cEntry.spinnerTimer); cEntry.spinnerTimer = null; }
557
- cEntry.close();
558
- const prevTs = lastMsgTimestamps.get(chatId);
559
- if (prevTs === undefined || cEntry.msgTimestamp > prevTs) {
560
- lastMsgTimestamps.set(chatId, cEntry.msgTimestamp);
561
- }
633
+ if (stopSession(sessionId)) {
562
634
  console.log(`[${ts()}] [STOP] User sent /stop, session=${sessionId}`);
563
635
  await sendTextReply(freshToken, chatId, "会话已停止。").catch(() => {});
564
636
  logTrace(tid, "DONE", { outcome: "stopped" });
@@ -572,8 +644,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
572
644
  if (textLower === "/status") {
573
645
  logTrace(tid, "BRANCH", { cmd: "/status" });
574
646
  const status = await getSessionStatus(chatId);
575
- const running = chatSessionMap.get(chatId);
576
- const isActive = running && !running.stopped;
647
+ const isActive = isSessionRunning(sessionId);
577
648
  const statusText = [
578
649
  `**群名:** ${status?.chatName || "—"}`,
579
650
  `**Session ID:** \`${status?.sessionId ?? sessionId}\``,
@@ -636,17 +707,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
636
707
  cwd = await getDefaultCwd(chatId);
637
708
  }
638
709
 
639
- const existing = chatSessionMap.get(chatId);
640
- if (existing) {
641
- existing.stopped = true;
642
- if (existing.spinnerTimer) { clearInterval(existing.spinnerTimer); existing.spinnerTimer = null; }
643
- existing.close();
644
- const prevTs = lastMsgTimestamps.get(chatId);
645
- if (prevTs === undefined || existing.msgTimestamp > prevTs) {
646
- lastMsgTimestamps.set(chatId, existing.msgTimestamp);
647
- }
648
- chatSessionMap.delete(chatId);
649
- }
710
+ // abort 旧 session,只解绑当前 chat
711
+ // session 如果正在跑,display loop 继续服务其他群
712
+ unbindChatFromSession(sessionId, chatId);
650
713
 
651
714
  let newSessionId: string;
652
715
  try {
@@ -658,6 +721,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
658
721
  return;
659
722
  }
660
723
 
724
+ // 绑定新 session
725
+ bindChatToSession(newSessionId, chatId);
726
+
661
727
  const descPrefix = sessionPrefixForTool(descriptionTool);
662
728
  const newName = sessionChatName("新会话", cwd);
663
729
  await updateChatInfo(freshToken, chatId, newName, `${descPrefix} ${newSessionId}`);
@@ -683,6 +749,12 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
683
749
 
684
750
  setChatAvatar(freshToken, chatId, descriptionTool, "new").catch(() => {});
685
751
 
752
+ // 如果新 session 有活跃 prompt,启动 display loop 让本群也能看到
753
+ if (isSessionRunning(newSessionId)) {
754
+ const { ensureDisplayLoop } = await import("./session.ts");
755
+ ensureDisplayLoop(newSessionId);
756
+ }
757
+
686
758
  await sendCardReply(
687
759
  freshToken, chatId, `${toolLabel} Session Reset`,
688
760
  `会话已重置为新的 **${toolLabel}** 会话。\n\n` +
@@ -720,16 +792,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
720
792
  }
721
793
  const target = ordered[index];
722
794
 
723
- const existing2 = chatSessionMap.get(chatId);
724
- if (existing2) {
725
- existing2.stopped = true;
726
- if (existing2.spinnerTimer) { clearInterval(existing2.spinnerTimer); existing2.spinnerTimer = null; }
727
- existing2.close();
728
- const prevTs2 = lastMsgTimestamps.get(chatId);
729
- if (prevTs2 === undefined || existing2.msgTimestamp > prevTs2) {
730
- lastMsgTimestamps.set(chatId, existing2.msgTimestamp);
731
- }
732
- chatSessionMap.delete(chatId);
795
+ // abort 当前 chat 的旧 session,只解绑再重新绑定
796
+ if (sessionId) {
797
+ unbindChatFromSession(sessionId, chatId);
733
798
  }
734
799
 
735
800
  const targetAdapter = getAdapterForTool(target.tool);
@@ -741,6 +806,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
741
806
  cwd2 = await getDefaultCwd(chatId);
742
807
  }
743
808
 
809
+ // 绑定到新 session
810
+ bindChatToSession(target.sessionId, chatId);
811
+
744
812
  const descPrefix2 = sessionPrefixForTool(target.tool);
745
813
  const newName2 = target.chatName || sessionChatName("新会话", cwd2);
746
814
  await updateChatInfo(freshToken, chatId, newName2, `${descPrefix2} ${target.sessionId}`);
@@ -763,14 +831,21 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
763
831
 
764
832
  setChatAvatar(freshToken, chatId, target.tool, "new").catch(() => {});
765
833
 
834
+ // 如果新 session 有活跃 prompt,加上 display loop
835
+ if (isSessionRunning(target.sessionId)) {
836
+ const { ensureDisplayLoop } = await import("./session.ts");
837
+ ensureDisplayLoop(target.sessionId);
838
+ }
839
+
766
840
  const targetToolLabel = toolDisplayName(target.tool);
841
+ const busyNote = isSessionRunning(target.sessionId) ? "\n\n⚠️ 该会话当前正在生成中,请等待完成后再发送消息。" : "";
767
842
  await sendCardReply(
768
843
  freshToken, chatId, `${targetToolLabel} Session Switched`,
769
844
  `已切换到 **${targetToolLabel}** 会话。\n\n` +
770
845
  `**序号:** ${index + 1}\n` +
771
846
  `**Session ID:** ${target.sessionId}\n` +
772
847
  `**工作目录:** \`${cwd2}\`\n\n` +
773
- `直接在这里发消息即可继续对话。`,
848
+ `直接在这里发消息即可继续对话。${busyNote}`,
774
849
  "green"
775
850
  );
776
851
 
@@ -832,50 +907,16 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
832
907
  return;
833
908
  }
834
909
 
835
- const existing = chatSessionMap.get(chatId);
836
- if (existing && !existing.stopped) {
837
- if (msgTimestamp <= existing.msgTimestamp) {
838
- logTrace(tid, "DONE", { outcome: "skip_old_message", msgTimestamp, existingTimestamp: existing.msgTimestamp });
839
- console.log(`[${ts()}] [SKIP] Older message (${msgTimestamp} <= ${existing.msgTimestamp}), ignoring`);
840
- return;
841
- }
842
-
843
- if (!ALLOW_INTERRUPT) {
844
- logTrace(tid, "BLOCKED", { outcome: "interrupt_disabled", sessionId });
845
- console.log(`[${ts()}] [BLOCKED] allowInterrupt=false, ignoring message during generation. Hint sent to user.`);
846
- await sendCardReply(
847
- freshToken, chatId, "生成中",
848
- "AI 正在生成回复中,Agent 不会遗忘当前进度,停止后仍可从断点继续。\n请先点击卡片上的「停止」按钮,再发送新消息。",
849
- "yellow"
850
- );
851
- return;
852
- }
853
-
854
- existing.stopped = true;
855
- if (existing.spinnerTimer) { clearInterval(existing.spinnerTimer); existing.spinnerTimer = null; }
856
- existing.close();
857
- const prevTs = lastMsgTimestamps.get(chatId);
858
- if (prevTs === undefined || existing.msgTimestamp > prevTs) {
859
- lastMsgTimestamps.set(chatId, existing.msgTimestamp);
860
- }
861
- chatSessionMap.delete(chatId);
862
- console.log(`[${ts()}] [INTERRUPT] New message arrived, cancelled previous session ${sessionId}`);
863
- logTrace(tid, "INTERRUPT", { oldSessionId: sessionId });
864
- if (existing.cardId) {
865
- while (existing.cardBusy) {
866
- await new Promise(r => setTimeout(r, 20));
867
- }
868
- const cardId = existing.cardId;
869
- const currentContent = existing.accumulatedContent;
870
- const interruptedCard = buildProgressCard(
871
- currentContent || "新问题已提交,当前回复已中断。",
872
- { showStop: false, headerTitle: "已中断", headerTemplate: "yellow" }
873
- );
874
- let nextSeq = existing.sequence + 1;
875
- await updateCardKitCard(freshToken, cardId, interruptedCard, nextSeq).catch((err) => {
876
- console.error(`[${ts()}] [INTERRUPT] CardKit update failed: ${(err as Error).message}`);
877
- });
878
- }
910
+ // 并发检查:同一 session 只能有一个活跃 prompt
911
+ if (isSessionRunning(sessionId)) {
912
+ logTrace(tid, "BLOCKED", { outcome: "session_busy", sessionId });
913
+ console.log(`[${ts()}] [BLOCKED] Session ${sessionId} is already generating, rejecting message from chat ${chatId}`);
914
+ await sendCardReply(
915
+ freshToken, chatId, "生成中",
916
+ "该会话正在生成回复中,请等待完成后再发送新消息。",
917
+ "yellow"
918
+ );
919
+ return;
879
920
  }
880
921
 
881
922
  try {
@@ -895,13 +936,8 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
895
936
  }
896
937
  return;
897
938
  }
898
- // 群聊但 description 中没有 session info → 也走 help card
899
- logTrace(tid, "BRANCH", { reason: "no_session_info_in_group" });
900
- } catch (err) {
901
- logTrace(tid, "BRANCH", { reason: "get_chat_info_failed", error: (err as Error).message });
902
- console.log(`[${ts()}] [INFO] Cannot get chat info for ${chatId}: ${(err as Error).message}`);
903
- }
904
- } // end if (chatType !== "p2p")
939
+
940
+ // 无会话上下文 help card
905
941
 
906
942
  // 私聊或群聊无 session info → 发送 help card
907
943
  logTrace(tid, "SEND", { method: "help_card", chatId });
@@ -1151,12 +1187,25 @@ async function startBotServiceCore(): Promise<void> {
1151
1187
  });
1152
1188
  } else {
1153
1189
  resetState();
1190
+ // 启动时修正残留的 running 状态并重建 session→chat 映射
1191
+ fixStaleStreamStates().then(async () => {
1192
+ const registry = await loadSessionRegistryForBinding();
1193
+ rebuildSessionChatsFromRegistry(registry);
1194
+ }).catch((err) => console.error(`[${ts()}] Init bindings failed: ${(err as Error).message}`));
1154
1195
 
1155
1196
  const wsClient = new WSClient({
1156
1197
  appId: APP_ID,
1157
1198
  appSecret: APP_SECRET,
1158
- onReady: () => { resetState(); },
1159
- onReconnected: () => { resetState(); },
1199
+ onReady: async () => {
1200
+ resetState();
1201
+ const registry = await loadSessionRegistryForBinding();
1202
+ rebuildSessionChatsFromRegistry(registry);
1203
+ },
1204
+ onReconnected: async () => {
1205
+ resetState();
1206
+ const registry = await loadSessionRegistryForBinding();
1207
+ rebuildSessionChatsFromRegistry(registry);
1208
+ },
1160
1209
  });
1161
1210
 
1162
1211
  console.log(`\n[启动 6/7] 飞书长连接:正在通过 SDK 建立 WebSocket …`);
@@ -1212,7 +1261,7 @@ async function main(): Promise<void> {
1212
1261
  setExtraApiHandler(async (req, res) => {
1213
1262
  const injected = await handleSimInjectMessage(req, res);
1214
1263
  if (injected) return true;
1215
- return (await handleAgentGrantsRequest(req, res)) || (await handleAgentImageRequest(req, res)) || (await handleAgentFileRequest(req, res));
1264
+ return (await handleAgentImageRequest(req, res)) || (await handleAgentFileRequest(req, res));
1216
1265
  });
1217
1266
 
1218
1267
  const simServer = createServer(createUiRouter());
@@ -1270,7 +1319,7 @@ async function main(): Promise<void> {
1270
1319
  });
1271
1320
  });
1272
1321
  setExtraApiHandler(async (req, res) => {
1273
- return (await handleAgentGrantsRequest(req, res)) || (await handleAgentImageRequest(req, res)) || (await handleAgentFileRequest(req, res));
1322
+ return (await handleAgentImageRequest(req, res)) || (await handleAgentFileRequest(req, res));
1274
1323
  });
1275
1324
 
1276
1325
  console.log(`[启动 2/7] 环境与凭证检查`);