@jesonliu/lark-claudecode-bridge 0.18.1 → 0.20.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.
Files changed (78) hide show
  1. package/README.md +14 -12
  2. package/assets/web/css/main.css +509 -111
  3. package/assets/web/index.html +12 -2
  4. package/assets/web/js/main.js +19 -11
  5. package/assets/web/js/pages/apps.js +10 -5
  6. package/assets/web/js/pages/claude.js +15 -5
  7. package/assets/web/js/pages/mcp.js +265 -0
  8. package/assets/web/js/pages/overview.js +5 -4
  9. package/assets/web/js/pages/permissions.js +56 -30
  10. package/assets/web/js/pages/plugins.js +29 -6
  11. package/assets/web/js/pages/skills.js +342 -0
  12. package/assets/web/js/pages/slash.js +11 -5
  13. package/assets/web/js/pages/workspaces.js +36 -14
  14. package/assets/web/js/theme.js +23 -0
  15. package/assets/web/js/ui.js +75 -4
  16. package/config.example.yaml +2 -3
  17. package/dist/access/access-control.d.ts +17 -1
  18. package/dist/access/access-control.js +31 -2
  19. package/dist/access/access-control.js.map +1 -1
  20. package/dist/bin/lcb.js +5 -0
  21. package/dist/bin/lcb.js.map +1 -1
  22. package/dist/claude-config.d.ts +14 -2
  23. package/dist/claude-config.js +75 -4
  24. package/dist/claude-config.js.map +1 -1
  25. package/dist/cli/setup-wizard.d.ts +0 -1
  26. package/dist/cli/setup-wizard.js +2 -4
  27. package/dist/cli/setup-wizard.js.map +1 -1
  28. package/dist/config.js +39 -14
  29. package/dist/config.js.map +1 -1
  30. package/dist/executor/claude-executor.d.ts +1 -1
  31. package/dist/executor/claude-executor.js +4 -2
  32. package/dist/executor/claude-executor.js.map +1 -1
  33. package/dist/executor/notify-server.d.ts +20 -0
  34. package/dist/executor/notify-server.js +85 -4
  35. package/dist/executor/notify-server.js.map +1 -1
  36. package/dist/executor/plugin-discovery.d.ts +6 -0
  37. package/dist/executor/plugin-discovery.js +14 -0
  38. package/dist/executor/plugin-discovery.js.map +1 -1
  39. package/dist/gateway/card-builder.d.ts +60 -24
  40. package/dist/gateway/card-builder.js +200 -109
  41. package/dist/gateway/card-builder.js.map +1 -1
  42. package/dist/gateway/diff-card.js +1 -1
  43. package/dist/gateway/diff-card.js.map +1 -1
  44. package/dist/gateway/feishu-gateway.d.ts +27 -0
  45. package/dist/gateway/feishu-gateway.js +106 -4
  46. package/dist/gateway/feishu-gateway.js.map +1 -1
  47. package/dist/gateway/progress-card.d.ts +77 -0
  48. package/dist/gateway/progress-card.js +231 -8
  49. package/dist/gateway/progress-card.js.map +1 -1
  50. package/dist/index.d.ts +16 -1
  51. package/dist/index.js +354 -98
  52. package/dist/index.js.map +1 -1
  53. package/dist/notify-sop.d.ts +1 -0
  54. package/dist/notify-sop.js +33 -0
  55. package/dist/notify-sop.js.map +1 -0
  56. package/dist/session/commands.d.ts +1 -1
  57. package/dist/session/commands.js +25 -2
  58. package/dist/session/commands.js.map +1 -1
  59. package/dist/session/session-store.d.ts +3 -0
  60. package/dist/session/session-store.js.map +1 -1
  61. package/dist/types.d.ts +23 -5
  62. package/dist/util/log-tee.d.ts +4 -0
  63. package/dist/util/log-tee.js +99 -0
  64. package/dist/util/log-tee.js.map +1 -0
  65. package/dist/util/runtime-dirs.d.ts +4 -0
  66. package/dist/util/runtime-dirs.js +27 -0
  67. package/dist/util/runtime-dirs.js.map +1 -0
  68. package/dist/util/workspace-diff.js +2 -2
  69. package/dist/util/workspace-diff.js.map +1 -1
  70. package/dist/web/lifecycle.d.ts +4 -1
  71. package/dist/web/lifecycle.js +11 -25
  72. package/dist/web/lifecycle.js.map +1 -1
  73. package/dist/web/server.js +404 -11
  74. package/dist/web/server.js.map +1 -1
  75. package/dist/web/skills-mcp-api.d.ts +120 -0
  76. package/dist/web/skills-mcp-api.js +348 -0
  77. package/dist/web/skills-mcp-api.js.map +1 -0
  78. package/package.json +3 -1
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // 装配主流程:消息 → 访问控制 → 命令 → 通道队列 → 执行 → 确认卡片 → 回传
2
2
  import { randomUUID } from 'node:crypto';
3
- import { existsSync } from 'node:fs';
4
- import { basename, join, resolve } from 'node:path';
3
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { basename, join, resolve, sep } from 'node:path';
5
5
  import { CONFIG_DIR, CONFIG_PATH, DEFAULT_CONTEXT_REMIND_TOKENS, loadConfig, sameApps } from './config.js';
6
6
  import { DEFAULT_CLAUDE_DIR, resolveClaudeDir } from './claude-config.js';
7
7
  import { warnIfNoClaudeAuth } from './auth-precheck.js';
@@ -11,12 +11,14 @@ import { clearPidFile, writePidFile, stopExistingBridgeAndWait } from './web/lif
11
11
  import { AccessControl } from './access/access-control.js';
12
12
  import { FeishuGateway } from './gateway/feishu-gateway.js';
13
13
  import { ProgressCard } from './gateway/progress-card.js';
14
- import { DECISION_TEXT, buildPlanCards, buildPlanResultCard, buildExpiredPlanCard, PROGRESS_TAIL_CHARS, buildQuestionCard, buildQuestionResultCard, buildExpiredQuestionCard, } from './gateway/card-builder.js';
14
+ import { DECISION_TEXT, PROGRESS_TAIL_CHARS, LONG_OUTPUT_THRESHOLD, buildLongOutputCard, buildLongOutputSettledCard, } from './gateway/card-builder.js';
15
15
  import { buildDiffSummaryCards } from './gateway/diff-card.js';
16
16
  import { runTask } from './executor/claude-executor.js';
17
17
  import { PermissionGate, DEFAULT_ALLOW_TOOLS_LIST, DEFAULT_DANGEROUS_COMMANDS, } from './executor/permission-gate.js';
