@jesonliu/lark-claudecode-bridge 1.0.6 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +6 -4
  2. package/assets/web/css/main.css +46 -0
  3. package/assets/web/index.html +1 -0
  4. package/assets/web/js/pages/apps.js +118 -6
  5. package/assets/web/js/pages/claude.js +4 -7
  6. package/assets/web/js/pages/overview.js +57 -8
  7. package/assets/web/js/pages/permissions.js +45 -10
  8. package/assets/web/js/ui.js +167 -0
  9. package/config.example.yaml +20 -2
  10. package/dist/config-defaults.js +2 -1
  11. package/dist/config-defaults.js.map +1 -1
  12. package/dist/config.d.ts +4 -2
  13. package/dist/config.js +80 -5
  14. package/dist/config.js.map +1 -1
  15. package/dist/executor/claude-executor.d.ts +3 -0
  16. package/dist/executor/claude-executor.js +3 -0
  17. package/dist/executor/claude-executor.js.map +1 -1
  18. package/dist/executor/notify-server.d.ts +4 -18
  19. package/dist/executor/notify-server.js +6 -74
  20. package/dist/executor/notify-server.js.map +1 -1
  21. package/dist/executor/permission-gate.d.ts +10 -0
  22. package/dist/executor/permission-gate.js +24 -7
  23. package/dist/executor/permission-gate.js.map +1 -1
  24. package/dist/gateway/card-builder.d.ts +28 -20
  25. package/dist/gateway/card-builder.js +47 -56
  26. package/dist/gateway/card-builder.js.map +1 -1
  27. package/dist/gateway/diff-card.d.ts +1 -2
  28. package/dist/gateway/diff-card.js +2 -3
  29. package/dist/gateway/diff-card.js.map +1 -1
  30. package/dist/gateway/feishu-gateway.d.ts +2 -2
  31. package/dist/gateway/feishu-gateway.js +38 -9
  32. package/dist/gateway/feishu-gateway.js.map +1 -1
  33. package/dist/gateway/progress-card.d.ts +15 -5
  34. package/dist/gateway/progress-card.js +29 -14
  35. package/dist/gateway/progress-card.js.map +1 -1
  36. package/dist/index.js +176 -139
  37. package/dist/index.js.map +1 -1
  38. package/dist/notify-sop.d.ts +1 -1
  39. package/dist/notify-sop.js +21 -26
  40. package/dist/notify-sop.js.map +1 -1
  41. package/dist/session/commands.d.ts +20 -2
  42. package/dist/session/commands.js +58 -17
  43. package/dist/session/commands.js.map +1 -1
  44. package/dist/types.d.ts +26 -7
  45. package/dist/web/autostart.d.ts +49 -0
  46. package/dist/web/autostart.js +250 -0
  47. package/dist/web/autostart.js.map +1 -0
  48. package/dist/web/config-api.js +14 -2
  49. package/dist/web/config-api.js.map +1 -1
  50. package/dist/web/server.js +38 -1
  51. package/dist/web/server.js.map +1 -1
  52. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // 装配主流程:消息 → 访问控制 → 命令 → 通道队列 → 执行 → 确认卡片 → 回传
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
4
- import { basename, join, relative, resolve, sep } from 'node:path';
4
+ import { basename, join, relative, resolve } 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, initManagedClaudeDir, resolveClaudeDir } from './claude-config.js';
7
7
  import { buildTaskEnv } from './task-env.js';
@@ -11,12 +11,13 @@ import { startWebServer } from './web/server.js';
11
11
  import { clearPidFile, writePidFile, stopExistingBridgeAndWait } from './web/lifecycle.js';
12
12
  import { AccessControl } from './access/access-control.js';
13
13
  import { FeishuGateway } from './gateway/feishu-gateway.js';
14
- import { ProgressCard } from './gateway/progress-card.js';
15
- import { DECISION_TEXT, PROGRESS_TAIL_CHARS, LONG_OUTPUT_THRESHOLD, buildLongOutputCard, buildLongOutputSettledCard, } from './gateway/card-builder.js';
14
+ import { ProgressCard, feishuErrorBody } from './gateway/progress-card.js';
15
+ import { DECISION_TEXT, buildResultTailCard, buildWorkspaceSwitchedCard, resultChunkHeader, } from './gateway/card-builder.js';
16
+ import { chunkText } from './util/chunk-text.js';
16
17
  import { runTask } from './executor/claude-executor.js';
17
- import { buildFileDiffCards, DIFF_INLINE_MAX_CHARS } from './gateway/diff-card.js';
18
+ import { buildFileDiffCards } from './gateway/diff-card.js';
18
19
  import { collectFileDiff } from './util/workspace-diff.js';
19
- import { PermissionGate, DEFAULT_ALLOW_TOOLS_LIST, DEFAULT_DANGEROUS_COMMANDS, } from './executor/permission-gate.js';
20
+ import { PermissionGate, DEFAULT_ALLOW_TOOLS_LIST, DEFAULT_DANGEROUS_COMMANDS, DEFAULT_ALLOW_ALL_TOOLS, } from './executor/permission-gate.js';
20
21
  import { discoverPlugins, resolvePluginPaths } from './executor/plugin-discovery.js';
21
22
  import { createGatewaySender, createNotifyServer, NOTIFY_SERVER_NAME } from './executor/notify-server.js';
