cursor-feedback 2.1.2 → 2.2.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ## [2.2.0](https://github.com/jianger666/cursor-feedback-extension/compare/v2.1.2...v2.2.0) (2026-07-03)
6
+
7
+
8
+ ### Features
9
+
10
+ * **feishu:** 忙时消息排队——AI 干活期间的飞书消息不再丢失 ([fd8aa75](https://github.com/jianger666/cursor-feedback-extension/commit/fd8aa7540a787dbb44e85f35ea137a0c0ea9c03e))
11
+
5
12
  ### [2.1.2](https://github.com/jianger666/cursor-feedback-extension/compare/v2.1.1...v2.1.2) (2026-07-03)
6
13
 
7
14
 
package/README.md CHANGED
@@ -227,6 +227,7 @@ Either way works — but you first need to set up the bot in the Feishu console:
227
227
  | `FEISHU_APP_SECRET` | - | Feishu app App Secret |
228
228
  | `FEISHU_ENABLED` | `true` | Whether to push feedback to Feishu. Set to `false` to disable |
229
229
  | `FEISHU_ACK` | `true` | Whether to react with a "Get" emoji after you reply. Set to `false` to disable |
230
+ | `FEISHU_QUEUE` | `true` | Queue messages while the AI is busy: messages sent when no feedback request is waiting are queued and auto-delivered on the AI's next feedback round, prefixed with an "appended during the task" hint; the bot acknowledges with a "queued" reply. Set to `false` to disable (also toggleable in the panel's notification settings) |
230
231
 
231
232
  > Priority: **panel config (when credentials are filled) > env here > default**. The panel wins when App ID/Secret are filled; otherwise it falls back to env. You still need to send the bot one message in Feishu to complete binding.
232
233
 
package/README_CN.md CHANGED
@@ -231,6 +231,7 @@ npm install -g cursor-feedback
231
231
  | `FEISHU_APP_SECRET` | - | 飞书应用 App Secret |
232
232
  | `FEISHU_ENABLED` | `true` | 是否推送反馈到飞书,设 `false` 关闭 |
233
233
  | `FEISHU_ACK` | `true` | 收到你的回复后是否回「Get」表情回执,设 `false` 关闭 |
234
+ | `FEISHU_QUEUE` | `true` | 忙时消息排队:AI 正忙(没有等待中的反馈)时你发的消息先排队,等 AI 下一轮询问时自动送达并附「任务期间追加」提示;排队时机器人会回执「已排队,任务完成后自动读取」。设 `false` 关闭(也可在面板「通知设置」里切换) |
234
235
 
235
236
  > 优先级:**插件面板(填了凭证)> 这里的 env > 默认**。面板里填了 App ID/Secret 就以面板为准;没填则回退到 env。首次仍需在飞书里给机器人发一条消息完成绑定。
236
237
 
package/dist/extension.js CHANGED
@@ -177,6 +177,8 @@ class FeedbackViewProvider {
177
177
  this._feishuEnabled = true;
178
178
  // Get 表情回执子开关(飞书通知子项;关掉后用户飞书回复不加 Get 表情、也不发文字兜底)
179
179
  this._feishuAck = true;
180
+ // 忙时消息排队子开关(飞书通知子项;AI 正忙时用户消息入队,下一轮 feedback 自动送达)
181
+ this._feishuQueue = true;
180
182
  this._debugInfo = {
181
183
  portRange: '',
182
184
  workspacePath: '',
@@ -194,6 +196,7 @@ class FeedbackViewProvider {
194
196
  this._feishuConfig = { appId: fc.appId || '', appSecret: fc.appSecret || '' };
195
197
  this._feishuEnabled = this._memento?.get('feishuEnabled', true) ?? true;
196
198
  this._feishuAck = this._memento?.get('feishuAck', true) ?? true;
199
+ this._feishuQueue = this._memento?.get('feishuQueue', true) ?? true;
197
200
  }
198
201
  /**
199
202
  * 获取翻译消息
@@ -277,6 +280,9 @@ class FeedbackViewProvider {
277
280
  case 'toggleFeishuAck':
278
281
  await this._handleToggleFeishuAck(!!data.payload?.enabled);
279
282
  break;
283
+ case 'toggleFeishuQueue':
284
+ await this._handleToggleFeishuQueue(!!data.payload?.enabled);
285
+ break;
280
286
  case 'testNotification':
281
287
  this._sendTestNotification();
282
288
  break;
@@ -973,6 +979,7 @@ class FeedbackViewProvider {
973
979
  bound: this._feishuBound,
974
980
  feishuEnabled: this._feishuEnabled,
975
981
  feishuAck: this._feishuAck,
982
+ feishuQueue: this._feishuQueue,
976
983
  systemNotification: !!systemNotification,
977
984
  osNotification: !!osNotification
978
985
  }
@@ -1007,6 +1014,7 @@ class FeedbackViewProvider {
1007
1014
  const appSecret = feishuStatus.appSecret || '';
1008
1015
  const enabled = feishuStatus.enabled !== false;
1009
1016
  const ack = feishuStatus.ackReaction !== false;
1017
+ const queue = feishuStatus.queueWhenBusy !== false;
1010
1018
  const bound = !!feishuStatus.boundChatId;
1011
1019
  let changed = false;
1012
1020
  if (appId !== this._feishuConfig.appId || appSecret !== this._feishuConfig.appSecret) {
@@ -1023,6 +1031,10 @@ class FeedbackViewProvider {
1023
1031
  this._feishuAck = ack;
1024
1032
  changed = true;
1025
1033
  }
1034
+ if (queue !== this._feishuQueue) {
1035
+ this._feishuQueue = queue;
1036
+ changed = true;
1037
+ }
1026
1038
  if (bound !== this._feishuBound) {
1027
1039
  this._feishuBound = bound;
1028
1040
  changed = true;
@@ -1041,6 +1053,7 @@ class FeedbackViewProvider {
1041
1053
  appSecret,
1042
1054
  enabled: this._feishuEnabled,
1043
1055
  ackReaction: this._feishuAck,
1056
+ queueWhenBusy: this._feishuQueue,
1044
1057
  });
1045
1058
  for (const port of ports) {
1046
1059
  this._httpPost(`http://127.0.0.1:${port}/api/feishu/config`, body).catch(() => { });
@@ -1100,6 +1113,15 @@ class FeedbackViewProvider {
1100
1113
  this._broadcastFeishuConfig();
1101
1114
  this._postFeishuState();
1102
1115
  }
1116
+ /**
1117
+ * 切换「忙时消息排队」子开关(飞书通知的子项;透传到 server 端,控制忙时队列)
1118
+ */
1119
+ async _handleToggleFeishuQueue(enabled) {
1120
+ this._feishuQueue = enabled;
1121
+ await this._memento?.update('feishuQueue', enabled);
1122
+ this._broadcastFeishuConfig();
1123
+ this._postFeishuState();
1124
+ }
1103
1125
  /**
1104
1126
  * HTTP GET 请求
1105
1127
  */
package/dist/feishu.js CHANGED
@@ -63,6 +63,8 @@ class FeishuBridge {
63
63
  this.enabled = true;
64
64
  /** Get 表情轻回执开关(false 时用户回复后不加表情、也不发文字兜底) */
65
65
  this.ackReaction = true;
66
+ /** 忙时消息排队开关(true 时 AI 正忙的消息入队等下一轮,false 走旧的短暂存 + 过期提示) */
67
+ this.queueWhenBusy = true;
66
68
  /** 绑定关系(appId -> chat_id)持久化到磁盘:多个 server 进程共享、reload 不丢 */
67
69
  this.bindStorePath = path.join(os.homedir(), '.cursor-feedback', 'feishu-bind.json');
68
70
  /** 飞书凭证持久化到磁盘:多个 server 进程共享、reload/重启不丢,作为凭证的全局真相源 */
@@ -71,6 +73,9 @@ class FeishuBridge {
71
73
  this.cardToRequest = new Map();
72
74
  // requestId -> 卡片 message_id(清理用)
73
75
  this.requestToCard = new Map();
76
+ // requestId -> 归属项目空间:请求结束后仍保留(与卡片映射同生命周期),
77
+ // 供「回复已结束的旧卡片 → 忙时排队到对应项目」定位归属,随 FIFO 上界一起淘汰
78
+ this.requestProjects = new Map();
74
79
  // requestId -> 摘要信息(多窗口「列清单」提示用)
75
80
  this.pendingSummaries = new Map();
76
81
  /** 收到用户回复时的回调(由 mcp-server 注入,负责路由 / 转发) */
@@ -92,11 +97,15 @@ class FeishuBridge {
92
97
  connected: this.connected,
93
98
  enabled: this.enabled,
94
99
  ackReaction: this.ackReaction,
100
+ queueWhenBusy: this.queueWhenBusy,
95
101
  appId: this.config?.appId || '',
96
102
  appSecret: this.config?.appSecret || '',
97
103
  boundChatId: this.getBoundChatId(),
98
104
  };
99
105
  }
106
+ isQueueWhenBusy() {
107
+ return this.queueWhenBusy;
108
+ }
100
109
  isConfigured() {
101
110
  return !!this.config;
102
111
  }
@@ -175,9 +184,10 @@ class FeishuBridge {
175
184
  * - 凭证为空:视为「关闭飞书」
176
185
  */
177
186
  async configure(config) {
178
- // 通知开关 / Get 表情回执开关随时可改(与凭证无关),总是更新
187
+ // 通知开关 / Get 表情回执开关 / 排队开关随时可改(与凭证无关),总是更新
179
188
  this.enabled = config.enabled !== false;
180
189
  this.ackReaction = config.ackReaction !== false;
190
+ this.queueWhenBusy = config.queueWhenBusy !== false;
181
191
  const sameCred = this.config &&
182
192
  this.config.appId === config.appId &&
183
193
  this.config.appSecret === config.appSecret;
@@ -514,11 +524,14 @@ class FeishuBridge {
514
524
  break;
515
525
  const oldReq = this.cardToRequest.get(oldest);
516
526
  this.cardToRequest.delete(oldest);
517
- if (oldReq !== undefined)
527
+ if (oldReq !== undefined) {
518
528
  this.requestToCard.delete(oldReq);
529
+ this.requestProjects.delete(oldReq);
530
+ }
519
531
  }
520
532
  this.cardToRequest.set(messageId, requestId);
521
533
  this.requestToCard.set(requestId, messageId);
534
+ this.requestProjects.set(requestId, projectDir);
522
535
  this.pendingSummaries.set(requestId, { summary, projectDir });
523
536
  }
524
537
  return messageId;
@@ -534,6 +547,10 @@ class FeishuBridge {
534
547
  return null;
535
548
  return this.cardToRequest.get(parentId) || null;
536
549
  }
550
+ /** 查某 requestId 卡片的归属项目空间(请求结束后仍可查,用于忙时排队路由) */
551
+ projectDirOf(requestId) {
552
+ return this.requestProjects.get(requestId) || null;
553
+ }
537
554
  /** 本实例当前待回复的请求数(用于多窗口判断) */
538
555
  pendingCount() {
539
556
  return this.pendingSummaries.size;
package/dist/i18n/en.json CHANGED
@@ -73,6 +73,8 @@
73
73
  "osNotifyDesc": "Sends a system notification when the IDE is in the background.",
74
74
  "feishuAckLabel": "Get emoji acknowledgement",
75
75
  "feishuAckDesc": "After you reply in Feishu, the bot adds a Get emoji as acknowledgement.",
76
+ "feishuQueueLabel": "Queue messages while AI is busy",
77
+ "feishuQueueDesc": "Messages sent while the AI is busy are queued and auto-delivered on its next feedback round, marked as appended during the task.",
76
78
  "quickReplyDefault1": "Keep waiting for my feedback",
77
79
  "quickReplyDefault2": "End the task",
78
80
  "quickReplyEdit": "Edit quick replies",
@@ -73,6 +73,8 @@
73
73
  "osNotifyDesc": "IDE 切到后台时,用系统通知提醒你。",
74
74
  "feishuAckLabel": "Get 表情回执",
75
75
  "feishuAckDesc": "你在飞书回复后,机器人加个 Get 表情表示「已收到」。",
76
+ "feishuQueueLabel": "忙时消息排队",
77
+ "feishuQueueDesc": "AI 正忙时你发的消息先排队,等它下一轮询问时自动送达,并附「任务期间追加」提示。",
76
78
  "quickReplyDefault1": "继续等待我的反馈",
77
79
  "quickReplyDefault2": "结束任务",
78
80
  "quickReplyEdit": "编辑快捷短语",
@@ -94,6 +94,12 @@ class McpFeedbackServer {
94
94
  this.recentlyTimedOut = new Map();
95
95
  /** 面板在超时空窗内提交的反馈:暂存到下一轮 pending 注册时立即兑现 */
96
96
  this.panelStash = null;
97
+ /**
98
+ * 忙时消息队列:AI 正在干活(该项目空间没有等待中的反馈请求)时用户发来的飞书消息
99
+ * 不再丢弃,而是按项目空间排队;等 AI 下一轮调 interactive_feedback 时合并送达,
100
+ * 并附「任务期间追加」提示头。开关见 FeishuBridge.queueWhenBusy(面板 / FEISHU_QUEUE)。
101
+ */
102
+ this.queuedInbound = [];
97
103
  /** 暂存过期提示定时器:到点仍未被认领则「回复」那条消息告知没送到,避免静默丢弃 */
98
104
  this.stashExpiryTimer = null;
99
105
  // 最近一次被飞书回复 resolve 的请求:供插件端精确区分「飞书回复」与「超时」,
@@ -297,8 +303,10 @@ class McpFeedbackServer {
297
303
  debugLog(`Project: ${projectDir}`);
298
304
  debugLog(`Timeout: ${timeout}s`);
299
305
  debugLog(`Waiting for VS Code extension to collect feedback...`);
300
- // 飞书:已配置则推送一张反馈请求卡片(失败不影响插件主流程)
301
- if (this.feishu.isConfigured()) {
306
+ // 飞书:已配置则推送一张反馈请求卡片(失败不影响插件主流程)。
307
+ // 例外:忙时队列里已有本项目的排队消息 → 本轮会在注册后立即被队列兑现,
308
+ // 卡片发出去马上就过期(用户回复只会得到「已结束」),干脆不发。
309
+ if (this.feishu.isConfigured() && !this.hasQueuedFor(projectDir)) {
302
310
  this.feishu.sendFeedbackCard(requestId, summary, projectDir).catch(() => { });
303
311
  }
304
312
  try {
@@ -353,6 +361,9 @@ class McpFeedbackServer {
353
361
  else if (this.maybeStashForEndedCard(reqId, text, chatId, images, files, messageId)) {
354
362
  // 卡片刚超时、AI 正要续期重调 → 暂存续接到下一轮(超时未认领由 armStashExpiryNotice 回执)
355
363
  }
364
+ else if (this.queueForEndedCard(reqId, text, chatId, images, files, messageId)) {
365
+ // 忙时排队:AI 正忙(该项目无等待中的请求)→ 消息入队,等下一轮 feedback 自动送达
366
+ }
356
367
  else {
357
368
  // 卡片是本实例发的,但请求确实已结束(已被回复 / 超时太久)→ 明确告知
358
369
  this.feishu.replyText(chatId, '这条反馈已经结束了(可能已超时或已被回复)。');
@@ -375,8 +386,16 @@ class McpFeedbackServer {
375
386
  const remoteCount = remote.reduce((n, r) => n + r.list.length, 0);
376
387
  const globalCount = localPending.length + remoteCount;
377
388
  if (globalCount === 0) {
378
- // 抢跑兜底:此刻全局无人等待,但很可能 AI 正要发起下一轮(卡片还没注册)。
379
- // 暂存到收到消息的本实例,等下一轮 pending 注册时立即兑现。
389
+ // 忙时排队优先:全局无人等待 = AI 大概率正在干活。定位到唯一的活跃项目窗口时
390
+ // 直接把消息排队给它(含回执「已排队」),多个活跃窗口则引导用户回复对应卡片。
391
+ // 复用上面 queryRemotePending 的扫描结果,避免二次全端口扫描。
392
+ if (this.feishu.isQueueWhenBusy()) {
393
+ const routed = await this.routeOrphanToQueue(text, chatId, images, files, messageId, remote);
394
+ if (routed)
395
+ return;
396
+ }
397
+ // 抢跑兜底:定位不到活跃窗口(或队列关闭)时,退回原有短暂存——很可能 AI 正要
398
+ // 发起下一轮(卡片还没注册),等 pending 注册时立即兑现。
380
399
  // 关键:此刻没有任何 AI 在等待,绝不能给「✅ 已收到」回执——那是虚假承诺,会让用户
381
400
  // 误以为消息已被接收(实则 AI 这轮可能已结束、永远不会来认领,消息石沉大海)。
382
401
  // 回执只在「真正送达某个等待中的请求」时给(见 tryConsumeStash → submitFromFeishu)。
@@ -495,7 +514,8 @@ class McpFeedbackServer {
495
514
  return Promise.all(posts).then((results) => results.some(Boolean));
496
515
  }
497
516
  /**
498
- * 向其他窗口的 server 查询各自的 pending 列表(仅项目名,用于全局视角判断 + 提示文案)。
517
+ * 向其他窗口的 server 查询各自的 pending 列表(仅项目名,用于全局视角判断 + 提示文案),
518
+ * 以及实例归属窗口的存活状态(忙时排队用来定位「唯一活跃窗口」)。
499
519
  * 用于无 parent_id 的「无主消息」:飞书只推给一个窗口,需汇总全局才能正确决策。
500
520
  */
501
521
  queryRemotePending() {
@@ -508,21 +528,27 @@ class McpFeedbackServer {
508
528
  }
509
529
  fetchRemotePending(port) {
510
530
  return new Promise((resolve) => {
531
+ const empty = { port, list: [], ownerWorkspace: null, ownerAlive: false };
511
532
  const req = http.request({ hostname: '127.0.0.1', port, path: '/api/feishu/pending', method: 'GET', timeout: 1500 }, (res) => {
512
533
  let body = '';
513
534
  res.on('data', (chunk) => { body += chunk.toString(); });
514
535
  res.on('end', () => {
515
536
  try {
516
537
  const j = JSON.parse(body);
517
- resolve({ port, list: Array.isArray(j.list) ? j.list : [] });
538
+ resolve({
539
+ port,
540
+ list: Array.isArray(j.list) ? j.list : [],
541
+ ownerWorkspace: j.ownerWorkspace || null,
542
+ ownerAlive: !!j.ownerAlive,
543
+ });
518
544
  }
519
545
  catch {
520
- resolve({ port, list: [] });
546
+ resolve(empty);
521
547
  }
522
548
  });
523
549
  });
524
- req.on('error', () => resolve({ port, list: [] }));
525
- req.on('timeout', () => { req.destroy(); resolve({ port, list: [] }); });
550
+ req.on('error', () => resolve(empty));
551
+ req.on('timeout', () => { req.destroy(); resolve(empty); });
526
552
  req.end();
527
553
  });
528
554
  }
@@ -701,9 +727,10 @@ class McpFeedbackServer {
701
727
  waiters,
702
728
  });
703
729
  // 抢跑兑现:空窗期的面板提交(优先,用户显式点了发送)与飞书暂存消息,
704
- // 本轮 pending 一注册立即作为回复提交
730
+ // 本轮 pending 一注册立即作为回复提交;最后是忙时队列里排队的追加消息
705
731
  this.tryConsumePanelStash(requestId);
706
732
  this.tryConsumeStash(requestId);
733
+ this.tryConsumeQueue(requestId);
707
734
  });
708
735
  }
709
736
  /**
@@ -896,6 +923,202 @@ class McpFeedbackServer {
896
923
  this.pendingRequests.delete(requestId);
897
924
  this.feishu.clearPending(requestId);
898
925
  }
926
+ // ==================== 忙时消息队列 ====================
927
+ /** 某项目空间当前是否有等待中的反馈请求(路径互为前缀视为同一窗口语境) */
928
+ hasPendingForProject(projectDir) {
929
+ const owner = this.normalizePath(projectDir);
930
+ for (const [, p] of this.pendingRequests) {
931
+ if (this.pathsRelated(this.normalizePath(p.projectDir), owner))
932
+ return true;
933
+ }
934
+ return false;
935
+ }
936
+ /** 队列里是否有属于某项目空间的排队消息 */
937
+ hasQueuedFor(projectDir) {
938
+ if (!this.feishu.isQueueWhenBusy())
939
+ return false;
940
+ const owner = this.normalizePath(projectDir);
941
+ return this.queuedInbound.some((q) => this.pathsRelated(this.normalizePath(q.forProjectDir), owner));
942
+ }
943
+ /**
944
+ * 用户回复了一张「已结束」的卡片(不在超时续接窗口内)→ 忙时排队:
945
+ * - 该项目已有新一轮在等 → 引导回复最新卡片(避免旧回复窜入错误轮次),视为已处理;
946
+ * - 该项目没有等待中的请求(AI 正忙)→ 入队,等下一轮 feedback 自动送达。
947
+ * 返回 true 表示已处理(排队或已引导),false 表示队列关闭 / 定位不到归属,走原有「已结束」回执。
948
+ */
949
+ queueForEndedCard(reqId, text, chatId, images, files, messageId) {
950
+ if (!this.feishu.isQueueWhenBusy())
951
+ return false;
952
+ const cardProject = this.feishu.projectDirOf(reqId);
953
+ if (!cardProject)
954
+ return false;
955
+ if (this.hasPendingForProject(cardProject)) {
956
+ this.feishu.replyToMessage(messageId || undefined, chatId, '这张卡片的反馈已结束,且该项目有新的反馈卡片正在等待。请「回复」最新的那张卡片。');
957
+ return true;
958
+ }
959
+ this.enqueueInbound(text, chatId, images, files, messageId, cardProject);
960
+ return true;
961
+ }
962
+ /**
963
+ * 消息入队 + 回执用户「已排队」。每条消息带 60 分钟过期兜底:
964
+ * 到点仍未被 AI 读取(对话可能已结束)→ 引用回复告知未送达,绝不静默丢弃。
965
+ */
966
+ enqueueInbound(text, chatId, images, files, messageId, forProjectDir) {
967
+ // 上限保护:挤出最老的一条并告知未送达
968
+ while (this.queuedInbound.length >= McpFeedbackServer.QUEUE_MAX) {
969
+ const dropped = this.queuedInbound.shift();
970
+ if (!dropped)
971
+ break;
972
+ clearTimeout(dropped.expiryTimer);
973
+ this.feishu.replyToMessage(dropped.messageId || undefined, dropped.chatId, '⚠️ 排队消息过多,这条消息已被挤出队列、未送达 AI,请稍后重发。');
974
+ }
975
+ const item = {
976
+ text,
977
+ chatId,
978
+ images,
979
+ files,
980
+ at: Date.now(),
981
+ messageId,
982
+ forProjectDir,
983
+ expiryTimer: undefined,
984
+ };
985
+ item.expiryTimer = setTimeout(() => {
986
+ const idx = this.queuedInbound.indexOf(item);
987
+ if (idx < 0)
988
+ return; // 已被消费
989
+ this.queuedInbound.splice(idx, 1);
990
+ this.feishu.replyToMessage(item.messageId || undefined, item.chatId, '⚠️ 这条排队消息等了 60 分钟仍未被 AI 读取(对话可能已结束),未能送达。需要的话请在 AI 下次询问时重发。');
991
+ }, McpFeedbackServer.QUEUE_TTL_MS);
992
+ item.expiryTimer.unref?.();
993
+ 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)}」当前任务完成后自动读取。`);
996
+ }
997
+ /**
998
+ * 队列兑现:新一轮 pending 注册时,把属于该项目空间的所有排队消息合并成一次反馈送达,
999
+ * 正文附「任务期间追加」提示头,并给每条排队消息补 ✅ 送达回执。
1000
+ */
1001
+ tryConsumeQueue(requestId) {
1002
+ const pending = this.pendingRequests.get(requestId);
1003
+ if (!pending)
1004
+ return;
1005
+ if (this.queuedInbound.length === 0)
1006
+ return;
1007
+ const owner = this.normalizePath(pending.projectDir);
1008
+ const matched = this.queuedInbound.filter((q) => this.pathsRelated(this.normalizePath(q.forProjectDir), owner));
1009
+ if (matched.length === 0)
1010
+ return;
1011
+ this.queuedInbound = this.queuedInbound.filter((q) => !matched.includes(q));
1012
+ for (const m of matched)
1013
+ clearTimeout(m.expiryTimer);
1014
+ const two = (n) => String(n).padStart(2, '0');
1015
+ const fmt = (at) => {
1016
+ const d = new Date(at);
1017
+ return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}`;
1018
+ };
1019
+ const lines = matched.map((m) => {
1020
+ const body = m.text || (m.images.length || m.files.length ? '[图片/附件]' : '');
1021
+ return `[${fmt(m.at)}] ${body}`;
1022
+ });
1023
+ const text = '[QUEUED_MESSAGES] 以下是用户在你执行上一轮任务期间「追加」发送的消息(当时你正忙,消息已排队暂存)。' +
1024
+ '注意:这些内容不是对你最新一轮工作摘要的回复,请结合任务上下文阅读并处理;' +
1025
+ '如与你摘要中的询问冲突,以用户追加内容为准。处理完后照常调用 interactive_feedback 继续对话。\n\n' +
1026
+ `=== 追加消息(${matched.length} 条)===\n` +
1027
+ lines.join('\n\n');
1028
+ debugLog(`Consuming ${matched.length} queued message(s) for request: ${requestId}`);
1029
+ clearTimeout(pending.timeout);
1030
+ pending.resolve({
1031
+ interactive_feedback: text,
1032
+ images: matched.flatMap((m) => m.images),
1033
+ attachedFiles: matched.flatMap((m) => m.files),
1034
+ project_directory: pending.projectDir,
1035
+ });
1036
+ this.pendingRequests.delete(requestId);
1037
+ this.feishu.clearPending(requestId);
1038
+ // 标记为「飞书渠道 resolve」:插件面板据此重置,避免对着已消失的请求提交
1039
+ this.lastFeishuResolved = { id: requestId, at: Date.now() };
1040
+ // 送达回执:给每条排队消息补 ✅ 表情
1041
+ for (const m of matched) {
1042
+ this.feishu.reactDone(m.messageId || undefined, m.chatId);
1043
+ }
1044
+ }
1045
+ /** 「我的窗口」是否仍活着(无插件 host 无从判定,视为活跃) */
1046
+ isOwnerAlive() {
1047
+ if (!this.everOwnerPolled)
1048
+ return true;
1049
+ return Date.now() - this.lastOwnerPollTime <= McpFeedbackServer.OWNER_IDLE_MS;
1050
+ }
1051
+ /**
1052
+ * 「无主消息」的忙时排队路由:全局无人等待时,汇总所有实例的活跃项目窗口——
1053
+ * - 恰好 1 个 → 消息排队给它(本地直接入队;远程实例经 /api/feishu/enqueue 转发);
1054
+ * - 多个 → 回执引导用户回复对应项目的卡片(回复旧卡片也会正确排队到该项目);
1055
+ * - 0 个 → 返回 false,调用方退回原有短暂存兜底。
1056
+ */
1057
+ async routeOrphanToQueue(text, chatId, images, files, messageId, remotes) {
1058
+ const candidates = [];
1059
+ if (this.ownerWorkspace && this.isOwnerAlive()) {
1060
+ candidates.push({ port: null, workspace: this.ownerWorkspace });
1061
+ }
1062
+ for (const r of remotes) {
1063
+ if (r.ownerAlive && r.ownerWorkspace) {
1064
+ candidates.push({ port: r.port, workspace: r.ownerWorkspace });
1065
+ }
1066
+ }
1067
+ // 按 workspace 去重(同窗口多对话共用/多实例只算一个活跃窗口;本地优先)
1068
+ const seen = new Set();
1069
+ const uniq = [];
1070
+ for (const c of candidates) {
1071
+ const key = this.normalizePath(c.workspace);
1072
+ if (seen.has(key))
1073
+ continue;
1074
+ seen.add(key);
1075
+ uniq.push(c);
1076
+ }
1077
+ if (uniq.length === 1) {
1078
+ const target = uniq[0];
1079
+ if (target.port === null) {
1080
+ this.enqueueInbound(text, chatId, images, files, messageId, target.workspace);
1081
+ return true;
1082
+ }
1083
+ return this.forwardEnqueue(target.port, text, chatId, images, files, messageId);
1084
+ }
1085
+ if (uniq.length > 1) {
1086
+ const names = uniq.map((c) => this.projectName(c.workspace));
1087
+ this.feishu.replyToMessage(messageId || undefined, chatId, `当前有 ${uniq.length} 个项目窗口在工作(${names.join('、')}),没法判断这条消息要发给谁。\n` +
1088
+ '请「回复」你要发送的那个项目的卡片(旧卡片也行),消息会排队到对应项目。');
1089
+ return true;
1090
+ }
1091
+ return false;
1092
+ }
1093
+ /** 把无主消息转发给远程实例排队(远程会自己入队并回执用户)。返回是否排队成功 */
1094
+ forwardEnqueue(port, text, chatId, images, files, messageId) {
1095
+ const body = JSON.stringify({ text, chatId, images, attachedFiles: files, messageId });
1096
+ return new Promise((resolve) => {
1097
+ const req = http.request({
1098
+ hostname: '127.0.0.1',
1099
+ port,
1100
+ path: '/api/feishu/enqueue',
1101
+ method: 'POST',
1102
+ timeout: 2000,
1103
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
1104
+ }, (res) => {
1105
+ let resBody = '';
1106
+ res.on('data', (chunk) => { resBody += chunk.toString(); });
1107
+ res.on('end', () => {
1108
+ try {
1109
+ resolve(!!JSON.parse(resBody).queued);
1110
+ }
1111
+ catch {
1112
+ resolve(false);
1113
+ }
1114
+ });
1115
+ });
1116
+ req.on('error', () => resolve(false));
1117
+ req.on('timeout', () => { req.destroy(); resolve(false); });
1118
+ req.write(body);
1119
+ req.end();
1120
+ });
1121
+ }
899
1122
  /**
900
1123
  * 处理获取系统信息请求
901
1124
  */
@@ -1134,6 +1357,7 @@ class McpFeedbackServer {
1134
1357
  appSecret: cfg.appSecret || '',
1135
1358
  enabled: cfg.enabled,
1136
1359
  ackReaction: cfg.ackReaction,
1360
+ queueWhenBusy: cfg.queueWhenBusy,
1137
1361
  touched: true,
1138
1362
  });
1139
1363
  await this.feishu.configure(cfg);
@@ -1224,6 +1448,12 @@ class McpFeedbackServer {
1224
1448
  res.writeHead(200, { 'Content-Type': 'application/json' });
1225
1449
  res.end(JSON.stringify({ handled: true }));
1226
1450
  }
1451
+ else if (reqId &&
1452
+ this.queueForEndedCard(reqId, text, chatId, images || [], attachedFiles || [], messageId || '')) {
1453
+ // 本实例的卡片、请求已结束且 AI 正忙 → 消息已排队(或已引导回复新卡片)
1454
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1455
+ res.end(JSON.stringify({ handled: true }));
1456
+ }
1227
1457
  else {
1228
1458
  res.writeHead(200, { 'Content-Type': 'application/json' });
1229
1459
  res.end(JSON.stringify({ handled: false }));
@@ -1246,7 +1476,13 @@ class McpFeedbackServer {
1246
1476
  ? []
1247
1477
  : this.feishu.listPending().map((p) => ({ projectName: this.projectName(p.projectDir) }));
1248
1478
  res.writeHead(200, { 'Content-Type': 'application/json' });
1249
- res.end(JSON.stringify({ count: list.length, list }));
1479
+ // ownerWorkspace / ownerAlive:供其他实例的忙时排队路由定位「活跃项目窗口」
1480
+ res.end(JSON.stringify({
1481
+ count: list.length,
1482
+ list,
1483
+ ownerWorkspace: this.ownerWorkspace,
1484
+ ownerAlive: !!this.ownerWorkspace && !ownerGone,
1485
+ }));
1250
1486
  return;
1251
1487
  }
1252
1488
  // 接收「无主消息」转发:仅当本实例恰好持有唯一 pending 时认领提交
@@ -1275,6 +1511,31 @@ class McpFeedbackServer {
1275
1511
  });
1276
1512
  return;
1277
1513
  }
1514
+ // 接收「无主消息」的忙时排队转发:本实例是全局唯一活跃窗口时,把消息排进本实例队列
1515
+ // (入队与「已排队」回执都由本实例完成,转发方只关心 queued 结果)
1516
+ if (req.method === 'POST' && req.url === '/api/feishu/enqueue') {
1517
+ let body = '';
1518
+ req.on('data', (chunk) => { body += chunk.toString(); });
1519
+ req.on('end', () => {
1520
+ try {
1521
+ const { text, chatId, images, attachedFiles, messageId } = JSON.parse(body);
1522
+ if (this.feishu.isQueueWhenBusy() && this.ownerWorkspace) {
1523
+ this.enqueueInbound(text, chatId, images || [], attachedFiles || [], messageId || '', this.ownerWorkspace);
1524
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1525
+ res.end(JSON.stringify({ queued: true }));
1526
+ }
1527
+ else {
1528
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1529
+ res.end(JSON.stringify({ queued: false }));
1530
+ }
1531
+ }
1532
+ catch {
1533
+ res.writeHead(400);
1534
+ res.end('{}');
1535
+ }
1536
+ });
1537
+ return;
1538
+ }
1278
1539
  // 健康检查
1279
1540
  if (req.method === 'GET' && req.url === '/api/health') {
1280
1541
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1372,6 +1633,7 @@ class McpFeedbackServer {
1372
1633
  appSecret,
1373
1634
  enabled: process.env.FEISHU_ENABLED !== 'false',
1374
1635
  ackReaction: process.env.FEISHU_ACK !== 'false',
1636
+ queueWhenBusy: process.env.FEISHU_QUEUE !== 'false',
1375
1637
  };
1376
1638
  }
1377
1639
  /**
@@ -1501,6 +1763,11 @@ class McpFeedbackServer {
1501
1763
  pending.resolve(null);
1502
1764
  }
1503
1765
  this.pendingRequests.clear();
1766
+ // 清理忙时队列的过期定时器(进程将退出,消息由 60 分钟兜底逻辑以外的重启场景自然失效)
1767
+ for (const q of this.queuedInbound) {
1768
+ clearTimeout(q.expiryTimer);
1769
+ }
1770
+ this.queuedInbound = [];
1504
1771
  // 关闭 MCP 服务器
1505
1772
  this.server.close();
1506
1773
  debugLog('Server stopped');
@@ -1516,6 +1783,10 @@ McpFeedbackServer.STASH_TTL_MS = 5000;
1516
1783
  * 都应暂存并续接到下一轮,而不是报「Request not found / 已结束」把用户内容丢掉。
1517
1784
  */
1518
1785
  McpFeedbackServer.REJOIN_TTL_MS = 15000;
1786
+ /** 队列消息未被读取的兜底时长:超过视为对话已结束,回执用户避免静默丢失 */
1787
+ McpFeedbackServer.QUEUE_TTL_MS = 60 * 60 * 1000;
1788
+ /** 队列长度上限:极端情况下防止无限堆积 */
1789
+ McpFeedbackServer.QUEUE_MAX = 100;
1519
1790
  /**
1520
1791
  * 「我的窗口」判定为已关闭的空闲阈值:本实例曾被自己窗口插件轮询,但超过此时长没再收到
1521
1792
  *(只剩别的窗口在扫端口)→ 视为我的窗口已关、本实例是僵尸:不再上报 pending,避免污染全局计数。
@@ -199,6 +199,14 @@
199
199
  </label>
200
200
  <p class="feishu-modal__desc feishu-modal__desc--sub">{{i18n.feishuAckDesc}}</p>
201
201
  </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>
202
210
  <label class="feishu-field">
203
211
  <span class="feishu-field__label">{{i18n.feishuAppId}}</span>
204
212
  <input id="feishuAppId" class="feishu-input" type="text" placeholder="{{i18n.feishuAppIdPlaceholder}}" autocomplete="off" spellcheck="false">
@@ -98,8 +98,10 @@
98
98
  const systemNotifyToggle = document.getElementById('systemNotifyToggle');
99
99
  const osNotifyToggle = document.getElementById('osNotifyToggle');
100
100
  const feishuAckToggle = document.getElementById('feishuAckToggle');
101
+ const feishuQueueToggle = document.getElementById('feishuQueueToggle');
101
102
  const osNotifySub = document.getElementById('osNotifySub');
102
103
  const feishuAckSub = document.getElementById('feishuAckSub');
104
+ const feishuQueueSub = document.getElementById('feishuQueueSub');
103
105
  const notifyTestBtn = document.getElementById('notifyTestBtn');
104
106
  const notifyTestHint = document.getElementById('notifyTestHint');
105
107
  const historyBtn = document.getElementById('historyBtn');
@@ -239,6 +241,9 @@
239
241
  feishuAckToggle.addEventListener('change', () => {
240
242
  vscode.postMessage({ type: 'toggleFeishuAck', payload: { enabled: feishuAckToggle.checked } });
241
243
  });
244
+ feishuQueueToggle.addEventListener('change', () => {
245
+ vscode.postMessage({ type: 'toggleFeishuQueue', payload: { enabled: feishuQueueToggle.checked } });
246
+ });
242
247
 
243
248
  // 发送测试通知:一键验证系统通知链路通不通(权限被拒时收不到,配合上方排查提示定位)
244
249
  let notifyTestHintTimer = null;
@@ -257,6 +262,8 @@
257
262
  const feishuOn = feishuEnabledToggle.checked;
258
263
  feishuAckToggle.disabled = !feishuOn;
259
264
  if (feishuAckSub) feishuAckSub.classList.toggle('is-disabled', !feishuOn);
265
+ feishuQueueToggle.disabled = !feishuOn;
266
+ if (feishuQueueSub) feishuQueueSub.classList.toggle('is-disabled', !feishuOn);
260
267
  }
261
268
 
262
269
  function updateFeishuUI(state) {
@@ -276,6 +283,9 @@
276
283
  if (typeof state.feishuAck === 'boolean') {
277
284
  feishuAckToggle.checked = state.feishuAck;
278
285
  }
286
+ if (typeof state.feishuQueue === 'boolean') {
287
+ feishuQueueToggle.checked = state.feishuQueue;
288
+ }
279
289
  syncSubSwitchDisabled();
280
290
  feishuStatusEl.classList.toggle('is-configured', !!state.configured && !state.bound);
281
291
  feishuStatusEl.classList.toggle('is-bound', !!state.bound);
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "cursor-feedback",
3
3
  "displayName": "Cursor Feedback",
4
4
  "description": "One Cursor conversation, unlimited AI interactions - Save your monthly request quota! Interactive feedback loop for AI chat via MCP",
5
- "version": "2.1.2",
5
+ "version": "2.2.0",
6
6
  "icon": "icon.png",
7
7
  "author": "jianger666",
8
8
  "license": "MIT",