18
18
  import { discoverPlugins, resolvePluginPaths } from './executor/plugin-discovery.js';
19
19
  import { createGatewaySender, createNotifyServer, NOTIFY_SERVER_NAME } from './executor/notify-server.js';
20
+ import { BRIDGE_MCP_JSON, readMcpServersFromJsonFile } from './web/skills-mcp-api.js';
21
+ import { FEISHU_NOTIFY_SOP_PROMPT } from './notify-sop.js';
20
22
  import { collectWorkspaceDiff } from './util/workspace-diff.js';
21
23
  import { isImageFile } from './util/file-types.js';
22
24
  import { FileTracker } from './util/file-tracker.js';
@@ -53,7 +55,31 @@ const PLAN_DECISION_TEXT = {
53
55
  'plan-approve': '✅ 计划已批准,开始执行(后续写操作免确认)',
54
56
  'plan-revise': '📝 修改意见已提交',
55
57
  'plan-reject': '❌ 计划已放弃',
58
+ // plan-view-file 不消费挂起项,仅 toast「正在发送方案文件…」——文案不在此出现,留口供编译期完整性
59
+ 'plan-view-file': '',
56
60
  };
61
+ /**
62
+ * 运行期文档落盘(plan 方案 / 超长回复共用):CONFIG_DIR 下按子目录 + 时间戳 + 工作区
63
+ * slug 命名,返回文件路径;写盘失败返回 ''(调用方回退降级行为,不影响主流程)
64
+ */
65
+ function writeRuntimeMarkdown(subDir, slugBase, content, logTag) {
66
+ const dir = join(CONFIG_DIR, subDir);
67
+ try {
68
+ mkdirSync(dir, { recursive: true });
69
+ }
70
+ catch { /* 已存在 */ }
71
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
72
+ const slug = (slugBase || subDir).replace(/[^\w-]+/g, '_').slice(0, 32);
73
+ const filePath = join(dir, `${ts}-${slug}.md`);
74
+ try {
75
+ writeFileSync(filePath, content, 'utf8');
76
+ return filePath;
77
+ }
78
+ catch (e) {
79
+ console.error(logTag, e);
80
+ return '';
81
+ }
82
+ }
57
83
  export function createBridge(config, app, // 本 bridge 绑定的飞书应用:并发/默认工作区/Claude Code 环境/人格均 per-app
58
84
  deps, opts = {}) {
59
85
  const sem = new Semaphore(app.concurrency ?? config.concurrency);
@@ -85,10 +111,18 @@ deps, opts = {}) {
85
111
  // 用于实现「默认不发非图片文件,用户主动点名才发」的回传规则
86
112
  const fileTracker = new FileTracker();
87
113
  const confirmPending = new Map();
88
- // plan 确认挂起项:requestId → 等待中的计划决策(plan-approve/revise/reject)
114
+ // plan 确认挂起项:requestId → 等待中的计划决策(plan-approve/revise/reject)。
115
+ // 嵌入主卡片后回调侧无 cardId(主卡 messageId 由 ProgressCard 内部管理),改带 channelKey 定位活跃进度卡
89
116
  const planPending = new Map();
90
- // 提问挂起项:requestId → 等待中的问题答案(qa-pick 逐项收集,qa-submit 一次性 resolve)
117
+ // 提问挂起项:requestId → 等待中的问题答案(qa-pick 逐项收集,qa-submit 一次性 resolve)。
118
+ // 嵌入主卡片后无 cardId,改带 channelKey 定位活跃进度卡
91
119
  const qaPending = new Map();
120
+ // 长回复卡挂起项(0.20.0):requestId → 收起卡的查看/确认/提意见上下文。与 planPending
121
+ // 不同——无 Promise 要 resolve(任务已结束,确认/提意见是「发起新一轮」),条目仅用于:
122
+ // 发起人校验(仅任务发起人可操作)、settled 防重(确认/提意见只生效一次,查看可反复)、
123
+ // filePath/workspaceName 上下文。条目常驻供用户回看历史卡片时再次查看全文(量级:每次
124
+ // 超长回复一条,几十字节;超 500 条清最老,防无界增长)
125
+ const outputPending = new Map();
92
126
  // 通道当前任务的进度卡:gate 的 ask/planAsk/askQuestion 闭包随 gate 通道级复用,
93
127
  // 不能捕获任务级 progress 实例(会是首个任务的旧卡)——经此间接引用每次任务的新卡
94
128
  const activeProgress = new Map();
@@ -98,18 +132,28 @@ deps, opts = {}) {
98
132
  function workspacePath(name) {
99
133
  return config.workspaces.find((w) => w.name === name)?.path ?? config.workspaces[0].path;
100
134
  }
135
+ /** 配置页管理的 MCP(#12):bridge 自管 mcp/servers.json 每任务现读注入 SDK(改配置即热生效,
136
+ * 无须重启)。notify 名固定后展开防被用户配置同名覆盖;文件缺失/损坏返回空(配置页写坏
137
+ * 不应拖垮任务执行) */
138
+ function loadBridgeMcpServers() {
139
+ return readMcpServersFromJsonFile(BRIDGE_MCP_JSON).servers;
140
+ }
101
141
  /** 通道内串行入队(每 key 一条 Promise 链),全局并发由 Semaphore 限制。
102
142
  * 有任务在跑时新消息静默 FIFO 排队——不提示的话用户不知道消息已被接收,会反复重发或误以为丢失。
103
- * queuedCount 计「等待中」消息数:enqueue 一律自增(含首位),executeTask 启动时递减(该条离开队列) */
143
+ * queuedCount 计「等待中」消息数:enqueue 一律自增(含首位),executeTask 启动时递减(该条离开队列)。
144
+ * /new 通过 epoch 代际丢弃旧排队消息:enqueue 捕获当前 epoch 传入 executeTask,不匹配则 no-op */
104
145
  function enqueue(key, msg, prompt, wsName) {
105
146
  const rt = runtimes.get(key) ?? { queue: Promise.resolve() };
147
+ if (rt.epoch === undefined)
148
+ rt.epoch = 0; // 旧状态无 epoch 字段的运行期兜底
106
149
  runtimes.set(key, rt);
107
150
  rt.queuedCount = (rt.queuedCount ?? 0) + 1;
108
151
  if (rt.busy) {
109
152
  void deps.gateway.sendTextTo(msg.chatId, `⏳ 当前有任务在运行,本条消息已排队(第 ${rt.queuedCount} 位),完成后自动开始`)
110
153
  .catch(() => { }); // 提示失败静默:不影响任务本身入队
111
154
  }
112
- rt.queue = rt.queue.then(() => executeTask(key, msg, prompt, wsName)).catch((e) => {
155
+ const myEpoch = rt.epoch; // 闭包捕获本条消息入队时的代际;executeTask 据此判断是否被 /new 作废
156
+ rt.queue = rt.queue.then(() => executeTask(key, msg, prompt, wsName, myEpoch)).catch((e) => {
113
157
  console.error(tag, '[任务异常]', e);
114
158
  });
115
159
  }
@@ -152,17 +196,32 @@ deps, opts = {}) {
152
196
  // 命中后拦截不再交给 Claude Code——避免模型重复 send_file 同一文件造成轰炸
153
197
  if (await tryDeliverRequestedFiles(msg, key))
154
198
  return;
155
- // 1. 访问控制:白名单外发配对码(首个配对成功者自动成为 admin,见 AccessControl)
199
+ // 1. 访问控制:白名单外首个使用者自动成为 admin(免配对),其后未知用户发配对码
156
200
  // 每条消息先重读 access.json:lcb pair / 运行终端等独立进程批准写盘后,
157
201
  // 长存的内存实例不 reload 会查到旧白名单 → 反复发配对码且整盘覆写抹掉新用户(死循环)
158
202
  deps.access.reload();
159
203
  // 配置热重载:lcb ws add/remove 独立进程写盘后,本实例下一条消息即读到新工作区
160
204
  opts.reloadConfig?.();
161
205
  if (!deps.access.isAllowed(msg.userId)) {
162
- const code = deps.access.beginPairing(msg.userId, msg.userId);
163
- console.log(`${tag}[配对] 未知用户 ${msg.userId} 请求接入,配对码:${code}(在终端输入 lcb pair ${code} 批准)`);
164
- await deps.gateway.sendTextTo(msg.chatId, `🔐 首次使用需配对。\n\n请管理员在桥接器终端确认配对码:**${code}**(15 分钟内有效)`);
165
- return;
206
+ // 首个使用者免配对直接成为 admin——按应用判定:多应用部署下 open_id 按应用隔离,
207
+ // 新应用的第一个使用者同样是「首个」,不应因别的应用已有用户而被配对码拦住
208
+ if (!deps.access.hasUsers(app.appId)) {
209
+ deps.access.addUser(msg.userId, msg.userId, 'admin', app.appId);
210
+ console.log(`${tag}[接入] 应用 ${app.appId} 首位用户 ${msg.userId} 已自动成为 admin(后续用户需配对)`);
211
+ await deps.gateway.sendTextTo(msg.chatId, `🎉 欢迎!你是本机器人的首位用户,已自动成为 **admin**。\n\n后续其他同事首次使用时需由你在桥接器终端批准配对(\`lcb pair <配对码>\`)。`);
212
+ // 不 return:欢迎语之外,本条消息照常进入命令/任务处理
213
+ }
214
+ else {
215
+ const code = deps.access.beginPairing(msg.userId, msg.userId, app.appId);
216
+ console.log(`${tag}[配对] 未知用户 ${msg.userId} 请求接入,配对码:${code}(在终端输入 lcb pair ${code} 批准)`);
217
+ await deps.gateway.sendTextTo(msg.chatId, `🔐 首次使用需配对。\n\n请管理员在桥接器终端确认配对码:**${code}**(15 分钟内有效)`);
218
+ return;
219
+ }
220
+ }
221
+ else {
222
+ // 老版本 access.json 的用户记录没有 appId:按当前来路补登,
223
+ // 保证 hasUsers(appId) 的按应用统计对存量用户同样成立
224
+ deps.access.ensureAppId(msg.userId, app.appId);
166
225
  }
167
226
  // 2. 命令:/help /new /resume /stop /status /ws
168
227
  const st = deps.store.getChannelState(key);
@@ -215,17 +274,61 @@ deps, opts = {}) {
215
274
  if (cmd.handled) {
216
275
  if (cmd.reply)
217
276
  await deps.gateway.sendTextTo(msg.chatId, cmd.reply);
218
- // /new 开启新会话:「本次会话不再询问」的授权随之失效(handleCommand 无权访问 wiring
219
- // 持有的 gate,故在命令处理分支后于 wiring 侧检测并 reset)
220
- if (/^\/new(?:\s|$)/.test(msg.text.trim()))
277
+ // /new 开启新会话:
278
+ // 1) 清空旧代际排队消息(epoch 自增,旧任务入 executeTask 即 no-op)
279
+ // 2) abort 正在执行的任务
280
+ // 3) 结算本用户挂起的 confirm/plan/qa 条目(按取消/拒绝处理:对话继续推进或被放弃)
281
+ // 4) 通道级权限闸 reset(spec:「本次会话不再询问」的记忆跨任务生效,直至 /new)
282
+ // 5) 汇总已取消的排队消息数(用户能看到新建会话的真实影响面)
283
+ // ——handleCommand 无权访问 wiring 的 runtime/pending map,统一在 wiring 侧做
284
+ if (/^\/new(?:\s|$)/.test(msg.text.trim())) {
285
+ const rt = runtimes.get(key);
286
+ const cancelled = rt?.queuedCount ?? 0;
287
+ if (rt) {
288
+ rt.epoch = (rt.epoch ?? 0) + 1;
289
+ rt.queuedCount = 0; // 旧代际消息在 executeTask 入口 no-op,不会再 decrement;此处一次性清零即可
290
+ if (rt.abort)
291
+ rt.abort.abort();
292
+ }
293
+ for (const [id, p] of confirmPending) {
294
+ if (p.channelKey !== key)
295
+ continue;
296
+ confirmPending.delete(id);
297
+ p.resolve('deny');
298
+ }
299
+ for (const [id, p] of planPending) {
300
+ if (p.channelKey !== key)
301
+ continue;
302
+ planPending.delete(id);
303
+ p.resolve({ action: 'reject' });
304
+ }
305
+ for (const [id, p] of qaPending) {
306
+ if (p.channelKey !== key)
307
+ continue;
308
+ qaPending.delete(id);
309
+ p.resolve({});
310
+ }
311
+ // 本用户在本会话的长回复卡确认/提意见按钮一并失效(保留查看全文):新会话已无
312
+ // 「上一轮方案」上下文,旧确认按钮再触发只会得到脱离语境的新一轮任务
313
+ for (const o of outputPending.values()) {
314
+ if (o.chatId === msg.chatId && o.ownerId === msg.userId)
315
+ o.settled = true;
316
+ }
221
317
  gates.get(key)?.reset();
318
+ if (cancelled > 0) {
319
+ await deps.gateway.sendTextTo(msg.chatId, `🗑️ 已因新建会话取消 ${cancelled} 条排队消息`);
320
+ }
321
+ }
222
322
  return;
223
323
  }
224
324
  // 3. 普通文本:通道内串行入队(每 key 一条 Promise 链),全局并发由 Semaphore 限制
225
325
  enqueue(key, msg, cmd.taskText ?? msg.text, currentWorkspace);
226
326
  }
227
- async function executeTask(key, msg, prompt, wsName) {
327
+ async function executeTask(key, msg, prompt, wsName, myEpoch) {
228
328
  const rt = runtimes.get(key);
329
+ // /new 丢弃旧代际:静默 no-op(不入队列、不 decrement、不建进度卡;汇总条数已在 /new 时回)
330
+ if (myEpoch !== (rt.epoch ?? 0))
331
+ return;
229
332
  rt.busy = true; // enqueue 据此给后续消息发「已排队」提示(sem 等待期同样算占用)
230
333
  rt.queuedCount = Math.max(0, (rt.queuedCount ?? 1) - 1); // 本条离开等待队列开始执行
231
334
  const release = await sem.acquire();
@@ -237,7 +340,18 @@ deps, opts = {}) {
237
340
  deleteCard: deps.gateway.deleteCard
238
341
  ? (id) => deps.gateway.deleteCard(id)
239
342
  : undefined,
240
- }, `任务 · ${wsName}`);
343
+ sendCardEntity: deps.gateway.sendCardEntityTo
344
+ ? (c) => deps.gateway.sendCardEntityTo(msg.chatId, c)
345
+ : undefined,
346
+ partialUpdateCard: deps.gateway.partialUpdateCardElements
347
+ ? (cardId, seq, actions) => deps.gateway.partialUpdateCardElements(cardId, seq, actions)
348
+ : undefined,
349
+ replaceCard: deps.gateway.replaceCardEntity
350
+ ? (cardId, seq, c) => deps.gateway.replaceCardEntity(cardId, seq, c)
351
+ : undefined,
352
+ }, `任务 · ${wsName}`,
353
+ // card.width 热生效:每任务现读 config(与同款「同对象 mutate 即生效」语义一致)
354
+ { cardWidthMode: config.card?.width ?? 'default' });
241
355
  const confirmTimeoutMs = opts.confirmTimeoutMs ?? CONFIRM_TIMEOUT_MS;