22
23
  import { BRIDGE_MCP_JSON, readMcpServersFromJsonFile } from './web/skills-mcp-api.js';
@@ -24,7 +25,7 @@ import { FEISHU_NOTIFY_SOP_PROMPT } from './notify-sop.js';
24
25
  import { isImageFile } from './util/file-types.js';
25
26
  import { FileTracker } from './util/file-tracker.js';
26
27
  import { SessionStore, migrateLegacySessions } from './session/session-store.js';
27
- import { handleCommand } from './session/commands.js';
28
+ import { handleCommand, applyWorkspaceSwitch, workspaceListMarkdown } from './session/commands.js';
28
29
  import { rewriteByTrigger } from './session/triggers.js';
29
30
  import { Semaphore, channelKey } from './session/channel.js';
30
31
  import { TranscriptWriter, sweepTranscripts } from './transcript/transcript-writer.js';
@@ -115,12 +116,11 @@ deps, opts = {}) {
115
116
  // 提问挂起项:requestId → 等待中的问题答案(qa-pick 逐项收集,qa-submit 一次性 resolve)。
116
117
  // 嵌入主卡片后无 cardId,改带 channelKey 定位活跃进度卡
117
118
  const qaPending = new Map();
118
- // 长回复卡挂起项(0.20.0):requestId → 收起卡的查看/确认/提意见上下文。与 planPending
119
- // 不同——无 Promise 要 resolve(任务已结束,确认/提意见是「发起新一轮」),条目仅用于:
120
- // 发起人校验(仅任务发起人可操作)、settled 防重(确认/提意见只生效一次,查看可反复)、
121
- // filePath/workspaceName 上下文。条目常驻供用户回看历史卡片时再次查看全文(量级:每次
122
- // 超长回复一条,几十字节;超 500 条清最老,防无界增长)
123
- const outputPending = new Map();
119
+ // 工作区切换卡挂起项(裸 /ws):requestId → 发卡上下文。「无 Promise 挂起」的常驻条目,
120
+ // 仅用于:发起记录、切换落盘目标通道(channelKey)、工作区快照(回调按
121
+ // 名查找目标)。stale 失效语义:同通道后发的新卡上完成切换后,旧卡点击一律回「已过期」——
122
+ // 否则旧卡显示的当前项是错的,用户会在旧卡上误切回旧工作区。上限 100 条删最老(防无界增长)
123
+ const wsPending = new Map();
124
124
  // 通道当前任务的进度卡:gate 的 ask/planAsk/askQuestion 闭包随 gate 通道级复用,
125
125
  // 不能捕获任务级 progress 实例(会是首个任务的旧卡)——经此间接引用每次任务的新卡
126
126
  const activeProgress = new Map();
@@ -217,23 +217,17 @@ deps, opts = {}) {
217
217
  await deps.gateway.sendTextTo(msg.chatId, `📊 ${displayName} 本次无可展示的改动(非 git 仓库 / 未改动 / 二进制)`);
218
218
  return true;
219
219
  }
220
+ // 全面卡片化(用户决策):不论多长一律多卡展示(~2800 字/块),不再落盘 .md 发文件附件;
221
+ // 连发间隔 300ms 防飞书消息频率限制;极长 diff(>10 万字 ≈ 35+ 张卡)先提示总卡数再发
220
222
  const widthMode = config.card?.width ?? 'default';
