chatccc 0.2.45 → 0.2.47

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,19 @@ 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
+ displayCards,
115
+ } from "./session-chat-binding.ts";
116
+ import { fixStaleStreamStates } from "./stream-state.ts";
109
117
 
110
118
  export function cwdDisplayName(cwd: string): string {
111
119
  const trimmed = cwd.trim().replace(/[\\/]+$/, "");
@@ -462,6 +470,41 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
462
470
  const cwd = sessionCwd;
463
471
  const initialName = sessionChatName("新会话", cwd);
464
472
 
473
+ // 私聊:不创建群,直接绑定 session 到当前私聊
474
+ if (chatType === "p2p") {
475
+ bindChatToSession(sessionId, chatId);
476
+ sessionInfoMap.set(chatId, {
477
+ sessionId,
478
+ turnCount: 0,
479
+ lastContextTokens: 0,
480
+ startTime: Date.now(),
481
+ tool,
482
+ });
483
+ await setDefaultCwd(cwd, chatId);
484
+ await recordSessionRegistry({
485
+ chatId,
486
+ sessionId,
487
+ tool,
488
+ chatName: initialName,
489
+ turnCount: 0,
490
+ startTime: Date.now(),
491
+ running: false,
492
+ });
493
+ await sendCardReply(
494
+ freshToken, chatId, `${toolLabel} Session Ready`,
495
+ `这是你的 **${toolLabel}** 私聊会话。\n\n` +
496
+ `**Session ID:** ${sessionId}\n` +
497
+ `**工作目录:** \`${cwd}\`\n\n` +
498
+ `直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
499
+ `发送 **/sessions** 查看所有会话状态。\n` +
500
+ `发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5\`。`,
501
+ "green"
502
+ );
503
+ console.log(`[${ts()}] [NEW] P2P session created: ${sessionId} (${toolLabel})`);
504
+ logTrace(tid, "DONE", { outcome: "session_ready_p2p", chatId, sessionId, tool });
505
+ return;
506
+ }
507
+
465
508
  let newChatId: string;
466
509
  try {
467
510
  newChatId = await createGroupChat(freshToken, initialName, [openId]);
@@ -486,6 +529,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
486
529
 
487
530
  // 让新群的默认工作目录继承当前会话的 cwd
488
531
  await setDefaultCwd(cwd, newChatId);
532
+ bindChatToSession(sessionId, newChatId);
489
533
  await recordSessionRegistry({
490
534
  chatId: newChatId,
491
535
  sessionId,
@@ -515,24 +559,62 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
515
559
  return;
516
560
  }
517
561
 
518
- // 私聊没有群 description,无法存储/获取 session ID,跳过群聊检测直接发 help card
562
+ // 检测会话上下文:群聊从 description 获取,私聊从 session-registry 获取
563
+ let sessionId: string | null = null;
564
+ let descriptionTool: string | null = null;
565
+ let toolLabel: string | null = null;
566
+ let chatInfo: Awaited<ReturnType<typeof getChatInfo>> | undefined;
567
+ let description: string | undefined;
568
+
519
569
  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}`);
570
+ try {
571
+ const token = await getTenantAccessToken();
572
+ chatInfo = await getChatInfo(token, chatId);
573
+ description = chatInfo.description;
574
+ const sessionInfo = extractSessionInfo(description);
575
+ if (sessionInfo) {
576
+ sessionId = sessionInfo.sessionId;
577
+ descriptionTool = sessionInfo.tool;
578
+ toolLabel = toolDisplayName(descriptionTool);
579
+ }
580
+ } catch (err) {
581
+ logTrace(tid, "BRANCH", { reason: "get_chat_info_failed", error: (err as Error).message });
582
+ console.log(`[${ts()}] [INFO] Cannot get chat info for ${chatId}: ${(err as Error).message}`);
583
+ }
584
+ } else {
585
+ // 私聊:从 session-registry.json 获取绑定的 session
586
+ try {
587
+ const registry = await loadSessionRegistryForBinding();
588
+ const record = registry[chatId];
589
+ if (record && record.sessionId && record.tool) {
590
+ sessionId = record.sessionId;
591
+ descriptionTool = record.tool;
592
+ toolLabel = toolDisplayName(descriptionTool);
593
+ // 确保 sessionInfoMap 中有该私聊的信息
594
+ if (!sessionInfoMap.has(chatId)) {
595
+ sessionInfoMap.set(chatId, {
596
+ sessionId,
597
+ turnCount: record.turnCount ?? 0,
598
+ lastContextTokens: record.lastContextTokens ?? 0,
599
+ startTime: record.startTime ?? Date.now(),
600
+ tool: descriptionTool,
601
+ });
602
+ }
603
+ bindChatToSession(sessionId, chatId);
604
+ }
605
+ } catch (err) {
606
+ console.log(`[${ts()}] [INFO] Cannot load registry for p2p ${chatId}: ${(err as Error).message}`);
607
+ }
608
+ }
609
+
610
+ if (sessionId && descriptionTool && toolLabel) {
611
+ // 有会话上下文 — 路由到命令处理或 prompt
612
+ logTrace(tid, "BRANCH", { sessionId, tool: descriptionTool });
613
+ console.log(`[${ts()}] [RESUME] ${toolLabel} session group detected, session=${sessionId} tool=${descriptionTool}`);
532
614
 
533
615
  const freshToken = await getTenantAccessToken();
534
616
 
535
- if (isUntitledSessionChatName(chatInfo.name)) {
617
+ if (chatType !== "p2p" && isUntitledSessionChatName(chatInfo!.name) && !textLower.startsWith("/")) {
536
618
  const MAX_PREFIX = 10;
537
619
  const prefix = text.slice(0, MAX_PREFIX);
538
620
  const adapter = getAdapterForTool(descriptionTool);
@@ -540,9 +622,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
540
622
  const sessionCwd = info?.cwd ?? (await getDefaultCwd(chatId));
541
623
  const newName = sessionChatName(prefix, sessionCwd);
542
624
  try {
543
- await updateChatInfo(freshToken, chatId, newName, description);
625
+ await updateChatInfo(freshToken, chatId, newName, description!);
544
626
  console.log(`[${ts()}] [RENAME] First message → group renamed to "${newName}"`);
545
- await recordSessionRegistry({ chatId, chatName: newName }).catch(() => {});
627
+ await recordSessionRegistry({ chatId, sessionId, tool: descriptionTool, chatName: newName }).catch(() => {});
546
628
  } catch (err) {
547
629
  console.error(`[${ts()}] [RENAME] Failed: ${(err as Error).message}`);
548
630
  }
@@ -550,15 +632,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
550
632
 
551
633
  if (textLower === "/stop") {
552
634
  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
- }
635
+ if (stopSession(sessionId)) {
562
636
  console.log(`[${ts()}] [STOP] User sent /stop, session=${sessionId}`);
563
637
  await sendTextReply(freshToken, chatId, "会话已停止。").catch(() => {});
564
638
  logTrace(tid, "DONE", { outcome: "stopped" });
@@ -572,8 +646,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
572
646
  if (textLower === "/status") {
573
647
  logTrace(tid, "BRANCH", { cmd: "/status" });
574
648
  const status = await getSessionStatus(chatId);
575
- const running = chatSessionMap.get(chatId);
576
- const isActive = running && !running.stopped;
649
+ const isActive = isSessionRunning(sessionId);
577
650
  const statusText = [
578
651
  `**群名:** ${status?.chatName || "—"}`,
579
652
  `**Session ID:** \`${status?.sessionId ?? sessionId}\``,
@@ -636,17 +709,10 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
636
709
  cwd = await getDefaultCwd(chatId);
637
710
  }
638
711
 
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
- }
712
+ // abort 旧 session,只解绑当前 chat
713
+ // session 如果正在跑,display loop 继续服务其他群
714
+ unbindChatFromSession(sessionId, chatId);
715
+ displayCards.delete(chatId);
650
716
 