242
356
  const cardSendTimeoutMs = opts.cardSendTimeoutMs ?? CARD_SEND_TIMEOUT_MS;
243
357
  // 通道级复用:已存在则沿用(allow-session 记忆跨任务),不存在才建。
@@ -279,30 +393,30 @@ deps, opts = {}) {
279
393
  },
280
394
  req,
281
395
  ownerId: msg.userId,
396
+ channelKey: key,
282
397
  onSettled: settle,
283
398
  });
284
399
  });
285
400
  },
286
- // plan 确认:发计划卡片组(首卡带按钮+输入框)挂起等待;超时自动放弃。
287
- // chatId/userId 绑定构造时任务——通道 key = chatId+userId,同通道内固定,闭包安全
401
+ // plan 确认(#3+#6):不再单独发计划卡片组。计划正文先落盘到 ~/.lark-claudecode-bridge/claude/plans/
402
+ // (slug + 时间戳,方便后续 review 与 SOP 要求的 plan 本地归档),然后嵌入当前任务的进度卡:
403
+ // 卡片正文仅露 600 字预览 + 「查看完整方案」按钮(callback send_file 发送原 md 文件,不塞卡片正文
404
+ // ——飞书单卡 30KB 上限与可读性都不允许长方案铺在卡片里);批准/放弃/按意见修改三按钮与意见输入框
405
+ // 仍在主卡片的内嵌 form 内。超时由本闭包自管:到点 clearPlan + resolve reject
288
406
  planAsk: async (req) => {
289
407
  const planReq = { requestId: randomUUID(), plan: req.plan, workspaceName: req.workspaceName };
290
- const ids = [];
291
- for (const c of buildPlanCards(planReq)) {
292
- const id = await raceFallback(deps.gateway.sendCardTo(msg.chatId, c), cardSendTimeoutMs, '');
293
- if (!id) {
294
- void deps.gateway.sendTextTo(msg.chatId, '⚠️ 计划卡发送失败,本次计划已自动放弃;模型重新提交计划时会再次弹出')
295
- .catch(() => { });
296
- return { action: 'reject' };
297
- }
298
- ids.push(id);
299
- }
300
- const cardId = ids[0];
301
- void activeProgress.get(key)?.sinkToBottom();
408
+ // 落盘计划原文:路径走 ensureRuntimeDirs 已建的 claude/plans 目录,文件名加时间戳避免并发冲突
409
+ const planFilePath = writeRuntimeMarkdown(join('claude', 'plans'), req.workspaceName || 'plan', `# 执行计划 · ${req.workspaceName} · ${new Date().toLocaleString('zh-CN', { hour12: false })}\n\n${req.plan}\n`, `${tag}[计划落盘失败](不影响流程,仅「查看完整方案」可能不可用):`);
410
+ const progress = activeProgress.get(key);
411
+ if (!progress)
412
+ return { action: 'reject' }; // 进度卡不在 = 任务已结束/被 abort,按放弃处理
413
+ progress.setPlan({ requestId: planReq.requestId, plan: req.plan, workspaceName: req.workspaceName, planFilePath });
414
+ // 不再 sinkToBottom:plan 区嵌入主进度卡,flush 自然 PATCH,删旧卡重发只会让用户看到计划区跳一下
302
415
  return new Promise((resolve) => {
303
416
  const gc = setTimeout(() => {
304
417
  planPending.delete(planReq.requestId);
305
- void deps.gateway.updateCard(cardId, buildExpiredPlanCard(planReq, confirmTimeoutMs)).catch(() => { });
418
+ progress.setPlan(undefined);
419
+ progress.setStatus(`⏰ 计划确认超时(${Math.round(confirmTimeoutMs / 60000)} 分钟未确认),已自动放弃;模型重新提交计划时会再次弹出`);
306
420
  resolve({ action: 'reject' });
307
421
  }, confirmTimeoutMs);
308
422
  gc.unref();
@@ -313,12 +427,14 @@ deps, opts = {}) {
313
427
  },
314
428
  req: planReq,
315
429
  ownerId: msg.userId,
316
- cardId,
430
+ channelKey: key,
431
+ chatId: msg.chatId,
432
+ planFilePath,
317
433
  });
