cursor-feedback 2.2.0 → 2.3.1

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
  */
@@ -95,11 +105,14 @@ class McpFeedbackServer {
95
105
  /** 面板在超时空窗内提交的反馈:暂存到下一轮 pending 注册时立即兑现 */
96
106
  this.panelStash = null;
97
107
  /**
98
- * 忙时消息队列:AI 正在干活(该项目空间没有等待中的反馈请求)时用户发来的飞书消息
108
+ * 忙时消息队列:AI 正在干活(该项目空间没有等待中的反馈请求)时用户发来的消息
99
109
  * 不再丢弃,而是按项目空间排队;等 AI 下一轮调 interactive_feedback 时合并送达,
100
- * 并附「任务期间追加」提示头。开关见 FeishuBridge.queueWhenBusy(面板 / FEISHU_QUEUE)。
110
+ * 并附「任务期间追加」提示头。飞书与面板消息共用同一个队列(按到达顺序),
111
+ * 飞书侧开关见 FeishuBridge.queueWhenBusy(面板 / FEISHU_QUEUE);面板排队恒可用。
101
112
  */
102
113
  this.queuedInbound = [];
114
+ /** 队列项 id 自增序号(配合时间戳保证进程内唯一) */
115
+ this.queueSeq = 0;
103
116
  /** 暂存过期提示定时器:到点仍未被认领则「回复」那条消息告知没送到,避免静默丢弃 */
104
117
  this.stashExpiryTimer = null;
105
118
  // 最近一次被飞书回复 resolve 的请求:供插件端精确区分「飞书回复」与「超时」,
@@ -135,11 +148,16 @@ class McpFeedbackServer {
135
148
  // stop() 防重入:server.close() 会触发 transport.onclose → 回调里又调 stop(),
136
149
  // 无标志位会无限递归直至 "Maximum call stack size exceeded"(日志曾刷出数万条 Stopping server...)
137
150
  this.stopping = false;
151
+ /**
152
+ * 已结束请求的结果缓存(wire id → 当时的结果):供「迟到的重复投递」精确重放。
153
+ * 容量与时效都收紧(feedback 结果可能含 base64 图片,不能无限囤积)。
154
+ */
155
+ this.settledByWire = new Map();
138
156
  this.port = port;
139
157
  this.basePort = port;
140
158
  this.server = new index_js_1.Server({
141
159
  name: 'cursor-feedback-server',
142
- version: '0.0.1',
160
+ version: PKG_VERSION,
143
161
  }, {
144
162
  capabilities: {
145
163
  tools: {},
@@ -211,14 +229,17 @@ class McpFeedbackServer {
211
229
  };
212
230
  });
213
231
  // 处理工具调用
214
- this.server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
232
+ this.server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request, extra) => {
215
233
  // AI 发起调用,刷新活动时间,避免被 watchdog 误判为闲置
216
234
  this.lastActivityTime = Date.now();
217
235
  const { name, arguments: args } = request.params;
236
+ // JSON-RPC 请求 id(同一连接内唯一):客户端把同一次 tool call 重复投递时若复用
237
+ // 同一 id,可据此做精确去重 / 结果重放(比 summary 内容启发式可靠)。
238
+ const wireKey = extra?.requestId !== undefined ? String(extra.requestId) : undefined;
218
239
  try {
219
240
  switch (name) {
220
241
  case 'interactive_feedback':
221
- return await this.handleInteractiveFeedback(args);
242
+ return await this.handleInteractiveFeedback(args, wireKey);
222
243
  case 'get_system_info':
223
244
  return this.handleGetSystemInfo();
224
245
  default:
@@ -239,8 +260,9 @@ class McpFeedbackServer {
239
260
  }
240
261
  /**
241
262
  * 处理交互式反馈请求
263
+ * @param wireKey 本次调用的 JSON-RPC 请求 id(用于重复投递的精确识别与结果重放)
242
264
  */
243
- async handleInteractiveFeedback(args) {
265
+ async handleInteractiveFeedback(args, wireKey) {
244
266
  // 参数校验:project_directory 是必填项
245
267
  if (!args?.project_directory) {
246
268
  const receivedParams = JSON.stringify(args || {});
@@ -263,15 +285,29 @@ class McpFeedbackServer {
263
285
  const envTimeout = process.env.MCP_FEEDBACK_TIMEOUT ? parseInt(process.env.MCP_FEEDBACK_TIMEOUT, 10) : null;
264
286
  const timeout = envTimeout || args?.timeout || 300;
265
287
  const requestId = this.generateRequestId();
266
- // 重复投递识别:同窗口 + summary + 旧请求还很新 → 视为客户端/传输层对同一次调用的
267
- // 重复投递(非新一轮),直接 join 旧等待共享结果:不发新卡、不顶旧请求(顶了会让
268
- // 先到的那次调用收到 SUPERSEDED、AI 误以为被新会话取代而提前收尾)。
269
- const dup = this.findDuplicatePending(projectDir, summary);
288
+ // 重复投递识别:客户端/传输层会把同一次 tool call 原样重复投递(实测某一时刻把进程内
289
+ // 所有 in-flight 调用各重投一遍,距原投递可晚至 3~8 分钟,无固定上限)。重复投递必须
290
+ // join 原等待共享结果,绝不能当新一轮:不发新卡、不顶旧请求——顶了会让原调用收到
291
+ // SUPERSEDED(AI 误以为被新会话取代而提前收尾),且重投开出的幽灵卡片没人认领,
292
+ // 用户回复它只会石沉大海。
293
+ const dup = this.findDuplicatePending(projectDir, summary, timeout, wireKey);
270
294
  if (dup) {
271
- debugLog(`Duplicate delivery detected for request ${dup.id}; joining its wait instead of superseding`);
295
+ debugLog(`Duplicate delivery detected (matched by ${dup.via}) for request ${dup.id}; joining its wait instead of superseding`);
272
296
  const outcome = await new Promise((res) => dup.waiters.push(res));
297
+ this.rememberSettledWire(wireKey, outcome, dup.id);
273
298
  return this.outcomeToResult(outcome, dup.id);
274
299
  }
300
+ // 迟到的重复投递(原请求已结束):同 wire id 精确命中已结束请求 → 原样重放当时的结果。
301
+ // 绝不能开新一轮:原调用早已返回、没人会认领新卡,用户回复只会丢失。
302
+ // 只按 wire id 匹配、绝不按 summary——AI 超时续期的新一轮常复用同一句 summary,
303
+ // 按内容重放旧超时会造成「新调用秒收超时 → 立即重调 → 又秒收」的快速空转。
304
+ if (wireKey) {
305
+ const settled = this.settledByWire.get(wireKey);
306
+ if (settled) {
307
+ debugLog(`Late duplicate delivery (wire id ${wireKey}) for settled request ${settled.requestId}; replaying its outcome`);
308
+ return this.outcomeToResult(settled.outcome, settled.requestId);
309
+ }
310
+ }
275
311
  // 作废上一轮残留的「僵尸」请求:单实例单窗口同时只应有一个活跃反馈请求。
276
312
  // 旧请求多半是对话被压缩 / 客户端取消后还卡在 await 的残留(要等 timeout 才自然结束),
277
313
  // 不清理会让 pendingCount 虚高、全局视角误判「多个窗口在等」
@@ -311,7 +347,8 @@ class McpFeedbackServer {
311
347
  }
312
348
  try {
313
349
  // 等待用户反馈
314
- const outcome = await this.waitForFeedback(request, timeout * 1000);
350
+ const outcome = await this.waitForFeedback(request, timeout * 1000, wireKey);
351
+ this.rememberSettledWire(wireKey, outcome, requestId);
315
352
  return this.outcomeToResult(outcome, requestId);
316
353
  }
317
354
  catch (error) {
@@ -593,19 +630,49 @@ class McpFeedbackServer {
593
630
  this.pendingRequests.delete(reqId);
594
631
  }
595
632
  }
596
- findDuplicatePending(projectDir, summary) {
633
+ /**
634
+ * 查找可 join 的「重复投递」等待,两级匹配:
635
+ * 1) wire id 精确匹配:同一条 JSON-RPC 请求(同 id)还在等待 → 必是重复投递;
636
+ * 2) 内容启发式:同窗口 + 同 summary + 同 timeout,且原请求仍在 pending(覆盖客户端
637
+ * 重投时换了新 wire id 的情况)。
638
+ * 内容启发式不设时间窗——旧实现的 90s 窗口被实测击穿(重投可晚至 3~8 分钟,无固定上限)。
639
+ * 不设窗口是安全的:pending 仍存活 = 原调用尚未返回 = 同一会话不可能已合法开启新一轮;
640
+ * 不同会话在同一窗口撞出一字不差的 summary + timeout 概率可忽略,即便撞上,
641
+ * join(双方共享同一份回复)也远比 supersede(悄悄掐掉别人的等待)安全。
642
+ */
643
+ findDuplicatePending(projectDir, summary, timeout, wireKey) {
597
644
  const owner = this.normalizePath(projectDir);
598
645
  for (const [id, pending] of this.pendingRequests) {
646
+ if (wireKey !== undefined && pending.wireId === wireKey) {
647
+ return { id, via: 'wire id', waiters: pending.waiters };
648
+ }
599
649
  if (this.normalizePath(pending.projectDir) !== owner)
600
650
  continue;
601
651
  if (pending.request.summary !== summary)
602
652
  continue;
603
- if (Date.now() - pending.request.timestamp > McpFeedbackServer.DUP_JOIN_MS)
653
+ if (pending.request.timeout !== timeout)
604
654
  continue;
605
- return { id, waiters: pending.waiters };
655
+ return { id, via: 'content', waiters: pending.waiters };
606
656
  }
607
657
  return null;
608
658
  }
659
+ rememberSettledWire(wireKey, outcome, requestId) {
660
+ if (!wireKey)
661
+ return;
662
+ const now = Date.now();
663
+ this.settledByWire.set(wireKey, { outcome, requestId, at: now });
664
+ for (const [k, v] of this.settledByWire) {
665
+ if (now - v.at > McpFeedbackServer.SETTLED_WIRE_TTL_MS)
666
+ this.settledByWire.delete(k);
667
+ }
668
+ // Map 按插入序迭代,超限时先淘汰最早的
669
+ while (this.settledByWire.size > McpFeedbackServer.SETTLED_WIRE_MAX) {
670
+ const oldest = this.settledByWire.keys().next().value;
671
+ if (oldest === undefined)
672
+ break;
673
+ this.settledByWire.delete(oldest);
674
+ }
675
+ }
609
676
  /**
610
677
  * 把等待结果翻译成 MCP 工具响应(主等待与重复投递 join 的等待共用同一段收尾语义)。
611
678
  */
@@ -687,7 +754,7 @@ class McpFeedbackServer {
687
754
  * 等待用户反馈:注册 pending 并挂起,结果通过 waiters 广播——
688
755
  * 首个调用与后续 join 进来的重复投递调用都会收到同一份结果。
689
756
  */
690
- waitForFeedback(request, timeoutMs) {
757
+ waitForFeedback(request, timeoutMs, wireKey) {
691
758
  return new Promise((resolve) => {
692
759
  const requestId = request.id;
693
760
  const projectDir = request.projectDir;
@@ -725,6 +792,7 @@ class McpFeedbackServer {
725
792
  remainingMs: timeoutMs,
726
793
  request,
727
794
  waiters,
795
+ wireId: wireKey,
728
796
  });
729
797
  // 抢跑兑现:空窗期的面板提交(优先,用户显式点了发送)与飞书暂存消息,
730
798
  // 本轮 pending 一注册立即作为回复提交;最后是忙时队列里排队的追加消息
@@ -933,13 +1001,43 @@ class McpFeedbackServer {
933
1001
  }
934
1002
  return false;
935
1003
  }
936
- /** 队列里是否有属于某项目空间的排队消息 */
1004
+ /** 队列里是否有属于某项目空间的排队消息(不看渠道开关:面板消息不受飞书队列开关约束) */
937
1005
  hasQueuedFor(projectDir) {
938
- if (!this.feishu.isQueueWhenBusy())
939
- return false;
940
1006
  const owner = this.normalizePath(projectDir);
941
1007
  return this.queuedInbound.some((q) => this.pathsRelated(this.normalizePath(q.forProjectDir), owner));
942
1008
  }
1009
+ /** 某项目空间的排队消息快照(随 /api/feedback/current 下发给面板展示队列列表) */
1010
+ queuedSnapshotFor(normalizedWs) {
1011
+ if (!normalizedWs)
1012
+ return [];
1013
+ return this.queuedInbound
1014
+ .filter((q) => this.pathsRelated(this.normalizePath(q.forProjectDir), normalizedWs))
1015
+ .map((q) => ({
1016
+ id: q.id,
1017
+ at: q.at,
1018
+ source: q.source,
1019
+ text: q.text,
1020
+ images: q.images.length,
1021
+ files: q.files.length,
1022
+ }));
1023
+ }
1024
+ /**
1025
+ * 撤回一条排队消息(面板队列列表的小叉触发)。
1026
+ * 飞书来源的消息同步回执「已撤回」,两边状态一致;面板消息撤掉后列表随轮询消失即回执。
1027
+ * 返回 false = 没找到(可能已被消费或过期),面板下一秒轮询自然对齐,无需特殊处理。
1028
+ */
1029
+ removeQueuedById(id) {
1030
+ const idx = this.queuedInbound.findIndex((q) => q.id === id);
1031
+ if (idx < 0)
1032
+ return false;
1033
+ const [item] = this.queuedInbound.splice(idx, 1);
1034
+ clearTimeout(item.expiryTimer);
1035
+ debugLog(`Queued message recalled (id=${id}, source=${item.source}, queueSize=${this.queuedInbound.length})`);
1036
+ if (item.source === 'feishu') {
1037
+ this.feishu.replyToMessage(item.messageId || undefined, item.chatId, '🗑️ 这条排队消息已在插件面板被撤回,不会送达 AI。');
1038
+ }
1039
+ return true;
1040
+ }
943
1041
  /**
944
1042
  * 用户回复了一张「已结束」的卡片(不在超时续接窗口内)→ 忙时排队:
945
1043
  * - 该项目已有新一轮在等 → 引导回复最新卡片(避免旧回复窜入错误轮次),视为已处理;
@@ -962,23 +1060,29 @@ class McpFeedbackServer {
962
1060
  /**
963
1061
  * 消息入队 + 回执用户「已排队」。每条消息带 60 分钟过期兜底:
964
1062
  * 到点仍未被 AI 读取(对话可能已结束)→ 引用回复告知未送达,绝不静默丢弃。
1063
+ * 飞书与面板消息共用同一个队列(严格按到达顺序),但回执链路分渠道:
1064
+ * 飞书走引用回复/表情,面板消息没有推送通道,靠轮询下发的队列列表反映在/不在。
965
1065
  */
966
- enqueueInbound(text, chatId, images, files, messageId, forProjectDir) {
1066
+ enqueueInbound(text, chatId, images, files, messageId, forProjectDir, source = 'feishu') {
967
1067
  // 上限保护:挤出最老的一条并告知未送达
968
1068
  while (this.queuedInbound.length >= McpFeedbackServer.QUEUE_MAX) {
969
1069
  const dropped = this.queuedInbound.shift();
970
1070
  if (!dropped)
971
1071
  break;
972
1072
  clearTimeout(dropped.expiryTimer);
973
- this.feishu.replyToMessage(dropped.messageId || undefined, dropped.chatId, '⚠️ 排队消息过多,这条消息已被挤出队列、未送达 AI,请稍后重发。');
1073
+ if (dropped.source === 'feishu') {
1074
+ this.feishu.replyToMessage(dropped.messageId || undefined, dropped.chatId, '⚠️ 排队消息过多,这条消息已被挤出队列、未送达 AI,请稍后重发。');
1075
+ }
974
1076
  }
975
1077
  const item = {
1078
+ id: `q${Date.now()}_${++this.queueSeq}`,
976
1079
  text,
977
1080
  chatId,
978
1081
  images,
979
1082
  files,
980
1083
  at: Date.now(),
981
1084
  messageId,
1085
+ source,
982
1086
  forProjectDir,
983
1087
  expiryTimer: undefined,
984
1088
  };
@@ -987,12 +1091,16 @@ class McpFeedbackServer {
987
1091
  if (idx < 0)
988
1092
  return; // 已被消费
989
1093
  this.queuedInbound.splice(idx, 1);
990
- this.feishu.replyToMessage(item.messageId || undefined, item.chatId, '⚠️ 这条排队消息等了 60 分钟仍未被 AI 读取(对话可能已结束),未能送达。需要的话请在 AI 下次询问时重发。');
1094
+ if (item.source === 'feishu') {
1095
+ this.feishu.replyToMessage(item.messageId || undefined, item.chatId, '⚠️ 这条排队消息等了 60 分钟仍未被 AI 读取(对话可能已结束),未能送达。需要的话请在 AI 下次询问时重发。');
1096
+ }
991
1097
  }, McpFeedbackServer.QUEUE_TTL_MS);
992
1098
  item.expiryTimer.unref?.();
993
1099
  this.queuedInbound.push(item);
994
- debugLog(`Inbound queued for busy AI (project=${forProjectDir}, queueSize=${this.queuedInbound.length})`);
995
- this.feishu.replyToMessage(messageId || undefined, chatId, `🤖 AI 正在工作中,这条消息已排队,将在「${this.projectName(forProjectDir)}」当前任务完成后自动读取。`);
1100
+ debugLog(`Inbound queued for busy AI (source=${source}, project=${forProjectDir}, queueSize=${this.queuedInbound.length})`);
1101
+ if (source === 'feishu') {
1102
+ this.feishu.replyToMessage(messageId || undefined, chatId, `🤖 AI 正在工作中,这条消息已排队,将在「${this.projectName(forProjectDir)}」当前任务完成后自动读取。`);
1103
+ }
996
1104
  }
997
1105
  /**
998
1106
  * 队列兑现:新一轮 pending 注册时,把属于该项目空间的所有排队消息合并成一次反馈送达,
@@ -1022,7 +1130,8 @@ class McpFeedbackServer {
1022
1130
  });
1023
1131
  const text = '[QUEUED_MESSAGES] 以下是用户在你执行上一轮任务期间「追加」发送的消息(当时你正忙,消息已排队暂存)。' +
1024
1132
  '注意:这些内容不是对你最新一轮工作摘要的回复,请结合任务上下文阅读并处理;' +
1025
- '如与你摘要中的询问冲突,以用户追加内容为准。处理完后照常调用 interactive_feedback 继续对话。\n\n' +
1133
+ '如与你摘要中的询问冲突,以用户追加内容为准。本次的 summary 用户并没有收到,如有必要可在下次带上。' +
1134
+ '处理完后照常调用 interactive_feedback 继续对话。\n\n' +
1026
1135
  `=== 追加消息(${matched.length} 条)===\n` +
1027
1136
  lines.join('\n\n');
1028
1137
  debugLog(`Consuming ${matched.length} queued message(s) for request: ${requestId}`);
@@ -1037,9 +1146,12 @@ class McpFeedbackServer {
1037
1146
  this.feishu.clearPending(requestId);
1038
1147
  // 标记为「飞书渠道 resolve」:插件面板据此重置,避免对着已消失的请求提交
1039
1148
  this.lastFeishuResolved = { id: requestId, at: Date.now() };
1040
- // 送达回执:给每条排队消息补表情
1149
+ // 送达回执:给每条飞书排队消息补表情(面板消息没有回执通道,
1150
+ // 其送达状态由轮询下发的队列列表体现——消费后列表随即清空)
1041
1151
  for (const m of matched) {
1042
- this.feishu.reactDone(m.messageId || undefined, m.chatId);
1152
+ if (m.source === 'feishu') {
1153
+ this.feishu.reactDone(m.messageId || undefined, m.chatId);
1154
+ }
1043
1155
  }
1044
1156
  }
1045
1157
  /** 「我的窗口」是否仍活着(无插件 host 无从判定,视为活跃) */
@@ -1293,9 +1405,66 @@ class McpFeedbackServer {
1293
1405
  feishu: this.feishu.getStatus(),
1294
1406
  feishuResolvedId: (this.lastFeishuResolved && Date.now() - this.lastFeishuResolved.at < 30000) ? this.lastFeishuResolved.id : null,
1295
1407
  pause: this.getPauseStateFor(chosen?.id),
1408
+ // 该窗口的忙时排队消息快照(飞书 + 面板同队列),供面板展示队列列表
1409
+ queued: this.queuedSnapshotFor(pollWs),
1296
1410
  }));
1297
1411
  return;
1298
1412
  }
1413
+ // 面板忙时排队:AI 正忙(该项目无等待中的请求)时,把面板消息排进忙时队列,
1414
+ // 与飞书消息共用同一队列(按到达顺序),下一轮 interactive_feedback 合并送达
1415
+ if (req.method === 'POST' && req.url === '/api/feedback/enqueue') {
1416
+ let body = '';
1417
+ req.on('data', (chunk) => { body += chunk.toString(); });
1418
+ req.on('end', () => {
1419
+ try {
1420
+ const { text, images, attachedFiles, projectDir } = JSON.parse(body);
1421
+ if (!projectDir) {
1422
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1423
+ res.end(JSON.stringify({ queued: false, reason: 'projectDir required' }));
1424
+ return;
1425
+ }
1426
+ // 忙时排队是全局开关(面板 UI 已隐藏排队入口,这里兜底防竞态)
1427
+ if (!this.feishu.isQueueWhenBusy()) {
1428
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1429
+ res.end(JSON.stringify({ queued: false, reason: 'disabled' }));
1430
+ return;
1431
+ }
1432
+ // 该项目有等待中的请求 → 不该排队,面板应直接提交(正常轮询下一秒就会显示该请求)
1433
+ if (this.hasPendingForProject(projectDir)) {
1434
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1435
+ res.end(JSON.stringify({ queued: false, reason: 'pending' }));
1436
+ return;
1437
+ }
1438
+ this.enqueueInbound(text || '', '', images || [], attachedFiles || [], '', projectDir, 'panel');
1439
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1440
+ res.end(JSON.stringify({ queued: true }));
1441
+ }
1442
+ catch {
1443
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1444
+ res.end(JSON.stringify({ queued: false, reason: 'invalid body' }));
1445
+ }
1446
+ });
1447
+ return;
1448
+ }
1449
+ // 撤回排队消息:面板队列列表的删除按钮触发,按队列项 id 定位。
1450
+ // 飞书来源的消息由 removeQueuedById 同步回执,保证两边状态一致
1451
+ if (req.method === 'POST' && req.url === '/api/feedback/queue/remove') {
1452
+ let body = '';
1453
+ req.on('data', (chunk) => { body += chunk.toString(); });
1454
+ req.on('end', () => {
1455
+ try {
1456
+ const { id } = JSON.parse(body);
1457
+ const removed = !!id && this.removeQueuedById(id);
1458
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1459
+ res.end(JSON.stringify({ removed }));
1460
+ }
1461
+ catch {
1462
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1463
+ res.end(JSON.stringify({ removed: false }));
1464
+ }
1465
+ });
1466
+ return;
1467
+ }
1299
1468
  // 提交反馈
1300
1469
  if (req.method === 'POST' && req.url === '/api/feedback/submit') {
1301
1470
  let body = '';
@@ -1342,6 +1511,30 @@ class McpFeedbackServer {
1342
1511
  return;
1343
1512
  }
1344
1513
  // 配置飞书(来自插件 UI;凭证未变只补 chatId,变了则重建长连接)
1514
+ // 扫码一键创建飞书应用:启动 Device Grant 流程并返回待扫码链接(等二维码就绪才响应)
1515
+ if (req.method === 'POST' && req.url === '/api/feishu/register/start') {
1516
+ this.feishu.startRegister().then((state) => {
1517
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1518
+ res.end(JSON.stringify(state));
1519
+ }).catch(() => {
1520
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1521
+ res.end(JSON.stringify({ status: 'error', error: 'internal' }));
1522
+ });
1523
+ return;
1524
+ }
1525
+ // 扫码创建流程状态(插件轮询直到 success / error)
1526
+ if (req.method === 'GET' && req.url === '/api/feishu/register/status') {
1527
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1528
+ res.end(JSON.stringify(this.feishu.getRegisterState()));
1529
+ return;
1530
+ }
1531
+ // 取消进行中的扫码创建流程(用户关闭设置弹窗)
1532
+ if (req.method === 'POST' && req.url === '/api/feishu/register/cancel') {
1533
+ this.feishu.cancelRegister();
1534
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1535
+ res.end(JSON.stringify({ ok: true }));
1536
+ return;
1537
+ }
1345
1538
  if (req.method === 'POST' && req.url === '/api/feishu/config') {
1346
1539
  let body = '';
1347
1540
  req.on('data', (chunk) => { body += chunk.toString(); });
@@ -1541,7 +1734,7 @@ class McpFeedbackServer {
1541
1734
  res.writeHead(200, { 'Content-Type': 'application/json' });
1542
1735
  res.end(JSON.stringify({
1543
1736
  status: 'ok',
1544
- version: '0.0.1',
1737
+ version: PKG_VERSION,
1545
1738
  hasCurrentRequest: this.currentRequest !== null,
1546
1739
  pid: process.pid,
1547
1740
  }));
@@ -1792,12 +1985,8 @@ McpFeedbackServer.QUEUE_MAX = 100;
1792
1985
  *(只剩别的窗口在扫端口)→ 视为我的窗口已关、本实例是僵尸:不再上报 pending,避免污染全局计数。
1793
1986
  */
1794
1987
  McpFeedbackServer.OWNER_IDLE_MS = 12000;
1795
- /**
1796
- * 查找可 join 的「重复投递」等待:同窗口 + 同 summary + 注册时间在 DUP_JOIN_MS 内。
1797
- * AI 每一轮的 summary 几乎不可能与上一轮一字不差,短窗口内完全相同基本可断定为
1798
- * 客户端/传输层对同一次 tool call 的重复投递。
1799
- */
1800
- McpFeedbackServer.DUP_JOIN_MS = 90000;
1988
+ McpFeedbackServer.SETTLED_WIRE_TTL_MS = 10 * 60 * 1000;
1989
+ McpFeedbackServer.SETTLED_WIRE_MAX = 20;
1801
1990
  // 主函数
1802
1991
  async function main() {
1803
1992
  const port = 61927;
@@ -20,7 +20,7 @@
20
20
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/></svg>
21
21
  </button>
22
22
  <button id="autoRetryBtn" class="iconbtn" type="button" aria-label="Timeout keep-waiting / 超时续期" data-tip="Timeout keep-waiting / 超时续期">
23
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg>
23
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 22h14"/><path d="M5 2h14"/><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/></svg>
24
24
  </button>
25
25
  <button id="themeAccentBtn" class="iconbtn" type="button" aria-label="Accent color / 主题色" data-tip="Accent color / 主题色">
26
26
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.555C21.965 6.012 17.461 2 12 2z"/></svg>
@@ -37,13 +37,20 @@
37
37
  </div>
38
38
  </header>
39
39
 
40
- <!-- 等待态 -->
40
+ <!-- 等待态(排队模式下排队消息列表内嵌在此卡片中,整体高度由分隔条控制) -->
41
41
  <section id="waitingStatus" class="empty">
42
42
  <div class="empty__icon" aria-hidden="true">
43
43
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
44
44
  </div>
45
45
  <p class="empty__title">{{i18n.waitingForAI}}</p>
46
46
  <p class="empty__hint">{{i18n.waitingHint}}</p>
47
+ <div id="queueCard" class="waiting-queue hidden">
48
+ <div class="waiting-queue__head">
49
+ <span class="waiting-queue__title">{{i18n.queuedTitle}}</span>
50
+ <span id="queueCount" class="queue-count"></span>
51
+ </div>
52
+ <div id="queueList" class="queue-list"></div>
53
+ </div>
47
54
  </section>
48
55
 
49
56
  <!-- 反馈表单 -->
@@ -55,6 +62,16 @@
55
62
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/></svg>
56
63
  </span>
57
64
  <h2 class="card__title">{{i18n.aiSummary}}</h2>
65
+ <!-- 历史摘要导航:存过 2 条以上时显示,可回看之前轮次的 AI 摘要 -->
66
+ <div id="summaryNav" class="summary-nav" hidden>
67
+ <button id="summaryPrevBtn" class="iconbtn summary-nav__btn" type="button" aria-label="{{i18n.summaryPrevTip}}" data-tip="{{i18n.summaryPrevTip}}">
68
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
69
+ </button>
70
+ <span id="summaryNavPos" class="summary-nav__pos"></span>
71
+ <button id="summaryNextBtn" class="iconbtn summary-nav__btn" type="button" aria-label="{{i18n.summaryNextTip}}" data-tip="{{i18n.summaryNextTip}}">
72
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
73
+ </button>
74
+ </div>
58
75
  </div>
59
76
  <div id="summaryContent" class="summary markdown-body"></div>
60
77
  <div id="projectInfo" class="project-chip"></div>
@@ -183,7 +200,17 @@
183
200
  </div>
184
201
  </div>
185
202
 
186
- <!-- 区块②:飞书通知(推送开关 + 凭证 + 绑定状态) -->
203
+ <!-- 区块②:忙时消息排队(全局开关:飞书与面板消息共用忙时队列) -->
204
+ <div class="feishu-section">
205
+ <label class="feishu-switch">
206
+ <span class="feishu-switch__label feishu-switch__label--strong">{{i18n.queueWhenBusyLabel}}</span>
207
+ <input id="queueWhenBusyToggle" type="checkbox" class="feishu-switch__input">
208
+ <span class="feishu-switch__track"><span class="feishu-switch__thumb"></span></span>
209
+ </label>
210
+ <p class="feishu-modal__desc">{{i18n.queueWhenBusyDesc}}</p>
211
+ </div>
212
+
213
+ <!-- 区块③:飞书通知(推送开关 + 凭证 + 绑定状态) -->
187
214
  <div class="feishu-section">
188
215
  <label class="feishu-switch">
189
216
  <span class="feishu-switch__label feishu-switch__label--strong">{{i18n.feishuSettings}}</span>
@@ -199,14 +226,6 @@
199
226
  </label>
200
227
  <p class="feishu-modal__desc feishu-modal__desc--sub">{{i18n.feishuAckDesc}}</p>
201
228
  </div>
202
- <div id="feishuQueueSub" class="feishu-subitem">
203
- <label class="feishu-switch">
204
- <span class="feishu-switch__label">{{i18n.feishuQueueLabel}}</span>
205
- <input id="feishuQueueToggle" type="checkbox" class="feishu-switch__input">
206
- <span class="feishu-switch__track"><span class="feishu-switch__thumb"></span></span>
207
- </label>
208
- <p class="feishu-modal__desc feishu-modal__desc--sub">{{i18n.feishuQueueDesc}}</p>
209
- </div>
210
229
  <label class="feishu-field">
211
230
  <span class="feishu-field__label">{{i18n.feishuAppId}}</span>
212
231
  <input id="feishuAppId" class="feishu-input" type="text" placeholder="{{i18n.feishuAppIdPlaceholder}}" autocomplete="off" spellcheck="false">
@@ -226,7 +245,16 @@
226
245
  <span id="feishuStatusText" class="feishu-status__text">{{i18n.feishuStatusUnconfigured}}</span>
227
246
  </div>
228
247
  <div class="feishu-modal__actions">
229
- <button id="feishuGuideBtn" class="feishu-link" type="button">{{i18n.feishuGuide}}</button>
248
+ <button id="feishuRegisterBtn" class="feishu-link feishu-link--primary" type="button">{{i18n.feishuRegisterBtn}}</button>
249
+ </div>
250
+ <!-- 扫码一键创建应用:点击上方按钮后展开,展示验证二维码与流程状态 -->
251
+ <div id="feishuRegisterPanel" class="feishu-register hidden">
252
+ <img id="feishuRegisterQr" class="feishu-register__qr hidden" alt="QR code">
253
+ <p id="feishuRegisterHint" class="feishu-register__hint"></p>
254
+ <div class="feishu-register__actions">
255
+ <button id="feishuRegisterOpenBtn" class="feishu-link hidden" type="button">{{i18n.registerOpenLink}}</button>
256
+ <button id="feishuRegisterRetryBtn" class="feishu-link hidden" type="button">{{i18n.registerRetry}}</button>
257
+ </div>
230
258
  </div>
231
259
  <p class="feishu-modal__hint">{{i18n.feishuHint}}</p>
232
260
  </div>