651
717
  let newSessionId: string;
652
718
  try {
@@ -658,6 +724,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
658
724
  return;
659
725
  }
660
726
 
727
+ // 绑定新 session
728
+ bindChatToSession(newSessionId, chatId);
729
+
661
730
  const descPrefix = sessionPrefixForTool(descriptionTool);
662
731
  const newName = sessionChatName("新会话", cwd);
663
732
  await updateChatInfo(freshToken, chatId, newName, `${descPrefix} ${newSessionId}`);
@@ -683,6 +752,12 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
683
752
 
684
753
  setChatAvatar(freshToken, chatId, descriptionTool, "new").catch(() => {});
685
754
 
755
+ // 如果新 session 有活跃 prompt,启动 display loop 让本群也能看到
756
+ if (isSessionRunning(newSessionId)) {
757
+ const { ensureDisplayLoop } = await import("./session.ts");
758
+ ensureDisplayLoop(newSessionId);
759
+ }
760
+
686
761
  await sendCardReply(
687
762
  freshToken, chatId, `${toolLabel} Session Reset`,
688
763
  `会话已重置为新的 **${toolLabel}** 会话。\n\n` +
@@ -720,16 +795,10 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
720
795
  }
721
796
  const target = ordered[index];
722
797
 
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);
798
+ // abort 当前 chat 的旧 session,只解绑再重新绑定
799
+ if (sessionId) {
800
+ unbindChatFromSession(sessionId, chatId);
801
+ displayCards.delete(chatId);
733
802
  }
734
803
 
735
804
  const targetAdapter = getAdapterForTool(target.tool);
@@ -741,6 +810,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
741
810
  cwd2 = await getDefaultCwd(chatId);
742
811
  }
743
812
 
813
+ // 绑定到新 session
814
+ bindChatToSession(target.sessionId, chatId);
815
+
744
816
  const descPrefix2 = sessionPrefixForTool(target.tool);
745
817
  const newName2 = target.chatName || sessionChatName("新会话", cwd2);