318
434
  });
319
435
  },
320
- // 提问确认:AskUserQuestion 的问题选项卡(每问选项按钮 + 提交);qa-pick 逐项
321
- // 更新选中态(PATCH 重渲染),qa-submit 全部作答后一次性 resolve。超时自动跳过
436
+ // 提问确认(#3):AskUserQuestion 的选项 + 提交按钮全部嵌入主进度卡(#3 决策:彻底替换独立提问卡)。
437
+ // qa-pick 仅 PATCH 选中态(卡片不刷新整卡,仅更新 answer map),qa-submit 一次性 resolve 并 clearQuestion
322
438
  askQuestion: async (req) => {
323
439
  const qaReq = {
324
440
  requestId: randomUUID(),
@@ -326,18 +442,16 @@ deps, opts = {}) {
326
442
  workspaceName: req.workspaceName,
327
443
  };
328
444
  const answers = {};
329
- // 发卡失败/超时返回空答案:gate 对空答案统一按「未作答」deny,模型自行决策或停下等待
330
- const cardId = await raceFallback(deps.gateway.sendCardTo(msg.chatId, buildQuestionCard(qaReq, answers)), cardSendTimeoutMs, '');
331
- if (!cardId) {
332
- void deps.gateway.sendTextTo(msg.chatId, '⚠️ 提问卡发送失败,已按未作答处理;模型会自行决策或停下等待你的回复')
333
- .catch(() => { });
334
- return {};
335
- }
336
- void activeProgress.get(key)?.sinkToBottom();
445
+ const progress = activeProgress.get(key);
446
+ if (!progress)
447
+ return {}; // 进度卡不在 = 任务已结束/被 abort,按未作答处理
448
+ progress.setQuestion({ requestId: qaReq.requestId, questions: req.questions, workspaceName: req.workspaceName, answers });
449
+ // 不再 sinkToBottom:提问区嵌入主进度卡后,flush 自然 PATCH(理由同 planAsk 上方注释)
337
450
  return new Promise((resolve) => {
338
451
  const gc = setTimeout(() => {
339
452
  qaPending.delete(qaReq.requestId);
340
- void deps.gateway.updateCard(cardId, buildExpiredQuestionCard(qaReq, confirmTimeoutMs)).catch(() => { });
453
+ progress.setQuestion(undefined);
454
+ progress.setStatus(`⏰ 提问超时(${Math.round(confirmTimeoutMs / 60000)} 分钟未作答),已按未作答处理;模型自行决策或停下等待你的回复`);
341
455
  resolve({});
342
456
  }, confirmTimeoutMs);
343
457
  gc.unref();
@@ -348,7 +462,8 @@ deps, opts = {}) {
348
462
  },
349
463
  req: qaReq,
350
464
  ownerId: msg.userId,
351
- cardId,
465
+ channelKey: key,
466
+ chatId: msg.chatId,
352
467
  answers,
353
468
  });
