cursor-feedback 2.1.2 → 2.3.0

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.
@@ -72,6 +72,16 @@ function debugLog(message) {
72
72
  const timestamp = new Date().toISOString();
73
73
  console.error(`[${timestamp}] ${message}`);
74
74
  }
75
+ // 包版本真相源:package.json(dist/mcp-server.js 的上一级目录)。读不到时兜底 0.0.0。
76
+ const PKG_VERSION = (() => {
77
+ try {
78
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
79
+ return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
80
+ }
81
+ catch {
82
+ return '0.0.0';
83
+ }
84
+ })();
75
85
  /**
76
86
  * MCP Feedback Server
77
87
  */
@@ -94,6 +104,15 @@ class McpFeedbackServer {
94
104
  this.recentlyTimedOut = new Map();
95
105
  /** 面板在超时空窗内提交的反馈:暂存到下一轮 pending 注册时立即兑现 */
96
106
  this.panelStash = null;
107
+ /**
108
+ * 忙时消息队列:AI 正在干活(该项目空间没有等待中的反馈请求)时用户发来的消息
109
+ * 不再丢弃,而是按项目空间排队;等 AI 下一轮调 interactive_feedback 时合并送达,
110
+ * 并附「任务期间追加」提示头。飞书与面板消息共用同一个队列(按到达顺序),
111
+ * 飞书侧开关见 FeishuBridge.queueWhenBusy(面板 / FEISHU_QUEUE);面板排队恒可用。
112
+ */
113
+ this.queuedInbound = [];
114
+ /** 队列项 id 自增序号(配合时间戳保证进程内唯一) */
115
+ this.queueSeq = 0;
97
116
  /** 暂存过期提示定时器:到点仍未被认领则「回复」那条消息告知没送到,避免静默丢弃 */
98
117
  this.stashExpiryTimer = null;
99
118
  // 最近一次被飞书回复 resolve 的请求:供插件端精确区分「飞书回复」与「超时」,
@@ -133,7 +152,7 @@ class McpFeedbackServer {
133
152
  this.basePort = port;
134
153
  this.server = new index_js_1.Server({
135
154
  name: 'cursor-feedback-server',
136
- version: '0.0.1',
155
+ version: PKG_VERSION,
137
156
  }, {
138
157
  capabilities: {
139
158
  tools: {},
@@ -297,8 +316,10 @@ class McpFeedbackServer {
297
316
  debugLog(`Project: ${projectDir}`);
298
317
  debugLog(`Timeout: ${timeout}s`);
299
318
  debugLog(`Waiting for VS Code extension to collect feedback...`);
300
- // 飞书:已配置则推送一张反馈请求卡片(失败不影响插件主流程)
301
- if (this.feishu.isConfigured()) {
319
+ // 飞书:已配置则推送一张反馈请求卡片(失败不影响插件主流程)。
320
+ // 例外:忙时队列里已有本项目的排队消息 → 本轮会在注册后立即被队列兑现,
321
+ // 卡片发出去马上就过期(用户回复只会得到「已结束」),干脆不发。
322
+ if (this.feishu.isConfigured() && !this.hasQueuedFor(projectDir)) {
302
323
  this.feishu.sendFeedbackCard(requestId, summary, projectDir).catch(() => { });
303
324
  }
304
325
  try {
@@ -353,6 +374,9 @@ class McpFeedbackServer {
353
374
  else if (this.maybeStashForEndedCard(reqId, text, chatId, images, files, messageId)) {
354
375
  // 卡片刚超时、AI 正要续期重调 → 暂存续接到下一轮(超时未认领由 armStashExpiryNotice 回执)
355
376
  }
377
+ else if (this.queueForEndedCard(reqId, text, chatId, images, files, messageId)) {
378
+ // 忙时排队:AI 正忙(该项目无等待中的请求)→ 消息入队,等下一轮 feedback 自动送达
379
+ }
356
380
  else {
357
381
  // 卡片是本实例发的,但请求确实已结束(已被回复 / 超时太久)→ 明确告知
358
382
  this.feishu.replyText(chatId, '这条反馈已经结束了(可能已超时或已被回复)。');
@@ -375,8 +399,16 @@ class McpFeedbackServer {
375
399
  const remoteCount = remote.reduce((n, r) => n + r.list.length, 0);
376
400
  const globalCount = localPending.length + remoteCount;
377
401
  if (globalCount === 0) {
378
- // 抢跑兜底:此刻全局无人等待,但很可能 AI 正要发起下一轮(卡片还没注册)。
379
- // 暂存到收到消息的本实例,等下一轮 pending 注册时立即兑现。
402
+ // 忙时排队优先:全局无人等待 = AI 大概率正在干活。定位到唯一的活跃项目窗口时
403
+ // 直接把消息排队给它(含回执「已排队」),多个活跃窗口则引导用户回复对应卡片。
404
+ // 复用上面 queryRemotePending 的扫描结果,避免二次全端口扫描。
405
+ if (this.feishu.isQueueWhenBusy()) {
406
+ const routed = await this.routeOrphanToQueue(text, chatId, images, files, messageId, remote);
407
+ if (routed)
408
+ return;
409
+ }
410
+ // 抢跑兜底:定位不到活跃窗口(或队列关闭)时,退回原有短暂存——很可能 AI 正要
411
+ // 发起下一轮(卡片还没注册),等 pending 注册时立即兑现。
380
412
  // 关键:此刻没有任何 AI 在等待,绝不能给「✅ 已收到」回执——那是虚假承诺,会让用户
381
413
  // 误以为消息已被接收(实则 AI 这轮可能已结束、永远不会来认领,消息石沉大海)。
382
414
  // 回执只在「真正送达某个等待中的请求」时给(见 tryConsumeStash → submitFromFeishu)。
@@ -495,7 +527,8 @@ class McpFeedbackServer {
495
527
  return Promise.all(posts).then((results) => results.some(Boolean));
496
528
  }
497
529
  /**
498
- * 向其他窗口的 server 查询各自的 pending 列表(仅项目名,用于全局视角判断 + 提示文案)。
530
+ * 向其他窗口的 server 查询各自的 pending 列表(仅项目名,用于全局视角判断 + 提示文案),
531
+ * 以及实例归属窗口的存活状态(忙时排队用来定位「唯一活跃窗口」)。
499
532
  * 用于无 parent_id 的「无主消息」:飞书只推给一个窗口,需汇总全局才能正确决策。
500
533
  */
501
534
  queryRemotePending() {
@@ -508,21 +541,27 @@ class McpFeedbackServer {
508
541
  }
509
542
  fetchRemotePending(port) {
510
543
  return new Promise((resolve) => {
544
+ const empty = { port, list: [], ownerWorkspace: null, ownerAlive: false };
511
545
  const req = http.request({ hostname: '127.0.0.1', port, path: '/api/feishu/pending', method: 'GET', timeout: 1500 }, (res) => {
512
546
  let body = '';
513
547
  res.on('data', (chunk) => { body += chunk.toString(); });
514
548
  res.on('end', () => {
515
549
  try {
516
550
  const j = JSON.parse(body);
517
- resolve({ port, list: Array.isArray(j.list) ? j.list : [] });
551
+ resolve({
552
+ port,
553
+ list: Array.isArray(j.list) ? j.list : [],
554
+ ownerWorkspace: j.ownerWorkspace || null,
555
+ ownerAlive: !!j.ownerAlive,
556
+ });
518
557
  }
519
558
  catch {
520
- resolve({ port, list: [] });
559
+ resolve(empty);
521
560
  }
522
561
  });
523
562
  });
524
- req.on('error', () => resolve({ port, list: [] }));
525
- req.on('timeout', () => { req.destroy(); resolve({ port, list: [] }); });
563
+ req.on('error', () => resolve(empty));
564
+ req.on('timeout', () => { req.destroy(); resolve(empty); });
526
565
  req.end();
527
566
  });
528
567
  }
@@ -701,9 +740,10 @@ class McpFeedbackServer {
701
740
  waiters,
702
741
  });
703
742
  // 抢跑兑现:空窗期的面板提交(优先,用户显式点了发送)与飞书暂存消息,
704
- // 本轮 pending 一注册立即作为回复提交
743
+ // 本轮 pending 一注册立即作为回复提交;最后是忙时队列里排队的追加消息
705
744
  this.tryConsumePanelStash(requestId);
706
745
  this.tryConsumeStash(requestId);
746
+ this.tryConsumeQueue(requestId);
707
747
  });
708
748
  }
709
749
  /**
@@ -896,6 +936,246 @@ class McpFeedbackServer {
896
936
  this.pendingRequests.delete(requestId);
897
937
  this.feishu.clearPending(requestId);
898
938
  }
939
+ // ==================== 忙时消息队列 ====================
940
+ /** 某项目空间当前是否有等待中的反馈请求(路径互为前缀视为同一窗口语境) */
941
+ hasPendingForProject(projectDir) {
942
+ const owner = this.normalizePath(projectDir);
943
+ for (const [, p] of this.pendingRequests) {
944
+ if (this.pathsRelated(this.normalizePath(p.projectDir), owner))
945
+ return true;
946
+ }
947
+ return false;
948
+ }
949
+ /** 队列里是否有属于某项目空间的排队消息(不看渠道开关:面板消息不受飞书队列开关约束) */
950
+ hasQueuedFor(projectDir) {
951
+ const owner = this.normalizePath(projectDir);
952
+ return this.queuedInbound.some((q) => this.pathsRelated(this.normalizePath(q.forProjectDir), owner));
953
+ }
954
+ /** 某项目空间的排队消息快照(随 /api/feedback/current 下发给面板展示队列列表) */
955
+ queuedSnapshotFor(normalizedWs) {
956
+ if (!normalizedWs)
957
+ return [];
958
+ return this.queuedInbound
959
+ .filter((q) => this.pathsRelated(this.normalizePath(q.forProjectDir), normalizedWs))
960
+ .map((q) => ({
961
+ id: q.id,
962
+ at: q.at,
963
+ source: q.source,
964
+ text: q.text,
965
+ images: q.images.length,
966
+ files: q.files.length,
967
+ }));
968
+ }
969
+ /**
970
+ * 撤回一条排队消息(面板队列列表的小叉触发)。
971
+ * 飞书来源的消息同步回执「已撤回」,两边状态一致;面板消息撤掉后列表随轮询消失即回执。
972
+ * 返回 false = 没找到(可能已被消费或过期),面板下一秒轮询自然对齐,无需特殊处理。
973
+ */
974
+ removeQueuedById(id) {
975
+ const idx = this.queuedInbound.findIndex((q) => q.id === id);
976
+ if (idx < 0)
977
+ return false;
978
+ const [item] = this.queuedInbound.splice(idx, 1);
979
+ clearTimeout(item.expiryTimer);
980
+ debugLog(`Queued message recalled (id=${id}, source=${item.source}, queueSize=${this.queuedInbound.length})`);
981
+ if (item.source === 'feishu') {
982
+ this.feishu.replyToMessage(item.messageId || undefined, item.chatId, '🗑️ 这条排队消息已在插件面板被撤回,不会送达 AI。');
983
+ }
984
+ return true;
985
+ }
986
+ /**
987
+ * 用户回复了一张「已结束」的卡片(不在超时续接窗口内)→ 忙时排队:
988
+ * - 该项目已有新一轮在等 → 引导回复最新卡片(避免旧回复窜入错误轮次),视为已处理;
989
+ * - 该项目没有等待中的请求(AI 正忙)→ 入队,等下一轮 feedback 自动送达。
990
+ * 返回 true 表示已处理(排队或已引导),false 表示队列关闭 / 定位不到归属,走原有「已结束」回执。
991
+ */
992
+ queueForEndedCard(reqId, text, chatId, images, files, messageId) {
993
+ if (!this.feishu.isQueueWhenBusy())
994
+ return false;
995
+ const cardProject = this.feishu.projectDirOf(reqId);
996
+ if (!cardProject)
997
+ return false;
998
+ if (this.hasPendingForProject(cardProject)) {
999
+ this.feishu.replyToMessage(messageId || undefined, chatId, '这张卡片的反馈已结束,且该项目有新的反馈卡片正在等待。请「回复」最新的那张卡片。');
1000
+ return true;
1001
+ }
1002
+ this.enqueueInbound(text, chatId, images, files, messageId, cardProject);
1003
+ return true;
1004
+ }
1005
+ /**
1006
+ * 消息入队 + 回执用户「已排队」。每条消息带 60 分钟过期兜底:
1007
+ * 到点仍未被 AI 读取(对话可能已结束)→ 引用回复告知未送达,绝不静默丢弃。
1008
+ * 飞书与面板消息共用同一个队列(严格按到达顺序),但回执链路分渠道:
1009
+ * 飞书走引用回复/表情,面板消息没有推送通道,靠轮询下发的队列列表反映在/不在。
1010
+ */
1011
+ enqueueInbound(text, chatId, images, files, messageId, forProjectDir, source = 'feishu') {
1012
+ // 上限保护:挤出最老的一条并告知未送达
1013
+ while (this.queuedInbound.length >= McpFeedbackServer.QUEUE_MAX) {
1014
+ const dropped = this.queuedInbound.shift();
1015
+ if (!dropped)
1016
+ break;
1017
+ clearTimeout(dropped.expiryTimer);
1018
+ if (dropped.source === 'feishu') {
1019
+ this.feishu.replyToMessage(dropped.messageId || undefined, dropped.chatId, '⚠️ 排队消息过多,这条消息已被挤出队列、未送达 AI,请稍后重发。');
1020
+ }
1021
+ }
1022
+ const item = {
1023
+ id: `q${Date.now()}_${++this.queueSeq}`,
1024
+ text,
1025
+ chatId,
1026
+ images,
1027
+ files,
1028
+ at: Date.now(),
1029
+ messageId,
1030
+ source,
1031
+ forProjectDir,
1032
+ expiryTimer: undefined,
1033
+ };
1034
+ item.expiryTimer = setTimeout(() => {
1035
+ const idx = this.queuedInbound.indexOf(item);
1036
+ if (idx < 0)
1037
+ return; // 已被消费
1038
+ this.queuedInbound.splice(idx, 1);
1039
+ if (item.source === 'feishu') {
1040
+ this.feishu.replyToMessage(item.messageId || undefined, item.chatId, '⚠️ 这条排队消息等了 60 分钟仍未被 AI 读取(对话可能已结束),未能送达。需要的话请在 AI 下次询问时重发。');
1041
+ }
1042
+ }, McpFeedbackServer.QUEUE_TTL_MS);
1043
+ item.expiryTimer.unref?.();
1044
+ this.queuedInbound.push(item);
1045
+ debugLog(`Inbound queued for busy AI (source=${source}, project=${forProjectDir}, queueSize=${this.queuedInbound.length})`);
1046
+ if (source === 'feishu') {
1047
+ this.feishu.replyToMessage(messageId || undefined, chatId, `🤖 AI 正在工作中,这条消息已排队,将在「${this.projectName(forProjectDir)}」当前任务完成后自动读取。`);
1048
+ }
1049
+ }
1050
+ /**
1051
+ * 队列兑现:新一轮 pending 注册时,把属于该项目空间的所有排队消息合并成一次反馈送达,
1052
+ * 正文附「任务期间追加」提示头,并给每条排队消息补 ✅ 送达回执。
1053
+ */
1054
+ tryConsumeQueue(requestId) {
1055
+ const pending = this.pendingRequests.get(requestId);
1056
+ if (!pending)
1057
+ return;
1058
+ if (this.queuedInbound.length === 0)
1059
+ return;
1060
+ const owner = this.normalizePath(pending.projectDir);
1061
+ const matched = this.queuedInbound.filter((q) => this.pathsRelated(this.normalizePath(q.forProjectDir), owner));
1062
+ if (matched.length === 0)
1063
+ return;
1064
+ this.queuedInbound = this.queuedInbound.filter((q) => !matched.includes(q));
1065
+ for (const m of matched)
1066
+ clearTimeout(m.expiryTimer);
1067
+ const two = (n) => String(n).padStart(2, '0');
1068
+ const fmt = (at) => {
1069
+ const d = new Date(at);
1070
+ return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}`;
1071
+ };
1072
+ const lines = matched.map((m) => {
1073
+ const body = m.text || (m.images.length || m.files.length ? '[图片/附件]' : '');
1074
+ return `[${fmt(m.at)}] ${body}`;
1075
+ });
1076
+ const text = '[QUEUED_MESSAGES] 以下是用户在你执行上一轮任务期间「追加」发送的消息(当时你正忙,消息已排队暂存)。' +
1077
+ '注意:这些内容不是对你最新一轮工作摘要的回复,请结合任务上下文阅读并处理;' +
1078
+ '如与你摘要中的询问冲突,以用户追加内容为准。本次的 summary 用户并没有收到,如有必要可在下次带上。' +
1079
+ '处理完后照常调用 interactive_feedback 继续对话。\n\n' +
1080
+ `=== 追加消息(${matched.length} 条)===\n` +
1081
+ lines.join('\n\n');
1082
+ debugLog(`Consuming ${matched.length} queued message(s) for request: ${requestId}`);
1083
+ clearTimeout(pending.timeout);
1084
+ pending.resolve({
1085
+ interactive_feedback: text,
1086
+ images: matched.flatMap((m) => m.images),
1087
+ attachedFiles: matched.flatMap((m) => m.files),
1088
+ project_directory: pending.projectDir,
1089
+ });
1090
+ this.pendingRequests.delete(requestId);
1091
+ this.feishu.clearPending(requestId);
1092
+ // 标记为「飞书渠道 resolve」:插件面板据此重置,避免对着已消失的请求提交
1093
+ this.lastFeishuResolved = { id: requestId, at: Date.now() };
1094
+ // 送达回执:给每条飞书排队消息补 ✅ 表情(面板消息没有回执通道,
1095
+ // 其送达状态由轮询下发的队列列表体现——消费后列表随即清空)
1096
+ for (const m of matched) {
1097
+ if (m.source === 'feishu') {
1098
+ this.feishu.reactDone(m.messageId || undefined, m.chatId);
1099
+ }
1100
+ }
1101
+ }
1102
+ /** 「我的窗口」是否仍活着(无插件 host 无从判定,视为活跃) */
1103
+ isOwnerAlive() {
1104
+ if (!this.everOwnerPolled)
1105
+ return true;
1106
+ return Date.now() - this.lastOwnerPollTime <= McpFeedbackServer.OWNER_IDLE_MS;
1107
+ }
1108
+ /**
1109
+ * 「无主消息」的忙时排队路由:全局无人等待时,汇总所有实例的活跃项目窗口——
1110
+ * - 恰好 1 个 → 消息排队给它(本地直接入队;远程实例经 /api/feishu/enqueue 转发);
1111
+ * - 多个 → 回执引导用户回复对应项目的卡片(回复旧卡片也会正确排队到该项目);
1112
+ * - 0 个 → 返回 false,调用方退回原有短暂存兜底。
1113
+ */
1114
+ async routeOrphanToQueue(text, chatId, images, files, messageId, remotes) {
1115
+ const candidates = [];
1116
+ if (this.ownerWorkspace && this.isOwnerAlive()) {
1117
+ candidates.push({ port: null, workspace: this.ownerWorkspace });
1118
+ }
1119
+ for (const r of remotes) {
1120
+ if (r.ownerAlive && r.ownerWorkspace) {
1121
+ candidates.push({ port: r.port, workspace: r.ownerWorkspace });
1122
+ }
1123
+ }
1124
+ // 按 workspace 去重(同窗口多对话共用/多实例只算一个活跃窗口;本地优先)
1125
+ const seen = new Set();
1126
+ const uniq = [];
1127
+ for (const c of candidates) {
1128
+ const key = this.normalizePath(c.workspace);
1129
+ if (seen.has(key))
1130
+ continue;
1131
+ seen.add(key);
1132
+ uniq.push(c);
1133
+ }
1134
+ if (uniq.length === 1) {
1135
+ const target = uniq[0];
1136
+ if (target.port === null) {
1137
+ this.enqueueInbound(text, chatId, images, files, messageId, target.workspace);
1138
+ return true;
1139
+ }
1140
+ return this.forwardEnqueue(target.port, text, chatId, images, files, messageId);
1141
+ }
1142
+ if (uniq.length > 1) {
1143
+ const names = uniq.map((c) => this.projectName(c.workspace));
1144
+ this.feishu.replyToMessage(messageId || undefined, chatId, `当前有 ${uniq.length} 个项目窗口在工作(${names.join('、')}),没法判断这条消息要发给谁。\n` +
1145
+ '请「回复」你要发送的那个项目的卡片(旧卡片也行),消息会排队到对应项目。');
1146
+ return true;
1147
+ }
1148
+ return false;
1149
+ }
1150
+ /** 把无主消息转发给远程实例排队(远程会自己入队并回执用户)。返回是否排队成功 */
1151
+ forwardEnqueue(port, text, chatId, images, files, messageId) {
1152
+ const body = JSON.stringify({ text, chatId, images, attachedFiles: files, messageId });
1153
+ return new Promise((resolve) => {
1154
+ const req = http.request({
1155
+ hostname: '127.0.0.1',
1156
+ port,
1157
+ path: '/api/feishu/enqueue',
1158
+ method: 'POST',
1159
+ timeout: 2000,
1160
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
1161
+ }, (res) => {
1162
+ let resBody = '';
1163
+ res.on('data', (chunk) => { resBody += chunk.toString(); });
1164
+ res.on('end', () => {
1165
+ try {
1166
+ resolve(!!JSON.parse(resBody).queued);
1167
+ }
1168
+ catch {
1169
+ resolve(false);
1170
+ }
1171
+ });
1172
+ });
1173
+ req.on('error', () => resolve(false));
1174
+ req.on('timeout', () => { req.destroy(); resolve(false); });
1175
+ req.write(body);
1176
+ req.end();
1177
+ });
1178
+ }
899
1179
  /**
900
1180
  * 处理获取系统信息请求
901
1181
  */
@@ -1070,9 +1350,66 @@ class McpFeedbackServer {
1070
1350
  feishu: this.feishu.getStatus(),
1071
1351
  feishuResolvedId: (this.lastFeishuResolved && Date.now() - this.lastFeishuResolved.at < 30000) ? this.lastFeishuResolved.id : null,
1072
1352
  pause: this.getPauseStateFor(chosen?.id),
1353
+ // 该窗口的忙时排队消息快照(飞书 + 面板同队列),供面板展示队列列表
1354
+ queued: this.queuedSnapshotFor(pollWs),
1073
1355
  }));
1074
1356
  return;
1075
1357
  }
1358
+ // 面板忙时排队:AI 正忙(该项目无等待中的请求)时,把面板消息排进忙时队列,
1359
+ // 与飞书消息共用同一队列(按到达顺序),下一轮 interactive_feedback 合并送达
1360
+ if (req.method === 'POST' && req.url === '/api/feedback/enqueue') {
1361
+ let body = '';
1362
+ req.on('data', (chunk) => { body += chunk.toString(); });
1363
+ req.on('end', () => {
1364
+ try {
1365
+ const { text, images, attachedFiles, projectDir } = JSON.parse(body);
1366
+ if (!projectDir) {
1367
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1368
+ res.end(JSON.stringify({ queued: false, reason: 'projectDir required' }));
1369
+ return;
1370
+ }
1371
+ // 忙时排队是全局开关(面板 UI 已隐藏排队入口,这里兜底防竞态)
1372
+ if (!this.feishu.isQueueWhenBusy()) {
1373
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1374
+ res.end(JSON.stringify({ queued: false, reason: 'disabled' }));
1375
+ return;
1376
+ }
1377
+ // 该项目有等待中的请求 → 不该排队,面板应直接提交(正常轮询下一秒就会显示该请求)
1378
+ if (this.hasPendingForProject(projectDir)) {
1379
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1380
+ res.end(JSON.stringify({ queued: false, reason: 'pending' }));
1381
+ return;
1382
+ }
1383
+ this.enqueueInbound(text || '', '', images || [], attachedFiles || [], '', projectDir, 'panel');
1384
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1385
+ res.end(JSON.stringify({ queued: true }));
1386
+ }
1387
+ catch {
1388
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1389
+ res.end(JSON.stringify({ queued: false, reason: 'invalid body' }));
1390
+ }
1391
+ });
1392
+ return;
1393
+ }
1394
+ // 撤回排队消息:面板队列列表的删除按钮触发,按队列项 id 定位。
1395
+ // 飞书来源的消息由 removeQueuedById 同步回执,保证两边状态一致
1396
+ if (req.method === 'POST' && req.url === '/api/feedback/queue/remove') {
1397
+ let body = '';
1398
+ req.on('data', (chunk) => { body += chunk.toString(); });
1399
+ req.on('end', () => {
1400
+ try {
1401
+ const { id } = JSON.parse(body);
1402
+ const removed = !!id && this.removeQueuedById(id);
1403
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1404
+ res.end(JSON.stringify({ removed }));
1405
+ }
1406
+ catch {
1407
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1408
+ res.end(JSON.stringify({ removed: false }));
1409
+ }
1410
+ });
1411
+ return;
1412
+ }
1076
1413
  // 提交反馈
1077
1414
  if (req.method === 'POST' && req.url === '/api/feedback/submit') {
1078
1415
  let body = '';
@@ -1119,6 +1456,30 @@ class McpFeedbackServer {
1119
1456
  return;
1120
1457
  }
1121
1458
  // 配置飞书(来自插件 UI;凭证未变只补 chatId,变了则重建长连接)
1459
+ // 扫码一键创建飞书应用:启动 Device Grant 流程并返回待扫码链接(等二维码就绪才响应)
1460
+ if (req.method === 'POST' && req.url === '/api/feishu/register/start') {
1461
+ this.feishu.startRegister().then((state) => {
1462
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1463
+ res.end(JSON.stringify(state));
1464
+ }).catch(() => {
1465
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1466
+ res.end(JSON.stringify({ status: 'error', error: 'internal' }));
1467
+ });
1468
+ return;
1469
+ }
1470
+ // 扫码创建流程状态(插件轮询直到 success / error)
1471
+ if (req.method === 'GET' && req.url === '/api/feishu/register/status') {
1472
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1473
+ res.end(JSON.stringify(this.feishu.getRegisterState()));
1474
+ return;
1475
+ }
1476
+ // 取消进行中的扫码创建流程(用户关闭设置弹窗)
1477
+ if (req.method === 'POST' && req.url === '/api/feishu/register/cancel') {
1478
+ this.feishu.cancelRegister();
1479
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1480
+ res.end(JSON.stringify({ ok: true }));
1481
+ return;
1482
+ }
1122
1483
  if (req.method === 'POST' && req.url === '/api/feishu/config') {
1123
1484
  let body = '';
1124
1485
  req.on('data', (chunk) => { body += chunk.toString(); });
@@ -1134,6 +1495,7 @@ class McpFeedbackServer {
1134
1495
  appSecret: cfg.appSecret || '',
1135
1496
  enabled: cfg.enabled,
1136
1497
  ackReaction: cfg.ackReaction,
1498
+ queueWhenBusy: cfg.queueWhenBusy,
1137
1499
  touched: true,
1138
1500
  });
1139
1501
  await this.feishu.configure(cfg);
@@ -1224,6 +1586,12 @@ class McpFeedbackServer {
1224
1586
  res.writeHead(200, { 'Content-Type': 'application/json' });
1225
1587
  res.end(JSON.stringify({ handled: true }));
1226
1588
  }
1589
+ else if (reqId &&
1590
+ this.queueForEndedCard(reqId, text, chatId, images || [], attachedFiles || [], messageId || '')) {
1591
+ // 本实例的卡片、请求已结束且 AI 正忙 → 消息已排队(或已引导回复新卡片)
1592
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1593
+ res.end(JSON.stringify({ handled: true }));
1594
+ }
1227
1595
  else {
1228
1596
  res.writeHead(200, { 'Content-Type': 'application/json' });
1229
1597
  res.end(JSON.stringify({ handled: false }));
@@ -1246,7 +1614,13 @@ class McpFeedbackServer {
1246
1614
  ? []
1247
1615
  : this.feishu.listPending().map((p) => ({ projectName: this.projectName(p.projectDir) }));
1248
1616
  res.writeHead(200, { 'Content-Type': 'application/json' });
1249
- res.end(JSON.stringify({ count: list.length, list }));
1617
+ // ownerWorkspace / ownerAlive:供其他实例的忙时排队路由定位「活跃项目窗口」
1618
+ res.end(JSON.stringify({
1619
+ count: list.length,
1620
+ list,
1621
+ ownerWorkspace: this.ownerWorkspace,
1622
+ ownerAlive: !!this.ownerWorkspace && !ownerGone,
1623
+ }));
1250
1624
  return;
1251
1625
  }
1252
1626
  // 接收「无主消息」转发:仅当本实例恰好持有唯一 pending 时认领提交
@@ -1275,12 +1649,37 @@ class McpFeedbackServer {
1275
1649
  });
1276
1650
  return;
1277
1651
  }
1652
+ // 接收「无主消息」的忙时排队转发:本实例是全局唯一活跃窗口时,把消息排进本实例队列
1653
+ // (入队与「已排队」回执都由本实例完成,转发方只关心 queued 结果)
1654
+ if (req.method === 'POST' && req.url === '/api/feishu/enqueue') {
1655
+ let body = '';
1656
+ req.on('data', (chunk) => { body += chunk.toString(); });
1657
+ req.on('end', () => {
1658
+ try {
1659
+ const { text, chatId, images, attachedFiles, messageId } = JSON.parse(body);
1660
+ if (this.feishu.isQueueWhenBusy() && this.ownerWorkspace) {
1661
+ this.enqueueInbound(text, chatId, images || [], attachedFiles || [], messageId || '', this.ownerWorkspace);
1662
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1663
+ res.end(JSON.stringify({ queued: true }));
1664
+ }
1665
+ else {
1666
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1667
+ res.end(JSON.stringify({ queued: false }));
1668
+ }
1669
+ }
1670
+ catch {
1671
+ res.writeHead(400);
1672
+ res.end('{}');
1673
+ }
1674
+ });
1675
+ return;
1676
+ }
1278
1677
  // 健康检查
1279
1678
  if (req.method === 'GET' && req.url === '/api/health') {
1280
1679
  res.writeHead(200, { 'Content-Type': 'application/json' });
1281
1680
  res.end(JSON.stringify({
1282
1681
  status: 'ok',
1283
- version: '0.0.1',
1682
+ version: PKG_VERSION,
1284
1683
  hasCurrentRequest: this.currentRequest !== null,
1285
1684
  pid: process.pid,
1286
1685
  }));
@@ -1372,6 +1771,7 @@ class McpFeedbackServer {
1372
1771
  appSecret,
1373
1772
  enabled: process.env.FEISHU_ENABLED !== 'false',
1374
1773
  ackReaction: process.env.FEISHU_ACK !== 'false',
1774
+ queueWhenBusy: process.env.FEISHU_QUEUE !== 'false',
1375
1775
  };
1376
1776
  }
1377
1777
  /**
@@ -1501,6 +1901,11 @@ class McpFeedbackServer {
1501
1901
  pending.resolve(null);
1502
1902
  }
1503
1903
  this.pendingRequests.clear();
1904
+ // 清理忙时队列的过期定时器(进程将退出,消息由 60 分钟兜底逻辑以外的重启场景自然失效)
1905
+ for (const q of this.queuedInbound) {
1906
+ clearTimeout(q.expiryTimer);
1907
+ }
1908
+ this.queuedInbound = [];
1504
1909
  // 关闭 MCP 服务器
1505
1910
  this.server.close();
1506
1911
  debugLog('Server stopped');
@@ -1516,6 +1921,10 @@ McpFeedbackServer.STASH_TTL_MS = 5000;
1516
1921
  * 都应暂存并续接到下一轮,而不是报「Request not found / 已结束」把用户内容丢掉。
1517
1922
  */
1518
1923
  McpFeedbackServer.REJOIN_TTL_MS = 15000;
1924
+ /** 队列消息未被读取的兜底时长:超过视为对话已结束,回执用户避免静默丢失 */
1925
+ McpFeedbackServer.QUEUE_TTL_MS = 60 * 60 * 1000;
1926
+ /** 队列长度上限:极端情况下防止无限堆积 */
1927
+ McpFeedbackServer.QUEUE_MAX = 100;
1519
1928
  /**
1520
1929
  * 「我的窗口」判定为已关闭的空闲阈值:本实例曾被自己窗口插件轮询,但超过此时长没再收到
1521
1930
  *(只剩别的窗口在扫端口)→ 视为我的窗口已关、本实例是僵尸:不再上报 pending,避免污染全局计数。