746
818
  await updateChatInfo(freshToken, chatId, newName2, `${descPrefix2} ${target.sessionId}`);
@@ -763,14 +835,21 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
763
835
 
764
836
  setChatAvatar(freshToken, chatId, target.tool, "new").catch(() => {});
765
837
 
838
+ // 如果新 session 有活跃 prompt,加上 display loop
839
+ if (isSessionRunning(target.sessionId)) {
840
+ const { ensureDisplayLoop } = await import("./session.ts");
841
+ ensureDisplayLoop(target.sessionId);
842
+ }
843
+
766
844
  const targetToolLabel = toolDisplayName(target.tool);
845
+ const busyNote = isSessionRunning(target.sessionId) ? "\n\n⚠️ 该会话当前正在生成中,请等待完成后再发送消息。" : "";
767
846
  await sendCardReply(
768
847
  freshToken, chatId, `${targetToolLabel} Session Switched`,
769
848
  `已切换到 **${targetToolLabel}** 会话。\n\n` +
770
849
  `**序号:** ${index + 1}\n` +
771
850
  `**Session ID:** ${target.sessionId}\n` +
772
851
  `**工作目录:** \`${cwd2}\`\n\n` +
773
- `直接在这里发消息即可继续对话。`,
852
+ `直接在这里发消息即可继续对话。${busyNote}`,
774
853
  "green"
775
854
  );
776
855
 
@@ -832,50 +911,16 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
832
911
  return;
833
912
  }
834
913
 
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
- }
914
+ // 并发检查:同一 session 只能有一个活跃 prompt
915
+ if (isSessionRunning(sessionId)) {
916
+ logTrace(tid, "BLOCKED", { outcome: "session_busy", sessionId });
917
+ console.log(`[${ts()}] [BLOCKED] Session ${sessionId} is already generating, rejecting message from chat ${chatId}`);
918
+ await sendCardReply(
919
+ freshToken, chatId, "生成中",
920
+ "该会话正在生成回复中,请等待完成后再发送新消息。",
921
+ "yellow"
922
+ );
923
+ return;
879
924
  }
880
925
 
881
926
  try {
@@ -895,13 +940,8 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
895
940
  }
896
941
  return;
897
942
  }
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")
943
+
944
+ // 无会话上下文 help card
905
945
 
906
946
  // 私聊或群聊无 session info → 发送 help card
907
947
  logTrace(tid, "SEND", { method: "help_card", chatId });
@@ -1151,12 +1191,25 @@ async function startBotServiceCore(): Promise<void> {
1151
1191
  });
1152
1192
  } else {
1153
1193
  resetState();
1194
+ // 启动时修正残留的 running 状态并重建 session→chat 映射
1195
+ fixStaleStreamStates().then(async () => {
1196
+ const registry = await loadSessionRegistryForBinding();
1197
+ rebuildSessionChatsFromRegistry(registry);
1198
+ }).catch((err) => console.error(`[${ts()}] Init bindings failed: ${(err as Error).message}`));
1154
1199
 
1155
1200
  const wsClient = new WSClient({
1156
1201
  appId: APP_ID,
1157
1202
  appSecret: APP_SECRET,
1158
- onReady: () => { resetState(); },
1159
- onReconnected: () => { resetState(); },
1203
+ onReady: async () => {
1204
+ resetState();
1205
+ const registry = await loadSessionRegistryForBinding();
1206
+ rebuildSessionChatsFromRegistry(registry);
1207
+ },
1208
+ onReconnected: async () => {
1209
+ resetState();
1210
+ const registry = await loadSessionRegistryForBinding();
1211
+ rebuildSessionChatsFromRegistry(registry);
1212
+ },
1160
1213
  });
1161
1214
 
1162
1215
  console.log(`\n[启动 6/7] 飞书长连接:正在通过 SDK 建立 WebSocket …`);
@@ -1212,7 +1265,7 @@ async function main(): Promise<void> {
1212
1265
  setExtraApiHandler(async (req, res) => {
1213
1266
  const injected = await handleSimInjectMessage(req, res);
1214
1267
  if (injected) return true;
1215
- return (await handleAgentGrantsRequest(req, res)) || (await handleAgentImageRequest(req, res)) || (await handleAgentFileRequest(req, res));
1268
+ return (await handleAgentImageRequest(req, res)) || (await handleAgentFileRequest(req, res));
1216
1269
  });
1217
1270
 
1218
1271
  const simServer = createServer(createUiRouter());
@@ -1270,7 +1323,7 @@ async function main(): Promise<void> {
1270
1323
  });
1271
1324
  });
1272
1325
  setExtraApiHandler(async (req, res) => {
1273
- return (await handleAgentGrantsRequest(req, res)) || (await handleAgentImageRequest(req, res)) || (await handleAgentFileRequest(req, res));
1326
+ return (await handleAgentImageRequest(req, res)) || (await handleAgentFileRequest(req, res));
1274
1327
  });
1275
1328
 
1276
1329
  console.log(`[启动 2/7] 环境与凭证检查`);