354
469
  });
@@ -363,6 +478,8 @@ deps, opts = {}) {
363
478
  // lcb-notify 发送能力:chatId 硬绑定当前任务(权限闸直通的安全前提);
364
479
  // sentPaths 记录中途已推送的文件,任务收尾的产出回传据此去重(用户已收过的不重发)。
365
480
  // 每次中途推送后进度卡沉底一次(删旧卡重发),保持「任务是否还在跑」始终可见于会话底部
481
+ // SOP 开关:本任务级现读 config.session.notifySop(缺省 true),决定后续 sendText 是否被强制降级
482
+ const sopEnabled = config.session?.notifySop !== false;
366
483
  const sentPaths = new Set();
367
484
  const sink = async () => { await activeProgress.get(key)?.sinkToBottom(); };
368
485
  const notifySender = createGatewaySender({
@@ -384,6 +501,10 @@ deps, opts = {}) {
384
501
  await deps.gateway.uploadAndSendFile(c, p);
385
502
  await sink();
386
503
  },
504
+ }, {
505
+ enabled: sopEnabled,
506
+ // #11 SOP 提示文案前缀(用户看到的「为什么自动转附件」提示)
507
+ sopTag: '⚠️ 推送上限',
387
508
  });
388
509
  activeProgress.set(key, progress);