221
- if (diff.length <= DIFF_INLINE_MAX_CHARS) {
222
- for (const c of buildFileDiffCards(diff, { fileName: displayName }, widthMode)) {
223
- await deps.gateway.sendCardTo(msg.chatId, c);
224
- }
223
+ const cards = buildFileDiffCards(diff, { fileName: displayName }, widthMode);
224
+ if (diff.length > 100_000) {
225
+ await deps.gateway.sendTextTo(msg.chatId, `📊 ${displayName} 的 diff 极长(约 ${diff.length.toLocaleString()} 字符),将分 ${cards.length} 张卡片陆续发送,请稍候`);
225
226
  }
226
- else {
227
- // 超 6000 字:卡片口径防爆,落盘 .md 发文件附件;落盘失败兜底截断发卡(不静默丢内容)
228
- const p = writeRuntimeMarkdown('diffs', basename(file), `# 改动详情 · ${displayName} · ${new Date().toLocaleString('zh-CN', { hour12: false })}\n\n\`\`\`diff\n${diff}\n\`\`\`\n`, `${tag}[diff 落盘失败]:`);
229
- if (p) {
230
- await deps.gateway.uploadAndSendFile(msg.chatId, p);
231
- }
232
- else {
233
- for (const c of buildFileDiffCards(diff.slice(0, 12000), { fileName: displayName }, widthMode)) {
234
- await deps.gateway.sendCardTo(msg.chatId, c);
235
- }
236
- }
227
+ for (let i = 0; i < cards.length; i++) {
228
+ if (i > 0)
229
+ await new Promise((r) => setTimeout(r, 300));
230
+ await deps.gateway.sendCardTo(msg.chatId, cards[i]);
237
231
  }
238
232
  return true;
239
233
  }
@@ -253,9 +247,18 @@ deps, opts = {}) {
253
247
  // 配置热重载:lcb ws add/remove 独立进程写盘后,本实例下一条消息即读到新工作区
254
248
  opts.reloadConfig?.();
255
249
  if (!deps.access.isAllowed(msg.userId)) {
256
- // 首个使用者免配对直接成为 admin——按应用判定:多应用部署下 open_id 按应用隔离,
257
- // 新应用的第一个使用者同样是「首个」,不应因别的应用已有用户而被配对码拦住
258
- if (!deps.access.hasUsers(app.appId)) {
250
+ if (app.role === 'deputy') {
251
+ // 分身机器人艾特即用(用户决策):不走配对——首条消息自动补录 member(access.json
252
+ // 保留审计记录,isAllowed 后续校验天然兼容);无 admin 权限,
253
+ // 管理命令与工作区切换在各自入口被拒,能力范围由分身配置限定
254
+ deps.access.addUser(msg.userId, msg.userId, 'member', app.appId);
255
+ console.log(`${tag}[接入] 分身应用 ${app.appId} 新用户 ${msg.userId} 已自动加入(member,能力按分身配置限定)`);
256
+ // 不 return:本条消息照常进入命令/任务处理
257
+ }
258
+ else if (!deps.access.hasUsers(app.appId)) {
259
+ // 首个使用者免配对直接成为 admin——按应用判定:多应用部署下 open_id 按应用隔离,
260
+ // 新应用的第一个使用者同样是「首个」,不应因别的应用已有用户而被配对码拦住
261
+ deps.access.addUser(msg.userId, msg.userId, 'admin', app.appId);
259
262
  deps.access.addUser(msg.userId, msg.userId, 'admin', app.appId);
260
263
  console.log(`${tag}[接入] 应用 ${app.appId} 首位用户 ${msg.userId} 已自动成为 admin(后续用户需配对)`);
261
264
  await deps.gateway.sendTextTo(msg.chatId, `🎉 欢迎!你是本机器人的首位用户,已自动成为 **admin**。\n\n后续其他同事首次使用时需由你在桥接器终端批准配对(\`lcb pair <配对码>\`)。`);
@@ -277,8 +280,12 @@ deps, opts = {}) {
277
280
  const st = deps.store.getChannelState(key);
278
281
  // currentWorkspace 语义:store 中已设置的工作区优先,否则 app 默认,最后全局默认——
279
282
  // 保证 /new、/status 回复与实际一致。用 || 而非 ??:archiveSession 为新通道建档时
280
- // workspaceName 存 ''(归档时不知工作区),空串必须回退默认,否则 inventory 键错位
281
- const currentWorkspace = st?.workspaceName || app.defaultWorkspace || config.defaults.workspace;
283
+ // workspaceName 存 ''(归档时不知工作区),空串必须回退默认,否则 inventory 键错位。
284
+ // 分身角色恒锁首个允许的工作区(config 已校验非空):命令上下文、inventory 键与任务
285
+ // wsName 同源锁定,通道 state 旧存的 workspaceName 一律不采信
286
+ const currentWorkspace = app.role === 'deputy'
287
+ ? app.allowedWorkspaces[0]
288
+ : (st?.workspaceName || app.defaultWorkspace || config.defaults.workspace);
282
289
  // 2.5 触发词映射:必须放在本地命令之前——斜杠触发词(如 /produce)会被 handleCommand
283
290
  // 的未知命令分支吞掉;rewriteByTrigger 对本地命令(/stop 等)直接放行,不会被劫持
284
291
  const triggered = rewriteByTrigger(msg.text, app.triggers);
@@ -292,9 +299,13 @@ deps, opts = {}) {
292
299
  store: deps.store,
293
300
  config,
294
301
  appName: app.name,
295
- isAdmin: deps.access.isAdmin(msg.userId),
302
+ // 管理命令闸(ctx.isAdmin 唯一入口,/ws use、/model-profile、/plugin 变更类经此流经):
303
+ // 分身机器人一律 false——管理面(工作区/模型档案/插件)全局共享,分身用户不该触碰,
304
+ // 连 admin 本人艾特分身也不放行(要用管理命令请找主机器人)
305
+ isAdmin: app.role !== 'deputy' && deps.access.isAdmin(msg.userId),
306
+ isDeputy: app.role === 'deputy',
296
307
  currentWorkspace: () => currentWorkspace,
297
- getInventory: () => inventories.get(currentWorkspace),
308
+ getInventory: () => inventories.get(`${app.appId}:${currentWorkspace}`),
298
309
  // /mcp 实时拉取:当前工作区有存活 query 才有实时数据(5s 超时),否则回退 init/收尾快照
299
310
  getLiveMcpStatus: async () => {
300
311
  try {
@@ -322,8 +333,40 @@ deps, opts = {}) {
322
333
  },
323
334
  });
324
335
  if (cmd.handled) {
325
- if (cmd.reply)
336
+ if (cmd.card) {
337
+ // 交互卡片命令(裸 /ws):发卡并注册挂起项供回调切换。快照工作区清单——回调按
338
+ // 名查找目标,配置热重载改清单不影响已发出卡片的自洽性
339
+ const requestId = cmd.cardRequestId ?? randomUUID();
340
+ wsPending.set(requestId, {
341
+ ownerId: msg.userId,
342
+ channelKey: key,
343
+ chatId: msg.chatId,
344
+ workspaces: config.workspaces.map((w) => ({ name: w.name, path: w.path })),
345
+ currentName: currentWorkspace,
346
+ stale: false,
347
+ });
348
+ // 上限 100 条删最老(Map 迭代序 = 插入序,首个即最老),防长期运行无界增长
349
+ if (wsPending.size > 100) {
350
+ const oldest = wsPending.keys().next().value;
351
+ if (oldest !== undefined && oldest !== requestId)
352
+ wsPending.delete(oldest);
353
+ }
354
+ // 发卡失败(卡片 schema 被飞书拒绝 / WS 偶发无响应挂起)降级文本清单——裸 /ws 的
355
+ // 唯一响应形态是卡片,静默吞错在用户侧就是「命令没反应」。raceFallback 同时覆盖
356
+ // 抛错与挂起(见 CARD_SEND_TIMEOUT_MS 注释),失败时 /ws use 文本命令兜底可用
357
+ const cardSent = await raceFallback(deps.gateway.sendCardTo(msg.chatId, cmd.card).then(() => true, (e) => {
358
+ console.error(tag, '[/ws] 工作区卡片发送失败,降级为文本列表', e, 'code=', e?.code, 'msg=', e?.msg);
359
+ return false;
360
+ }), CARD_SEND_TIMEOUT_MS, false);
361
+ if (!cardSent) {
362
+ wsPending.delete(requestId); // 卡未发出绝无回调,清挂起防占位
363
+ await deps.gateway.sendTextTo(msg.chatId, `**工作区列表**(当前:**${currentWorkspace}**)\n${workspaceListMarkdown(config.workspaces, currentWorkspace)}`
364
+ + '\n\n⚠️ 工作区卡片发送失败(详见桥接器日志),已降级为文本。切换:`/ws use <名字>`').catch(() => { });
365
+ }
366
+ }
367
+ else if (cmd.reply) {
326
368
  await deps.gateway.sendTextTo(msg.chatId, cmd.reply);
369
+ }
327
370
  // 指针变更代际 bump:/new、/resume <n>、/ws use 都改写了 currentSessionId——
328
371
  // 运行中/收尾窗口内任务的迟到归档据 pointerGen 放弃回写指针(archive 闭包比对)
329
372
  if (cmd.pointerTouched) {
@@ -365,12 +408,6 @@ deps, opts = {}) {
365
408
  qaPending.delete(id);
366
409
  p.resolve({});
367
410
  }
368
- // 本用户在本会话的长回复卡确认/提意见按钮一并失效(保留查看全文):新会话已无
369
- // 「上一轮方案」上下文,旧确认按钮再触发只会得到脱离语境的新一轮任务
370
- for (const o of outputPending.values()) {
371
- if (o.chatId === msg.chatId && o.ownerId === msg.userId)
372
- o.settled = true;
373
- }
374
411
  gates.get(key)?.reset();
375
412
  if (cancelled > 0) {
376
413
  await deps.gateway.sendTextTo(msg.chatId, `🗑️ 已因新建会话取消 ${cancelled} 条排队消息`);
@@ -419,6 +456,8 @@ deps, opts = {}) {
419
456
  if (!gate) {
420
457
  gate = new PermissionGate({
421
458
  allowTools: () => new Set(config.permissions?.allowTools ?? DEFAULT_ALLOW_TOOLS_LIST),
459
+ // ?? 而非 ||:显式 false(用户主动关闭全部免确认)不能被回落到缺省的 true
460
+ allowAllTools: () => config.permissions?.allowAllTools ?? DEFAULT_ALLOW_ALL_TOOLS,
422
461
  dangerousCommands: () => config.permissions?.dangerousCommands ?? DEFAULT_DANGEROUS_COMMANDS,
423
462
  ask: async (req) => {
424
463
  // 工具确认嵌入当前任务的进度卡(按钮在计时行上方、正文收敛)——不再单独发确认卡:
@@ -535,7 +574,8 @@ deps, opts = {}) {
535
574
  // lcb-notify 发送能力:chatId 硬绑定当前任务(权限闸直通的安全前提);
536
575
  // sentPaths 记录中途已推送的文件,任务收尾的产出回传据此去重(用户已收过的不重发)。
537
576
  // 每次中途推送后进度卡沉底一次(删旧卡重发),保持「任务是否还在跑」始终可见于会话底部
538
- // SOP 开关:本任务级现读 config.session.notifySop(缺省 true),决定后续 sendText 是否被强制降级
577
+ // SOP 开关:本任务级现读 config.session.notifySop(缺省 true),决定是否注入推送规范
578
+ // 提示词(v2 为纯内容分类软约束;旧「>50 行转文件」硬兜底已移除,send_text 恒多卡)
539
579
  const sopEnabled = config.session?.notifySop !== false;
540
580
  const sentPaths = new Set();
541
581
  const sink = async () => { await activeProgress.get(key)?.sinkToBottom(); };
@@ -558,8 +598,6 @@ deps, opts = {}) {
558
598
  await deps.gateway.uploadAndSendFile(c, p);
559
599
  await sink();
560
600
  },
561
- }, {
562
- enabled: sopEnabled,
563
601
  });
564
602
  activeProgress.set(key, progress);
565
603
  // 指针代际快照:任务运行期间 /new、/resume <n>、/ws use 都会 bump pointerGen,本任务
@@ -603,12 +641,17 @@ deps, opts = {}) {
603
641
  resumeSessionId: resumeId,
604
642
  signal: abort.signal,
605
643
  env: taskEnv,
606
- // #11 SOP 软约束:app.appendSystemPrompt 后追加 SOP 摘要(关闭时仅传 app 原有部分)
644
+ // 人设 + SOP 软约束:人设裸文本追加在 Claude Code 长系统提示末尾,对模型的约束力
645
+ // 天然偏弱(resume 旧会话时历史风格会带偏)——包一层显式遵循框架语显著改善
646
+ // 称谓/口吻/能力定位的遵循度(提示工程手段,非硬约束);SOP 摘要附后
607
647
  appendSystemPrompt: (() => {
608
- const base = app.appendSystemPrompt?.trim() ?? '';
648
+ const raw = app.appendSystemPrompt?.trim() ?? '';
649
+ const persona = raw
650
+ ? `【机器人人设(用户为该机器人配置,须在每一轮回复中严格遵循,优先级高于默认回复风格)】\n${raw}\n【人设结束】以上人设适用于本会话每一轮回复——包括称呼、口吻、能力定位的自我介绍;即使历史回复风格不同,也从本轮起遵循。`
651
+ : '';
609
652
  if (!sopEnabled)
610
- return base || undefined;
611
- return base ? `${base}\n\n${FEISHU_NOTIFY_SOP_PROMPT}` : FEISHU_NOTIFY_SOP_PROMPT;
653
+ return persona || undefined;
654
+ return persona ? `${persona}\n\n${FEISHU_NOTIFY_SOP_PROMPT}` : FEISHU_NOTIFY_SOP_PROMPT;
612
655
  })(),
613
656
  // 计划模式(#6):通道级 /plan 开关——模型先出计划(ExitPlanMode → planAsk 飞书卡片),
614
657
  // 用户批准后 SDK 自动切回可编辑模式继续执行(替代旧工作区 code-dev 类型)
@@ -622,26 +665,37 @@ deps, opts = {}) {
622
665
  // 插件:config.yaml 显式配置(开发期指源码目录)+ Claude 配置目录已启用的 marketplace
623
666
  // 插件自动发现,同名显式优先;SDK 对无效路径静默跳过,实际加载以 init 清单(/plugins 命令)为准。
624
667
  // managed 模式合并发现两处目录(自管 + 本机 ~/.claude):按绝对 installPath 传 SDK,
625
- // 用户本机已装插件无须重装;resolvePluginPaths 按 name 去重,当前生效目录优先
668
+ // 用户本机已装插件无须重装;resolvePluginPaths 按 name 去重,当前生效目录优先。
669
+ // 分身角色按 allowed_plugins 白名单过滤(显式 app.plugins 始终保留——写在分身配置里
670
+ // 的就是要给的);未配 allowed_plugins 时自动发现全剔除(分身不声明 = 不加载)
626
671
  ...(() => {
627
672
  const discovered = claudeDir === DEFAULT_CLAUDE_DIR
628
673
  ? discoverPlugins(claudeDir)
629
674
  : [...discoverPlugins(claudeDir), ...discoverPlugins(DEFAULT_CLAUDE_DIR)];
630
- const merged = resolvePluginPaths(app.plugins, discovered);
675
+ let merged = resolvePluginPaths(app.plugins, discovered);
676
+ if (app.role === 'deputy') {
677
+ merged = merged.filter((p) => app.plugins?.some((e) => e.name === p.name) || app.allowedPlugins?.includes(p.name));
678
+ }
631
679
  return merged.length ? { plugins: merged.map((p) => ({ path: p.path })) } : {};
632
680
  })(),
681
+ // 分身技能白名单:透传 SDK Options.skills(官方上下文过滤器——未列出技能对模型不可见
682
+ // 且被 Skill 工具拒绝;非沙箱:技能文件仍在磁盘可被 Read/Bash 读到,用户已知悉此边界)
683
+ ...(app.role === 'deputy' && app.allowedSkills?.length ? { allowedSkills: app.allowedSkills } : {}),
633
684
  canUseTool: (toolName, input, ctx) => gate.decide(toolName, input, wsName, ctx),
634
685
  onQuery: (handle) => { activeQueries.set(key, { wsName, handle }); },
635
686
  }, {
636
687
  onInit: (inv) => {
637
- inventories.set(wsName, { ...inv, workspace: wsName, loadedAt: new Date().toISOString() });
688
+ // inventory 键带 appId 前缀:分身机器人技能/插件集与主机器人不同,两个 app 共用
689
+ // 工作区时 /skills /plugins /mcp 清单不能互相污染
690
+ inventories.set(`${app.appId}:${wsName}`, { ...inv, workspace: wsName, loadedAt: new Date().toISOString() });
638
691
  },
639
692
  onMcpStatus: (servers) => {
640
693
  // 任务收尾时 executor 拉取的真实 MCP 状态回写 inventory——init 快照必为 pending,
641
694
  // 这是 /mcp 空闲期显示 connected/failed 的数据源(prev 不存在说明 init 未到,等 init 全量落盘)
642
- const prev = inventories.get(wsName);
695
+ const invKey = `${app.appId}:${wsName}`;
696
+ const prev = inventories.get(invKey);
643
697
  if (prev)
644
- inventories.set(wsName, { ...prev, mcpServers: servers, loadedAt: new Date().toISOString() });
698
+ inventories.set(invKey, { ...prev, mcpServers: servers, loadedAt: new Date().toISOString() });
645
699
  },
646
700
  onProgress: (e) => {
647
701
  if (e.kind === 'status') {
@@ -649,6 +703,7 @@ deps, opts = {}) {
649
703
  progress.setStatus(e.content);
650
704
  }
651
705
  else if (e.kind === 'text') {
706
+ progress.markStarted();
652
707
  progress.appendText(e.content);
653
708
  // 落盘 · assistant:流式文本块全文(每块一行,天然增量)
654
709
  deps.transcript?.assistant({
@@ -658,6 +713,7 @@ deps, opts = {}) {
658
713
  }
659
714
  else if (e.kind === 'tool-start') {
660
715
  const [n, ...rest] = e.content.split(': ');
716
+ progress.markStarted();
661
717
  progress.toolStart(n, rest.join(': '));
662
718
  deps.transcript?.tool({
663
719
  v: 1, ts: now(), kind: 'tool', app: app.appId,
@@ -691,7 +747,9 @@ deps, opts = {}) {
691
747
  });
692
748
  return;
693
749
  }
694
- await progress.finish(`✅ 完成`);
750
+ // finalText 提前算好:finish 携带字数,终态进度卡据此显示「完整回复已单独发送」提示行
751
+ const finalText = outcome.finalText?.trim() ?? '';
752
+ await progress.finish('✅ 完成', finalText.length || undefined);
695
753
  archive(outcome.sessionId);
696
754
  deps.transcript?.result({
697
755
  v: 1, ts: now(), kind: 'result', app: app.appId,
@@ -710,38 +768,26 @@ deps, opts = {}) {
710
768
  const scale = outcome.usage ? `(当前约 ${(ctxTokens / 10000).toFixed(1)} 万 tokens)` : '';
711
769
  await deps.gateway.sendTextTo(msg.chatId, `💡 本会话上下文已较大${scale},建议发送 /new 开启新会话(/resume 可随时切回)。阈值可在 config.yaml 的 session.context_remind_tokens 调整,设为 0 关闭`);
712
770
  }
713
- // 结果回传(超长截断,防飞书消息体超限)。
714
- // 短回复(≤进度卡正文上限)跳过独立结果消息:finalText 是最后一个 assistant 消息的文本,
715
- // 而 textTail 按序累积全部流式文本块、卡片取尾部 PROGRESS_TAIL_CHARS 字——finalText 不超上限时
716
- // 必然完整落在进度卡终态里,再发一条就是内容几乎逐字相同的重复消息(0.11.0 修复的双推 bug)
717
- if (outcome.finalText && outcome.finalText.length > PROGRESS_TAIL_CHARS) {
718
- // 结果回传(0.20.0 三段式):
719
- // - 400 字内:进度卡终态已完整展示,不发独立消息(0.11.0 双推修复)
720
- // - 400~2000 字:直接发全文卡片(旧 slice 上限对这段区间本就不触发)
721
- // - >2000 字:落盘 md + 收起卡(查看全文按钮 + 确认方案/按意见修改引导,点击
722
- // 即发起新一轮任务),替代旧版 4000 字硬截断——超长部分不再静默丢弃
723
- const text = outcome.finalText;
724
- if (text.length > LONG_OUTPUT_THRESHOLD) {
725
- const outFilePath = writeRuntimeMarkdown('outputs', wsName, `# 完整回复 · ${wsName} · ${new Date().toLocaleString('zh-CN', { hour12: false })}\n\n${text}\n`, `${tag}[长回复落盘失败,回退为截断发送]:`);
726
- if (outFilePath) {
727
- const outReq = {
728
- requestId: randomUUID(), filePath: outFilePath,
729
- charCount: text.length, workspaceName: wsName,
730
- };
731
- outputPending.set(outReq.requestId, { req: outReq, ownerId: msg.userId, chatId: msg.chatId, settled: false });
732
- if (outputPending.size > 500) {
733
- // 防无界增长:清最老的条目(Map 迭代序即插入序;被清卡片的确认按钮会提示已过期)
734
- outputPending.delete(outputPending.keys().next().value);
735
- }
736
- await deps.gateway.sendCardTo(msg.chatId, buildLongOutputCard(outReq, config.card?.width ?? 'default'));
771
+ // 结果回传两段式:finalText 非空即独立多卡直接展示(~3000 字/块,连发间隔 300ms 防飞书
772
+ // 消息限流)——不论长短都不再进进度卡正文(主卡只留提示行),彻底消除「折叠看不到全文」
773
+ // 与「卡片/消息两边重复」(旧三段式 ≤400 终态自载的边界问题)。尾卡纯展示,想继续对话/
774
+ // 提意见直接回复消息即可
775
+ if (finalText) {
776
+ const chunks = chunkText(finalText);
777
+ const total = chunks.length;
778
+ const resultReq = {
779
+ charCount: finalText.length, workspaceName: wsName,
780
+ };
781
+ for (let i = 0; i < total; i++) {
782
+ if (i > 0)
783
+ await new Promise((r) => setTimeout(r, 300));
784
+ if (i === total - 1) {
785
+ await deps.gateway.sendCardTo(msg.chatId, buildResultTailCard(resultReq, chunks[i], { index: total, total }, config.card?.width ?? 'default'));
737
786
  }
738
787
  else {
739
- await deps.gateway.sendTextTo(msg.chatId, text.slice(0, 4000)); // 落盘失败兜底:维持旧截断行为
788
+ await deps.gateway.sendTextTo(msg.chatId, `${resultChunkHeader(resultReq, i + 1, total)}\n\n${chunks[i]}`);
740
789
  }
741
790
  }
742
- else {
743
- await deps.gateway.sendTextTo(msg.chatId, text);
744
- }
745
791
  }
746
792
  // 图片自动发(im.image.create 通道),非图片文件不自动回传——
747
793
  // 用户从下面的文件清单里看到改了哪些文件,主动点名 basename 后由 tryDeliverRequestedFiles 处理
@@ -787,8 +833,10 @@ deps, opts = {}) {
787
833
  return;
788
834
  }
789
835
  // 常见错误附配置指引(认证 hint 按模式分流:managed 指向配置页,inherit 指向 claude login);
790
- // No conversation found 多因旧版独立目录里的会话(本版起统一 Claude 配置目录,旧会话无法跨目录 resume)
791
- const errText = String(e);
836
+ // No conversation found 多因旧版独立目录里的会话(本版起统一 Claude 配置目录,旧会话无法跨目录 resume)。
837
+ // 飞书 API 4xx 时 axios 的 message 只有裸 "status code 400",业务错误码(230099/200621 等)
838
+ // 藏在 e.response.data——拼上响应体摘要,错误卡与日志可直接定位拒绝原因
839
+ const errText = `${String(e)}${feishuErrorBody(e) ? `\n(飞书:${feishuErrorBody(e)})` : ''}`;
792
840
  const uiUrl = serverUrl(config);
793
841
  const hint = errText.includes('Not logged in')
794
842
  ? config.claude?.mode === 'managed'
@@ -809,12 +857,10 @@ deps, opts = {}) {
809
857
  deps.transcript?.result({
810
858
  v: 1, ts: nowBeijingISO(), kind: 'result', app: app.appId,
811
859
  chatId: msg.chatId, userId: msg.userId, sessionId: '',
812
- subtype: 'error', text: String(e).slice(0, 300),
860
+ subtype: 'error', text: errText.slice(0, 500), // 实录同带响应体摘要(排查飞书 4xx 不再依赖翻日志)
813
861
  });
814
862
  }
815
863
  finally {
816
- // SOP 降级单文件补发(#7):任务任何结局(完成/停止/出错)都在收尾补齐完整附件
817
- await notifySender.flushDowngradedFile?.().catch(() => { });
818
864
  clearTimeout(hardTimeout);
819
865
  activeProgress.delete(key);
820
866
  activeQueries.delete(key);
@@ -937,61 +983,51 @@ deps, opts = {}) {
937
983
  }
938
984
  return { toast: { type: 'info', content: '该提问已被处理或已过期' } };
939
985
  }
940
- // 长回复收起卡(0.20.0):任务已结束,无 Promise 挂起——outputPending 提供发起人校验、
941
- // settled 防重与文件上下文。「查看完整内容」可反复点击;「确认方案 / 按意见修改」合成
942
- // 一条用户消息入队,走与真实消息完全相同的排队/resume/进度卡链路发起新一轮任务
943
- const out = outputPending.get(action.value.requestId);
944
- if (out) {
945
- if (action.operatorId !== out.ownerId) {
946
- console.log(tag, `[卡片回调] 非发起人 ${action.operatorId} 点击长回复卡 ${action.value.requestId},已忽略`);
947
- return { toast: { type: 'info', content: '仅任务发起人可操作' } };
986
+ // 工作区切换卡(裸 /ws):无 Promise 挂起——wsPending 提供 admin 校验、stale 失效
987
+ // 与工作区快照。权限语义与 /ws use 一致是「admin 可切换」而非「仅发起人」:member
988
+ // 能拿卡看列表,点按钮被拒(卡片保持原样,admin 仍可点),与其它卡的 ownerId 校验刻意不同。
989
+ // 分身机器人工作区已锁定(allowed_workspaces),切换一律拒绝
990
+ const wsc = wsPending.get(action.value.requestId);
991
+ if (wsc) {
992
+ if (app.role === 'deputy') {
993
+ return { toast: { type: 'info', content: '⛔ 分身机器人已锁定工作区,不可切换' } };
948
994
  }
949
- const decision = action.value.decision;
950
- if (decision === 'view-output-file') {
951
- // 白名单校验:仅允许 outputs 目录内文件(value 虽由桥接器写入卡片,纵深防御不亏)
952
- const outputsDir = join(CONFIG_DIR, 'outputs');
953
- if (!out.req.filePath.startsWith(outputsDir + sep)) {
954
- return { toast: { type: 'error', content: '文件路径校验失败,已忽略' } };
955
- }
956
- void deps.gateway.uploadAndSendFile(out.chatId, out.req.filePath)
957
- .catch((e) => console.error('[完整回复发送失败]', action.value.requestId, e));
958
- return { toast: { type: 'info', content: '正在发送完整回复…' } };
995
+ if (wsc.stale) {
996
+ return { toast: { type: 'info', content: '该列表已过期,请以最新 /ws 卡片为准' } };
959
997
  }
960
- if (out.settled) {
961
- return { toast: { type: 'info', content: '该回复的处理已提交,无需重复操作' } };
998
+ if (!deps.access.isAdmin(action.operatorId)) {
999
+ console.log(tag, `[卡片回调] 非 admin ${action.operatorId} 点击工作区卡 ${action.value.requestId},已忽略`);
1000
+ return { toast: { type: 'info', content: '⛔ 切换工作区仅管理员可用' } };
962
1001
  }
963
- if (decision === 'output-confirm' || decision === 'output-revise') {
964
- const feedback = (action.value.feedback ?? '').trim();
965
- if (decision === 'output-revise' && !feedback) {
966
- return { toast: { type: 'warning', content: '请先在输入框填写修改意见(或点「确认方案」)' } };
967
- }
968
- out.settled = true;
969
- const prompt = decision === 'output-confirm'
970
- ? '用户已在飞书点击「确认方案」,确认了上一轮的完整回复(方案),请继续按该方案推进执行。'
971
- : `用户要求按以下意见调整上一轮完整回复中的方案:\n${feedback}`;
972
- // 合成用户消息入队:新一轮会 resume 上一会话(「上一轮方案」上下文天然在场)。
973
- // 发起人必在访问白名单(能发起任务才拿得到卡片),仍做 isAllowed 纵深校验;
974
- // chatType 不参与任务链路(仅 gateway 消息解析用),合成消息固定 p2p
975
- if (deps.access.isAllowed(action.operatorId)) {
976
- const key = channelKey(out.chatId, action.operatorId);
977
- enqueue(key, {
978
- chatId: out.chatId, chatType: 'p2p', userId: action.operatorId,
979
- text: prompt, messageId: `output-${action.value.requestId}`,
980
- }, prompt, out.req.workspaceName);
981
- }
982
- else {
983
- console.warn(tag, '[长回复卡] 发起人不在访问白名单,已忽略新一轮请求:', action.operatorId);
984
- }
985
- const settledText = decision === 'output-confirm'
986
- ? '✅ 已确认该方案,正在发起新一轮任务(详情见下方新进度卡)'
987
- : '✏️ 修改意见已收到,正在发起新一轮任务(详情见下方新进度卡)';
988
- return {
989
- toast: { type: 'success', content: settledText },
990
- // 回调响应内联换卡:决策按钮区收为一行文案(查看按钮保留,全文仍可回看)
991
- card: { type: 'raw', data: buildLongOutputSettledCard(out.req, settledText, config.card?.width ?? 'default') },
992
- };
1002
+ const target = wsc.workspaces.find((w) => w.name === action.value.ws);
1003
+ if (!target) {
1004
+ return { toast: { type: 'info', content: '该工作区不存在,请重新发送 /ws' } };
1005
+ }
1006
+ // 当前项判定(disabled 按钮不回调;不支持 disabled 的客户端点当前项时兜底,防冗余
1007
+ // 切换清掉续接指针):实时 workspaceName 优先,跟随默认未显式切换则回退发卡时快照
1008
+ const wsSt = deps.store.getChannelState(wsc.channelKey);
1009
+ const wsCurrent = wsSt?.workspaceName || wsc.currentName;
1010
+ if (target.name === wsCurrent) {
1011
+ return { toast: { type: 'info', content: '该工作区已是当前工作区' } };
1012
+ }
1013
+ applyWorkspaceSwitch(deps.store, wsc.channelKey, target);
1014
+ // 等价 cmd.pointerTouched:切换清了续接指针,运行中/收尾窗口内任务的迟到归档不得回写
1015
+ const wsRt = runtimes.get(wsc.channelKey);
1016
+ if (wsRt)
1017
+ wsRt.pointerGen = (wsRt.pointerGen ?? 0) + 1;
1018
+ // 同通道其余工作区卡置 stale:旧卡显示的当前项已过时,继续可点会诱导误切回旧工作区
1019
+ for (const [id, e] of wsPending) {
1020
+ if (e.channelKey === wsc.channelKey && id !== action.value.requestId)
1021
+ e.stale = true;
993
1022
  }
994
- return { toast: { type: 'info', content: '该操作无效或已过期' } };
1023
+ console.log(tag, `[工作区] 卡片切换 ${wsCurrent} → ${target.name}(${action.operatorId})`);
1024
+ // 一次性选择语义:本卡随即终态化(移除挂起项,迟到点击按孤儿卡 toast 提示),再切重发 /ws
1025
+ wsPending.delete(action.value.requestId);
1026
+ return {
1027
+ toast: { type: 'success', content: `✅ 已切换工作区:${target.name}(已开启新会话,/resume 可切回历史)` },
1028
+ // 回调响应内联换卡:整卡替换为终态提示(纯 markdown 无按钮),不能再点
1029
+ card: { type: 'raw', data: buildWorkspaceSwitchedCard(target.name, target.path, config.card?.width ?? 'default') },
1030
+ };
995
1031
  }
996
1032
  const pending = confirmPending.get(action.value.requestId);
997
1033
  if (!pending) {
@@ -1017,7 +1053,8 @@ deps, opts = {}) {
1017
1053
  catch (e) {
1018
1054
  console.error(tag, '[卡片回调异常]', action.value.requestId, e);
1019
1055
  // resolve 之后的代码理论不可抛;若未来插入可抛代码,按条目是否仍在区分决策是否已生效
1020
- const retryable = confirmPending.has(action.value.requestId) || planPending.has(action.value.requestId) || qaPending.has(action.value.requestId);
1056
+ const retryable = confirmPending.has(action.value.requestId) || planPending.has(action.value.requestId) || qaPending.has(action.value.requestId)
1057
+ || (wsPending.get(action.value.requestId)?.stale === false);
1021
1058
  return { toast: { type: 'error', content: retryable ? '处理失败,请重试' : '处理出现异常,决策可能已生效,请勿盲目重试' } };
1022
1059
  }
1023
1060
  }