389
510
  try {
@@ -405,15 +526,22 @@ deps, opts = {}) {
405
526
  resumeSessionId: resumeId,
406
527
  signal: abort.signal,
407
528
  env: appEnv,
408
- appendSystemPrompt: app.appendSystemPrompt,
409
- // code-dev 工作区统一 plan mode:模型先出计划(ExitPlanMode → planAsk 飞书卡片),
410
- // 用户批准后 SDK 自动切回可编辑模式继续执行
411
- ...(config.workspaces.find((w) => w.name === wsName)?.type === 'code-dev' ? { permissionMode: 'plan' } : {}),
529
+ // #11 SOP 软约束:app.appendSystemPrompt 后追加 SOP 摘要(关闭时仅传 app 原有部分)
530
+ appendSystemPrompt: (() => {
531
+ const base = app.appendSystemPrompt?.trim() ?? '';
532
+ if (!sopEnabled)
533
+ return base || undefined;
534
+ return base ? `${base}\n\n${FEISHU_NOTIFY_SOP_PROMPT}` : FEISHU_NOTIFY_SOP_PROMPT;
535
+ })(),
536
+ // 计划模式(#6):通道级 /plan 开关——模型先出计划(ExitPlanMode → planAsk 飞书卡片),
537
+ // 用户批准后 SDK 自动切回可编辑模式继续执行(替代旧工作区 code-dev 类型)
538
+ ...(state?.planMode ? { permissionMode: 'plan' } : {}),
412
539
  // 通道级模型覆盖(/model 设置,每任务现读——改完下一条消息即生效);未设 = 跟随 ~/.claude 全局
413
540
  ...(state?.model ? { model: state.model } : {}),
414
541
  // 进程内通知工具(send_text/send_image/send_file):中间产物实时推给当前聊天。
415
- // server 实例按任务构造,chatId 在 sender 闭包内硬绑定——模型无法选择接收者
416
- mcpServers: { [NOTIFY_SERVER_NAME]: createNotifyServer(notifySender) },
542
+ // server 实例按任务构造,chatId 在 sender 闭包内硬绑定——模型无法选择接收者。
543
+ // 配置页 MCP(#12)一并注入:bridge 自管 servers.json 每任务现读(热生效),notify 后展开保名
544
+ mcpServers: { ...loadBridgeMcpServers(), [NOTIFY_SERVER_NAME]: createNotifyServer(notifySender) },
417
545
  // 插件:config.yaml 显式配置(开发期指源码目录)+ Claude 配置目录已启用的 marketplace
418
546
  // 插件自动发现,同名显式优先;SDK 对无效路径静默跳过,实际加载以 init 清单(/plugins 命令)为准。
419
547
  // managed 模式合并发现两处目录(自管 + 本机 ~/.claude):按绝对 installPath 传 SDK,
@@ -510,7 +638,33 @@ deps, opts = {}) {
510
638
  // 而 textTail 按序累积全部流式文本块、卡片取尾部 PROGRESS_TAIL_CHARS 字——finalText 不超上限时
511
639
  // 必然完整落在进度卡终态里,再发一条就是内容几乎逐字相同的重复消息(0.11.0 修复的双推 bug)
512
640
  if (outcome.finalText && outcome.finalText.length > PROGRESS_TAIL_CHARS) {
513
- await deps.gateway.sendTextTo(msg.chatId, outcome.finalText.slice(0, 4000));
641
+ // 结果回传(0.20.0 三段式):
642
+ // - 400 字内:进度卡终态已完整展示,不发独立消息(0.11.0 双推修复)
643
+ // - 400~2000 字:直接发全文卡片(旧 slice 上限对这段区间本就不触发)
644
+ // - >2000 字:落盘 md + 收起卡(查看全文按钮 + 确认方案/按意见修改引导,点击
645
+ // 即发起新一轮任务),替代旧版 4000 字硬截断——超长部分不再静默丢弃
646
+ const text = outcome.finalText;
647
+ if (text.length > LONG_OUTPUT_THRESHOLD) {
648
+ const outFilePath = writeRuntimeMarkdown('outputs', wsName, `# 完整回复 · ${wsName} · ${new Date().toLocaleString('zh-CN', { hour12: false })}\n\n${text}\n`, `${tag}[长回复落盘失败,回退为截断发送]:`);
649
+ if (outFilePath) {
650
+ const outReq = {
651
+ requestId: randomUUID(), filePath: outFilePath,
652
+ charCount: text.length, workspaceName: wsName,
653
+ };
654
+ outputPending.set(outReq.requestId, { req: outReq, ownerId: msg.userId, chatId: msg.chatId, settled: false });
655
+ if (outputPending.size > 500) {
656
+ // 防无界增长:清最老的条目(Map 迭代序即插入序;被清卡片的确认按钮会提示已过期)
657
+ outputPending.delete(outputPending.keys().next().value);
658
+ }
659
+ await deps.gateway.sendCardTo(msg.chatId, buildLongOutputCard(outReq));
660
+ }
661
+ else {
662
+ await deps.gateway.sendTextTo(msg.chatId, text.slice(0, 4000)); // 落盘失败兜底:维持旧截断行为
663
+ }
664
+ }
665
+ else {
666
+ await deps.gateway.sendTextTo(msg.chatId, text);
667
+ }
514
668
  }
515
669
  // 图片自动发(im.image.create 通道),非图片文件不自动回传——
516
670
  // 用户从下面的文件清单里看到改了哪些文件,主动点名 basename 后由 tryDeliverRequestedFiles 处理
@@ -532,18 +686,12 @@ deps, opts = {}) {
532
686
  await deps.gateway.sendTextTo(msg.chatId, `📁 本次修改/新增的文件(共 ${outcome.producedFiles.length} 个):\n${list}`);
533
687
  }
534
688
  await uploadProducedImages();
535
- // code-dev 工作区额外发汇总 diff 卡片(git diff 不是文件本身,不冲突);
536
- // 非 git 仓库 wsDiff===null 时只发图片、不再回退上传非图片
537
- const isCodeDev = config.workspaces.find((w) => w.name === wsName)?.type === 'code-dev';
538
- if (isCodeDev) {
539
- const wsDiff = await collectWorkspaceDiff(workspacePath(wsName));
540
- if (wsDiff === null) {
541
- console.warn(tag, `[任务收尾] 工作区 ${wsName} 不是 git 仓库,非图片文件不再自动发送(git init 后可改用汇总 diff 卡片)`);
542
- }
543
- else if (wsDiff.diff.trim()) {
544
- for (const c of buildDiffSummaryCards(wsDiff.diff, { workspaceName: wsName, files: wsDiff.files })) {
545
- await deps.gateway.sendCardTo(msg.chatId, c);
546
- }
689
+ // 汇总 diff 收尾卡片(#6 起不再依赖工作区类型):工作区是 git 仓库且有改动才发——
690
+ // git 仓库即「代码工作区」的客观信号;非 git 仓库(内容生产类目录)天然跳过
691
+ const wsDiff = await collectWorkspaceDiff(workspacePath(wsName));
692
+ if (wsDiff !== null && wsDiff.diff.trim()) {
693
+ for (const c of buildDiffSummaryCards(wsDiff.diff, { workspaceName: wsName, files: wsDiff.files })) {
694
+ await deps.gateway.sendCardTo(msg.chatId, c);
547
695
  }
548
696
  }
549
697
  }
@@ -590,6 +738,8 @@ deps, opts = {}) {
590
738
  });
591
739
  }
592
740
  finally {
741
+ // SOP 降级单文件补发(#7):任务任何结局(完成/停止/出错)都在收尾补齐完整附件
742
+ await notifySender.flushDowngradedFile?.().catch(() => { });
593
743
  clearTimeout(hardTimeout);
594
744
  activeProgress.delete(key);
595
745
  activeQueries.delete(key);
@@ -610,10 +760,17 @@ deps, opts = {}) {
610
760
  const plan = planPending.get(action.value.requestId);
611
761
  if (plan) {
612
762
  if (action.operatorId !== plan.ownerId) {
613
- console.log(tag, `[卡片回调] 非发起人 ${action.operatorId} 点击计划卡 ${action.value.requestId},已忽略`);
763
+ console.log(tag, `[卡片回调] 非发起人 ${action.operatorId} 点击计划区 ${action.value.requestId},已忽略`);
614
764
  return { toast: { type: 'info', content: '仅任务发起人可操作计划' } };
615
765
  }
616
766
  const decision = action.value.decision;
767
+ // 「查看完整方案」(#6):callback 触发 send_file 直接发原 md 文件,不消耗挂起项
768
+ if (decision === 'plan-view-file') {
769
+ // 文件在 planAsk 时已落盘;这里直接 send_file(feishuGateway 走 file_type:stream,md 等任意后缀可发)
770
+ void deps.gateway.uploadAndSendFile(plan.chatId, plan.planFilePath)
771
+ .catch((e) => console.error('[查看方案发送失败]', action.value.requestId, e));
772
+ return { toast: { type: 'info', content: '正在发送方案文件…' } };
773
+ }
617
774
  if (decision !== 'plan-approve' && decision !== 'plan-revise' && decision !== 'plan-reject') {
618
775
  return { toast: { type: 'info', content: '该计划确认已被处理或已过期' } };
619
776
  }
@@ -626,19 +783,20 @@ deps, opts = {}) {
626
783
  plan.resolve(decision === 'plan-approve' ? { action: 'approve' }
627
784
  : decision === 'plan-revise' ? { action: 'revise', feedback }
628
785
  : { action: 'reject' });
629
- const resultCard = buildPlanResultCard(plan.req, decision, '任务发起人', feedback);
630
- void deps.gateway.updateCard(plan.cardId, resultCard)
631
- .catch((e) => console.error('[计划卡兜底更新失败]', action.value.requestId, e));
632
- return {
633
- toast: { type: 'success', content: PLAN_DECISION_TEXT[decision] },
634
- card: { type: 'raw', data: resultCard },
635
- };
786
+ // 清除主进度卡的内嵌计划区(按钮随 setPlan(undefined) 的 flush 消失)
787
+ const prog = activeProgress.get(plan.channelKey);
788
+ if (prog) {
789
+ prog.setPlan(undefined);
790
+ prog.setStatus(PLAN_DECISION_TEXT[decision]);
791
+ }
792
+ return { toast: { type: 'success', content: PLAN_DECISION_TEXT[decision] } };
636
793
  }
637
- // 提问回调分流:qa-pick 更新选中态(卡片 PATCH 重渲染,不消耗挂起项);qa-submit 全部作答后 resolve
794
+ // 提问回调分流(#3):qa-pick 仅 PATCH 选中态(不消耗挂起项,等所有题答完才 submit);
795
+ // qa-submit 全部作答后一次性 resolve 并 clearQuestion。主进度卡的 PATCH 由 ProgressCard 内部 flush 串行化
638
796
  const qa = qaPending.get(action.value.requestId);
639
797
  if (qa) {
640
798
  if (action.operatorId !== qa.ownerId) {
641
- console.log(tag, `[卡片回调] 非发起人 ${action.operatorId} 点击提问卡 ${action.value.requestId},已忽略`);
799
+ console.log(tag, `[卡片回调] 非发起人 ${action.operatorId} 点击提问区 ${action.value.requestId},已忽略`);
642
800
  return { toast: { type: 'info', content: '仅任务发起人可作答' } };
643
801
  }
644
802
  const { decision } = action.value;
@@ -648,38 +806,117 @@ deps, opts = {}) {
648
806
  if (!q || !label || !q.options.some((o) => o.label === label)) {
649
807
  return { toast: { type: 'info', content: '该选项无效' } };
650
808
  }
651
- const cur = qa.answers[action.value.qIndex];
652
- if (q.multiSelect) {
653
- const arr = Array.isArray(cur) ? cur : cur !== undefined ? [cur] : [];
654
- qa.answers[action.value.qIndex] = arr.includes(label)
655
- ? (arr.length > 1 ? arr.filter((x) => x !== label) : arr) // 至少保留一项:全取消等于未答
656
- : [...arr, label];
657
- }
658
- else {
659
- qa.answers[action.value.qIndex] = label;
660
- }
661
- void deps.gateway.updateCard(qa.cardId, buildQuestionCard(qa.req, qa.answers)).catch(() => { });
662
- return { toast: { type: 'success', content: `已选:${label}` } };
809
+ // 切换选中态由 ProgressCard.updateQuestionAnswer 唯一维护(toggle / 至少保留一项);本处只调 PATCH
810
+ const prog = activeProgress.get(qa.channelKey);
811
+ const before = prog?.getQuestionAnswer(action.value.qIndex);
812
+ const willUnpick = !!q.multiSelect && Array.isArray(before) && before.includes(label) && before.length > 1;
813
+ prog?.updateQuestionAnswer(action.value.qIndex, label, !!q.multiSelect);
814
+ // 把 progress 的最新答案同步回 qa.answers 供 submit 校验,避免双 mutate(toggle 重复计数)
815
+ if (prog)
816
+ qa.answers[action.value.qIndex] = prog.getQuestionAnswer(action.value.qIndex)
817
+ ?? (q.multiSelect ? [] : label);
818
+ return { toast: { type: 'success', content: willUnpick ? `已取消:${label}` : `已选:${label}` } };
663
819
  }
664
820
  if (decision === 'qa-submit') {
665
- const missing = qa.req.questions
666
- .filter((_, i) => qa.answers[i] === undefined || (Array.isArray(qa.answers[i]) && qa.answers[i].length === 0));
821
+ // 0.20.0:每题答案 = 选项点选 + 表单自定义输入(formValue.custom_N)合并——
822
+ // 单选:custom 非空优先覆盖选中项;多选:custom 追加进选中数组。
823
+ // 已答判定放宽为「选中项或自定义输入至少一项」
824
+ const fv = action.value.formValue ?? {};
825
+ const result = {};
826
+ const missing = [];
827
+ qa.req.questions.forEach((q, i) => {
828
+ const raw = fv[`custom_${i}`];
829
+ const custom = typeof raw === 'string' ? raw.trim() : '';
830
+ const picked = qa.answers[i];
831
+ if (q.multiSelect) {
832
+ const arr = Array.isArray(picked) ? [...picked] : picked !== undefined ? [picked] : [];
833
+ if (custom)
834
+ arr.push(custom);
835
+ if (arr.length === 0) {
836
+ missing.push(i);
837
+ return;
838
+ }
839
+ result[q.question] = arr;
840
+ }
841
+ else {
842
+ const ans = custom || (picked !== undefined ? picked : '');
843
+ if (!ans) {
844
+ missing.push(i);
845
+ return;
846
+ }
847
+ result[q.question] = ans;
848
+ }
849
+ });
667
850
  if (missing.length > 0) {
668
- return { toast: { type: 'warning', content: `还有 ${missing.length} 个问题未作答(多选题至少选 1 项)` } };
851
+ return { toast: { type: 'warning', content: `第 ${missing.map((i) => i + 1).join('、')} 题未作答(点选项或在输入框填写均可)` } };
669
852
  }
670
853
  qaPending.delete(action.value.requestId);
671
- const result = {};
672
- qa.req.questions.forEach((q, i) => { result[q.question] = qa.answers[i]; });
673
854
  qa.resolve(result);
674
- const resultCard = buildQuestionResultCard(qa.req, qa.answers, '任务发起人');
675
- void deps.gateway.updateCard(qa.cardId, resultCard)
676
- .catch((e) => console.error('[提问卡兜底更新失败]', action.value.requestId, e));
855
+ // 清除主进度卡的内嵌提问区(按钮随 setQuestion(undefined) 的 flush 消失)
856
+ const prog = activeProgress.get(qa.channelKey);
857
+ if (prog) {
858
+ prog.setQuestion(undefined);
859
+ prog.setStatus('✅ 答案已提交,继续执行…');
860
+ }
861
+ return { toast: { type: 'success', content: '✅ 答案已提交' } };
862
+ }
863
+ return { toast: { type: 'info', content: '该提问已被处理或已过期' } };
864
+ }
865
+ // 长回复收起卡(0.20.0):任务已结束,无 Promise 挂起——outputPending 提供发起人校验、
866
+ // settled 防重与文件上下文。「查看完整内容」可反复点击;「确认方案 / 按意见修改」合成
867
+ // 一条用户消息入队,走与真实消息完全相同的排队/resume/进度卡链路发起新一轮任务
868
+ const out = outputPending.get(action.value.requestId);
869
+ if (out) {
870
+ if (action.operatorId !== out.ownerId) {
871
+ console.log(tag, `[卡片回调] 非发起人 ${action.operatorId} 点击长回复卡 ${action.value.requestId},已忽略`);
872
+ return { toast: { type: 'info', content: '仅任务发起人可操作' } };
873
+ }
874
+ const decision = action.value.decision;
875
+ if (decision === 'view-output-file') {
876
+ // 白名单校验:仅允许 outputs 目录内文件(value 虽由桥接器写入卡片,纵深防御不亏)
877
+ const outputsDir = join(CONFIG_DIR, 'outputs');
878
+ if (!out.req.filePath.startsWith(outputsDir + sep)) {
879
+ return { toast: { type: 'error', content: '文件路径校验失败,已忽略' } };
880
+ }
881
+ void deps.gateway.uploadAndSendFile(out.chatId, out.req.filePath)
882
+ .catch((e) => console.error('[完整回复发送失败]', action.value.requestId, e));
883
+ return { toast: { type: 'info', content: '正在发送完整回复…' } };
884
+ }
885
+ if (out.settled) {
886
+ return { toast: { type: 'info', content: '该回复的处理已提交,无需重复操作' } };
887
+ }
888
+ if (decision === 'output-confirm' || decision === 'output-revise') {
889
+ const feedback = (action.value.feedback ?? '').trim();
890
+ if (decision === 'output-revise' && !feedback) {
891
+ return { toast: { type: 'warning', content: '请先在输入框填写修改意见(或点「确认方案」)' } };
892
+ }
893
+ out.settled = true;
894
+ const prompt = decision === 'output-confirm'
895
+ ? '用户已在飞书点击「确认方案」,确认了上一轮的完整回复(方案),请继续按该方案推进执行。'
896
+ : `用户要求按以下意见调整上一轮完整回复中的方案:\n${feedback}`;
897
+ // 合成用户消息入队:新一轮会 resume 上一会话(「上一轮方案」上下文天然在场)。
898
+ // 发起人必在访问白名单(能发起任务才拿得到卡片),仍做 isAllowed 纵深校验;
899
+ // chatType 不参与任务链路(仅 gateway 消息解析用),合成消息固定 p2p
900
+ if (deps.access.isAllowed(action.operatorId)) {
901
+ const key = channelKey(out.chatId, action.operatorId);
902
+ enqueue(key, {
903
+ chatId: out.chatId, chatType: 'p2p', userId: action.operatorId,
904
+ text: prompt, messageId: `output-${action.value.requestId}`,
905
+ }, prompt, out.req.workspaceName);
906
+ }
907
+ else {
908
+ console.warn(tag, '[长回复卡] 发起人不在访问白名单,已忽略新一轮请求:', action.operatorId);
909
+ }
910
+ const settledText = decision === 'output-confirm'
911
+ ? '✅ 已确认该方案,正在发起新一轮任务(详情见下方新进度卡)'
912
+ : '✏️ 修改意见已收到,正在发起新一轮任务(详情见下方新进度卡)';
677
913
  return {
678
- toast: { type: 'success', content: '✅ 答案已提交' },
679
- card: { type: 'raw', data: resultCard },
914
+ toast: { type: 'success', content: settledText },
915
+ // 回调响应内联换卡:决策按钮区收为一行文案(查看按钮保留,全文仍可回看)
916
+ card: { type: 'raw', data: buildLongOutputSettledCard(out.req, settledText) },
680
917
  };
681
918
  }
682
- return { toast: { type: 'info', content: '该提问已被处理或已过期' } };
919
+ return { toast: { type: 'info', content: '该操作无效或已过期' } };
683
920
  }
684
921
  const pending = confirmPending.get(action.value.requestId);
685
922
  if (!pending) {
@@ -709,7 +946,21 @@ deps, opts = {}) {
709
946
  return { toast: { type: 'error', content: retryable ? '处理失败,请重试' : '处理出现异常,决策可能已生效,请勿盲目重试' } };
710
947
  }
711
948
  }
712
- return { onMessage: handleIncoming, onCardAction: handleCardAction };
949
+ return {
950
+ onMessage: handleIncoming,
951
+ onCardAction: handleCardAction,
952
+ /**
953
+ * 测试/诊断入口:返回通道当前挂起的内嵌 plan / question requestId。
954
+ * 仅用于测试断言与生产期诊断,不影响业务流。channelKey 取 chatId + userId。
955
+ * 内嵌挂起项被 settle/超时清空时对应字段变 undefined
956
+ */
957
+ _pendingAsk(channelKey) {
958
+ const prog = activeProgress.get(channelKey);
959
+ if (!prog)
960
+ return {};
961
+ return prog.pendingAsk();
962
+ },
963
+ };
713
964
  }
714
965
  /**
715
966
  * 配置热重载器:lcb ws add/remove 写盘后,运行实例下一条消息即见新工作区
@@ -810,6 +1061,11 @@ export async function startBridge(configPath = CONFIG_PATH) {
810
1061
  uploadAndSendFile: (chatId, p) => gateway.uploadAndSendFile(chatId, p),
811
1062
  sendImageTo: (chatId, p, caption) => gateway.sendImage(chatId, p, caption),
812
1063
  deleteCard: (id) => gateway.deleteCard(id),
1064
+ // 卡片实体模式(cardkit):进度卡局部更新保 plan 表单输入;FeishuGateway 恒定实现,
1065
+ // 权限/接口异常时 ProgressCard.start 捕获降级为普通卡片 + 整卡 PATCH
1066
+ sendCardEntityTo: (chatId, card) => gateway.sendCardEntity(chatId, card),
1067
+ partialUpdateCardElements: (cardId, seq, actions) => gateway.partialUpdateCardElements(cardId, seq, actions),
1068
+ replaceCardEntity: (cardId, seq, card) => gateway.replaceCardEntity(cardId, seq, card),
813
1069
  },
814
1070
  access,
815
1071
  store: SessionStore.load(join(CONFIG_DIR, `sessions.${app.appId}.json`)),