@jesonliu/lark-claudecode-bridge 1.0.6 → 1.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/README.md +6 -4
- package/assets/web/css/main.css +46 -0
- package/assets/web/index.html +1 -0
- package/assets/web/js/pages/apps.js +118 -6
- package/assets/web/js/pages/claude.js +4 -7
- package/assets/web/js/pages/overview.js +57 -8
- package/assets/web/js/pages/permissions.js +45 -10
- package/assets/web/js/ui.js +167 -0
- package/config.example.yaml +20 -2
- package/dist/config-defaults.js +2 -1
- package/dist/config-defaults.js.map +1 -1
- package/dist/config.d.ts +4 -2
- package/dist/config.js +80 -5
- package/dist/config.js.map +1 -1
- package/dist/executor/claude-executor.d.ts +3 -0
- package/dist/executor/claude-executor.js +3 -0
- package/dist/executor/claude-executor.js.map +1 -1
- package/dist/executor/notify-server.d.ts +4 -18
- package/dist/executor/notify-server.js +6 -74
- package/dist/executor/notify-server.js.map +1 -1
- package/dist/executor/permission-gate.d.ts +10 -0
- package/dist/executor/permission-gate.js +24 -7
- package/dist/executor/permission-gate.js.map +1 -1
- package/dist/gateway/card-builder.d.ts +30 -19
- package/dist/gateway/card-builder.js +63 -46
- package/dist/gateway/card-builder.js.map +1 -1
- package/dist/gateway/diff-card.d.ts +1 -2
- package/dist/gateway/diff-card.js +2 -3
- package/dist/gateway/diff-card.js.map +1 -1
- package/dist/gateway/feishu-gateway.d.ts +2 -2
- package/dist/gateway/feishu-gateway.js +35 -5
- package/dist/gateway/feishu-gateway.js.map +1 -1
- package/dist/gateway/progress-card.d.ts +15 -5
- package/dist/gateway/progress-card.js +29 -14
- package/dist/gateway/progress-card.js.map +1 -1
- package/dist/index.js +198 -94
- package/dist/index.js.map +1 -1
- package/dist/notify-sop.d.ts +1 -1
- package/dist/notify-sop.js +21 -26
- package/dist/notify-sop.js.map +1 -1
- package/dist/session/commands.d.ts +20 -2
- package/dist/session/commands.js +58 -17
- package/dist/session/commands.js.map +1 -1
- package/dist/types.d.ts +27 -7
- package/dist/web/autostart.d.ts +49 -0
- package/dist/web/autostart.js +250 -0
- package/dist/web/autostart.js.map +1 -0
- package/dist/web/config-api.js +14 -2
- package/dist/web/config-api.js.map +1 -1
- package/dist/web/server.js +38 -1
- package/dist/web/server.js.map +1 -1
- 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
|
|
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,
|
|
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
|
|
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,16 @@ deps, opts = {}) {
|
|
|
115
116
|
// 提问挂起项:requestId → 等待中的问题答案(qa-pick 逐项收集,qa-submit 一次性 resolve)。
|
|
116
117
|
// 嵌入主卡片后无 cardId,改带 channelKey 定位活跃进度卡
|
|
117
118
|
const qaPending = new Map();
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
// 超长回复一条,几十字节;超 500 条清最老,防无界增长)
|
|
119
|
+
// 结论尾卡挂起项:requestId → 结论卡上下文。与 planPending 不同——无 Promise 要 resolve
|
|
120
|
+
// (任务已结束,确认/提意见是「发起新一轮」),条目仅用于:发起人校验(仅任务发起人可
|
|
121
|
+
// 操作)、settled 防重(确认/提意见只生效一次)、尾卡正文快照(settled 回调响应内联换卡
|
|
122
|
+
// 替换整卡,必须带回正文)。条目常驻供回看历史(每次有回复的任务一条;超 500 条清最老)
|
|
123
123
|
const outputPending = new Map();
|
|
124
|
+
// 工作区切换卡挂起项(裸 /ws):requestId → 发卡上下文。与 outputPending 同为「无 Promise
|
|
125
|
+
// 挂起」的常驻条目,仅用于:发起记录、切换落盘目标通道(channelKey)、工作区快照(回调按
|
|
126
|
+
// 名查找目标)。stale 失效语义:同通道后发的新卡上完成切换后,旧卡点击一律回「已过期」——
|
|
127
|
+
// 否则旧卡显示的当前项是错的,用户会在旧卡上误切回旧工作区。上限 100 条删最老(防无界增长)
|
|
128
|
+
const wsPending = new Map();
|
|
124
129
|
// 通道当前任务的进度卡:gate 的 ask/planAsk/askQuestion 闭包随 gate 通道级复用,
|
|
125
130
|
// 不能捕获任务级 progress 实例(会是首个任务的旧卡)——经此间接引用每次任务的新卡
|
|
126
131
|
const activeProgress = new Map();
|
|
@@ -217,23 +222,17 @@ deps, opts = {}) {
|
|
|
217
222
|
await deps.gateway.sendTextTo(msg.chatId, `📊 ${displayName} 本次无可展示的改动(非 git 仓库 / 未改动 / 二进制)`);
|
|
218
223
|
return true;
|
|
219
224
|
}
|
|
225
|
+
// 全面卡片化(用户决策):不论多长一律多卡展示(~2800 字/块),不再落盘 .md 发文件附件;
|
|
226
|
+
// 连发间隔 300ms 防飞书消息频率限制;极长 diff(>10 万字 ≈ 35+ 张卡)先提示总卡数再发
|
|
220
227
|
const widthMode = config.card?.width ?? 'default';
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
228
|
+
const cards = buildFileDiffCards(diff, { fileName: displayName }, widthMode);
|
|
229
|
+
if (diff.length > 100_000) {
|
|
230
|
+
await deps.gateway.sendTextTo(msg.chatId, `📊 ${displayName} 的 diff 极长(约 ${diff.length.toLocaleString()} 字符),将分 ${cards.length} 张卡片陆续发送,请稍候`);
|
|
225
231
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
}
|
|
232
|
+
for (let i = 0; i < cards.length; i++) {
|
|
233
|
+
if (i > 0)
|
|
234
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
235
|
+
await deps.gateway.sendCardTo(msg.chatId, cards[i]);
|
|
237
236
|
}
|
|
238
237
|
return true;
|
|
239
238
|
}
|
|
@@ -253,9 +252,18 @@ deps, opts = {}) {
|
|
|
253
252
|
// 配置热重载:lcb ws add/remove 独立进程写盘后,本实例下一条消息即读到新工作区
|
|
254
253
|
opts.reloadConfig?.();
|
|
255
254
|
if (!deps.access.isAllowed(msg.userId)) {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
255
|
+
if (app.role === 'deputy') {
|
|
256
|
+
// 分身机器人艾特即用(用户决策):不走配对——首条消息自动补录 member(access.json
|
|
257
|
+
// 保留审计记录,outputPending 等处的 isAllowed 纵深校验天然兼容);无 admin 权限,
|
|
258
|
+
// 管理命令与工作区切换在各自入口被拒,能力范围由分身配置限定
|
|
259
|
+
deps.access.addUser(msg.userId, msg.userId, 'member', app.appId);
|
|
260
|
+
console.log(`${tag}[接入] 分身应用 ${app.appId} 新用户 ${msg.userId} 已自动加入(member,能力按分身配置限定)`);
|
|
261
|
+
// 不 return:本条消息照常进入命令/任务处理
|
|
262
|
+
}
|
|
263
|
+
else if (!deps.access.hasUsers(app.appId)) {
|
|
264
|
+
// 首个使用者免配对直接成为 admin——按应用判定:多应用部署下 open_id 按应用隔离,
|
|
265
|
+
// 新应用的第一个使用者同样是「首个」,不应因别的应用已有用户而被配对码拦住
|
|
266
|
+
deps.access.addUser(msg.userId, msg.userId, 'admin', app.appId);
|
|
259
267
|
deps.access.addUser(msg.userId, msg.userId, 'admin', app.appId);
|
|
260
268
|
console.log(`${tag}[接入] 应用 ${app.appId} 首位用户 ${msg.userId} 已自动成为 admin(后续用户需配对)`);
|
|
261
269
|
await deps.gateway.sendTextTo(msg.chatId, `🎉 欢迎!你是本机器人的首位用户,已自动成为 **admin**。\n\n后续其他同事首次使用时需由你在桥接器终端批准配对(\`lcb pair <配对码>\`)。`);
|
|
@@ -277,8 +285,12 @@ deps, opts = {}) {
|
|
|
277
285
|
const st = deps.store.getChannelState(key);
|
|
278
286
|
// currentWorkspace 语义:store 中已设置的工作区优先,否则 app 默认,最后全局默认——
|
|
279
287
|
// 保证 /new、/status 回复与实际一致。用 || 而非 ??:archiveSession 为新通道建档时
|
|
280
|
-
// workspaceName 存 ''(归档时不知工作区),空串必须回退默认,否则 inventory
|
|
281
|
-
|
|
288
|
+
// workspaceName 存 ''(归档时不知工作区),空串必须回退默认,否则 inventory 键错位。
|
|
289
|
+
// 分身角色恒锁首个允许的工作区(config 已校验非空):命令上下文、inventory 键与任务
|
|
290
|
+
// wsName 同源锁定,通道 state 旧存的 workspaceName 一律不采信
|
|
291
|
+
const currentWorkspace = app.role === 'deputy'
|
|
292
|
+
? app.allowedWorkspaces[0]
|
|
293
|
+
: (st?.workspaceName || app.defaultWorkspace || config.defaults.workspace);
|
|
282
294
|
// 2.5 触发词映射:必须放在本地命令之前——斜杠触发词(如 /produce)会被 handleCommand
|
|
283
295
|
// 的未知命令分支吞掉;rewriteByTrigger 对本地命令(/stop 等)直接放行,不会被劫持
|
|
284
296
|
const triggered = rewriteByTrigger(msg.text, app.triggers);
|
|
@@ -292,9 +304,13 @@ deps, opts = {}) {
|
|
|
292
304
|
store: deps.store,
|
|
293
305
|
config,
|
|
294
306
|
appName: app.name,
|
|
295
|
-
|
|
307
|
+
// 管理命令闸(ctx.isAdmin 唯一入口,/ws use、/model-profile、/plugin 变更类经此流经):
|
|
308
|
+
// 分身机器人一律 false——管理面(工作区/模型档案/插件)全局共享,分身用户不该触碰,
|
|
309
|
+
// 连 admin 本人艾特分身也不放行(要用管理命令请找主机器人)
|
|
310
|
+
isAdmin: app.role !== 'deputy' && deps.access.isAdmin(msg.userId),
|
|
311
|
+
isDeputy: app.role === 'deputy',
|
|
296
312
|
currentWorkspace: () => currentWorkspace,
|
|
297
|
-
getInventory: () => inventories.get(currentWorkspace),
|
|
313
|
+
getInventory: () => inventories.get(`${app.appId}:${currentWorkspace}`),
|
|
298
314
|
// /mcp 实时拉取:当前工作区有存活 query 才有实时数据(5s 超时),否则回退 init/收尾快照
|
|
299
315
|
getLiveMcpStatus: async () => {
|
|
300
316
|
try {
|
|
@@ -322,8 +338,40 @@ deps, opts = {}) {
|
|
|
322
338
|
},
|
|
323
339
|
});
|
|
324
340
|
if (cmd.handled) {
|
|
325
|
-
if (cmd.
|
|
341
|
+
if (cmd.card) {
|
|
342
|
+
// 交互卡片命令(裸 /ws):发卡并注册挂起项供回调切换。快照工作区清单——回调按
|
|
343
|
+
// 名查找目标,配置热重载改清单不影响已发出卡片的自洽性
|
|
344
|
+
const requestId = cmd.cardRequestId ?? randomUUID();
|
|
345
|
+
wsPending.set(requestId, {
|
|
346
|
+
ownerId: msg.userId,
|
|
347
|
+
channelKey: key,
|
|
348
|
+
chatId: msg.chatId,
|
|
349
|
+
workspaces: config.workspaces.map((w) => ({ name: w.name, path: w.path })),
|
|
350
|
+
currentName: currentWorkspace,
|
|
351
|
+
stale: false,
|
|
352
|
+
});
|
|
353
|
+
// 上限 100 条删最老(Map 迭代序 = 插入序,首个即最老),防长期运行无界增长
|
|
354
|
+
if (wsPending.size > 100) {
|
|
355
|
+
const oldest = wsPending.keys().next().value;
|
|
356
|
+
if (oldest !== undefined && oldest !== requestId)
|
|
357
|
+
wsPending.delete(oldest);
|
|
358
|
+
}
|
|
359
|
+
// 发卡失败(卡片 schema 被飞书拒绝 / WS 偶发无响应挂起)降级文本清单——裸 /ws 的
|
|
360
|
+
// 唯一响应形态是卡片,静默吞错在用户侧就是「命令没反应」。raceFallback 同时覆盖
|
|
361
|
+
// 抛错与挂起(见 CARD_SEND_TIMEOUT_MS 注释),失败时 /ws use 文本命令兜底可用
|
|
362
|
+
const cardSent = await raceFallback(deps.gateway.sendCardTo(msg.chatId, cmd.card).then(() => true, (e) => {
|
|
363
|
+
console.error(tag, '[/ws] 工作区卡片发送失败,降级为文本列表', e, 'code=', e?.code, 'msg=', e?.msg);
|
|
364
|
+
return false;
|
|
365
|
+
}), CARD_SEND_TIMEOUT_MS, false);
|
|
366
|
+
if (!cardSent) {
|
|
367
|
+
wsPending.delete(requestId); // 卡未发出绝无回调,清挂起防占位
|
|
368
|
+
await deps.gateway.sendTextTo(msg.chatId, `**工作区列表**(当前:**${currentWorkspace}**)\n${workspaceListMarkdown(config.workspaces, currentWorkspace)}`
|
|
369
|
+
+ '\n\n⚠️ 工作区卡片发送失败(详见桥接器日志),已降级为文本。切换:`/ws use <名字>`').catch(() => { });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
else if (cmd.reply) {
|
|
326
373
|
await deps.gateway.sendTextTo(msg.chatId, cmd.reply);
|
|
374
|
+
}
|
|
327
375
|
// 指针变更代际 bump:/new、/resume <n>、/ws use 都改写了 currentSessionId——
|
|
328
376
|
// 运行中/收尾窗口内任务的迟到归档据 pointerGen 放弃回写指针(archive 闭包比对)
|
|
329
377
|
if (cmd.pointerTouched) {
|
|
@@ -419,6 +467,8 @@ deps, opts = {}) {
|
|
|
419
467
|
if (!gate) {
|
|
420
468
|
gate = new PermissionGate({
|
|
421
469
|
allowTools: () => new Set(config.permissions?.allowTools ?? DEFAULT_ALLOW_TOOLS_LIST),
|
|
470
|
+
// ?? 而非 ||:显式 false(用户主动关闭全部免确认)不能被回落到缺省的 true
|
|
471
|
+
allowAllTools: () => config.permissions?.allowAllTools ?? DEFAULT_ALLOW_ALL_TOOLS,
|
|
422
472
|
dangerousCommands: () => config.permissions?.dangerousCommands ?? DEFAULT_DANGEROUS_COMMANDS,
|
|
423
473
|
ask: async (req) => {
|
|
424
474
|
// 工具确认嵌入当前任务的进度卡(按钮在计时行上方、正文收敛)——不再单独发确认卡:
|
|
@@ -535,7 +585,8 @@ deps, opts = {}) {
|
|
|
535
585
|
// lcb-notify 发送能力:chatId 硬绑定当前任务(权限闸直通的安全前提);
|
|
536
586
|
// sentPaths 记录中途已推送的文件,任务收尾的产出回传据此去重(用户已收过的不重发)。
|
|
537
587
|
// 每次中途推送后进度卡沉底一次(删旧卡重发),保持「任务是否还在跑」始终可见于会话底部
|
|
538
|
-
// SOP 开关:本任务级现读 config.session.notifySop(缺省 true
|
|
588
|
+
// SOP 开关:本任务级现读 config.session.notifySop(缺省 true),决定是否注入推送规范
|
|
589
|
+
// 提示词(v2 为纯内容分类软约束;旧「>50 行转文件」硬兜底已移除,send_text 恒多卡)
|
|
539
590
|
const sopEnabled = config.session?.notifySop !== false;
|
|
540
591
|
const sentPaths = new Set();
|
|
541
592
|
const sink = async () => { await activeProgress.get(key)?.sinkToBottom(); };
|
|
@@ -558,8 +609,6 @@ deps, opts = {}) {
|
|
|
558
609
|
await deps.gateway.uploadAndSendFile(c, p);
|
|
559
610
|
await sink();
|
|
560
611
|
},
|
|
561
|
-
}, {
|
|
562
|
-
enabled: sopEnabled,
|
|
563
612
|
});
|
|
564
613
|
activeProgress.set(key, progress);
|
|
565
614
|
// 指针代际快照:任务运行期间 /new、/resume <n>、/ws use 都会 bump pointerGen,本任务
|
|
@@ -603,12 +652,17 @@ deps, opts = {}) {
|
|
|
603
652
|
resumeSessionId: resumeId,
|
|
604
653
|
signal: abort.signal,
|
|
605
654
|
env: taskEnv,
|
|
606
|
-
//
|
|
655
|
+
// 人设 + SOP 软约束:人设裸文本追加在 Claude Code 长系统提示末尾,对模型的约束力
|
|
656
|
+
// 天然偏弱(resume 旧会话时历史风格会带偏)——包一层显式遵循框架语显著改善
|
|
657
|
+
// 称谓/口吻/能力定位的遵循度(提示工程手段,非硬约束);SOP 摘要附后
|
|
607
658
|
appendSystemPrompt: (() => {
|
|
608
|
-
const
|
|
659
|
+
const raw = app.appendSystemPrompt?.trim() ?? '';
|
|
660
|
+
const persona = raw
|
|
661
|
+
? `【机器人人设(用户为该机器人配置,须在每一轮回复中严格遵循,优先级高于默认回复风格)】\n${raw}\n【人设结束】以上人设适用于本会话每一轮回复——包括称呼、口吻、能力定位的自我介绍;即使历史回复风格不同,也从本轮起遵循。`
|
|
662
|
+
: '';
|
|
609
663
|
if (!sopEnabled)
|
|
610
|
-
return
|
|
611
|
-
return
|
|
664
|
+
return persona || undefined;
|
|
665
|
+
return persona ? `${persona}\n\n${FEISHU_NOTIFY_SOP_PROMPT}` : FEISHU_NOTIFY_SOP_PROMPT;
|
|
612
666
|
})(),
|
|
613
667
|
// 计划模式(#6):通道级 /plan 开关——模型先出计划(ExitPlanMode → planAsk 飞书卡片),
|
|
614
668
|
// 用户批准后 SDK 自动切回可编辑模式继续执行(替代旧工作区 code-dev 类型)
|
|
@@ -622,26 +676,37 @@ deps, opts = {}) {
|
|
|
622
676
|
// 插件:config.yaml 显式配置(开发期指源码目录)+ Claude 配置目录已启用的 marketplace
|
|
623
677
|
// 插件自动发现,同名显式优先;SDK 对无效路径静默跳过,实际加载以 init 清单(/plugins 命令)为准。
|
|
624
678
|
// managed 模式合并发现两处目录(自管 + 本机 ~/.claude):按绝对 installPath 传 SDK,
|
|
625
|
-
// 用户本机已装插件无须重装;resolvePluginPaths 按 name
|
|
679
|
+
// 用户本机已装插件无须重装;resolvePluginPaths 按 name 去重,当前生效目录优先。
|
|
680
|
+
// 分身角色按 allowed_plugins 白名单过滤(显式 app.plugins 始终保留——写在分身配置里
|
|
681
|
+
// 的就是要给的);未配 allowed_plugins 时自动发现全剔除(分身不声明 = 不加载)
|
|
626
682
|
...(() => {
|
|
627
683
|
const discovered = claudeDir === DEFAULT_CLAUDE_DIR
|
|
628
684
|
? discoverPlugins(claudeDir)
|
|
629
685
|
: [...discoverPlugins(claudeDir), ...discoverPlugins(DEFAULT_CLAUDE_DIR)];
|
|
630
|
-
|
|
686
|
+
let merged = resolvePluginPaths(app.plugins, discovered);
|
|
687
|
+
if (app.role === 'deputy') {
|
|
688
|
+
merged = merged.filter((p) => app.plugins?.some((e) => e.name === p.name) || app.allowedPlugins?.includes(p.name));
|
|
689
|
+
}
|
|
631
690
|
return merged.length ? { plugins: merged.map((p) => ({ path: p.path })) } : {};
|
|
632
691
|
})(),
|
|
692
|
+
// 分身技能白名单:透传 SDK Options.skills(官方上下文过滤器——未列出技能对模型不可见
|
|
693
|
+
// 且被 Skill 工具拒绝;非沙箱:技能文件仍在磁盘可被 Read/Bash 读到,用户已知悉此边界)
|
|
694
|
+
...(app.role === 'deputy' && app.allowedSkills?.length ? { allowedSkills: app.allowedSkills } : {}),
|
|
633
695
|
canUseTool: (toolName, input, ctx) => gate.decide(toolName, input, wsName, ctx),
|
|
634
696
|
onQuery: (handle) => { activeQueries.set(key, { wsName, handle }); },
|
|
635
697
|
}, {
|
|
636
698
|
onInit: (inv) => {
|
|
637
|
-
|
|
699
|
+
// inventory 键带 appId 前缀:分身机器人技能/插件集与主机器人不同,两个 app 共用
|
|
700
|
+
// 工作区时 /skills /plugins /mcp 清单不能互相污染
|
|
701
|
+
inventories.set(`${app.appId}:${wsName}`, { ...inv, workspace: wsName, loadedAt: new Date().toISOString() });
|
|
638
702
|
},
|
|
639
703
|
onMcpStatus: (servers) => {
|
|
640
704
|
// 任务收尾时 executor 拉取的真实 MCP 状态回写 inventory——init 快照必为 pending,
|
|
641
705
|
// 这是 /mcp 空闲期显示 connected/failed 的数据源(prev 不存在说明 init 未到,等 init 全量落盘)
|
|
642
|
-
const
|
|
706
|
+
const invKey = `${app.appId}:${wsName}`;
|
|
707
|
+
const prev = inventories.get(invKey);
|
|
643
708
|
if (prev)
|
|
644
|
-
inventories.set(
|
|
709
|
+
inventories.set(invKey, { ...prev, mcpServers: servers, loadedAt: new Date().toISOString() });
|
|
645
710
|
},
|
|
646
711
|
onProgress: (e) => {
|
|
647
712
|
if (e.kind === 'status') {
|
|
@@ -649,6 +714,7 @@ deps, opts = {}) {
|
|
|
649
714
|
progress.setStatus(e.content);
|
|
650
715
|
}
|
|
651
716
|
else if (e.kind === 'text') {
|
|
717
|
+
progress.markStarted();
|
|
652
718
|
progress.appendText(e.content);
|
|
653
719
|
// 落盘 · assistant:流式文本块全文(每块一行,天然增量)
|
|
654
720
|
deps.transcript?.assistant({
|
|
@@ -658,6 +724,7 @@ deps, opts = {}) {
|
|
|
658
724
|
}
|
|
659
725
|
else if (e.kind === 'tool-start') {
|
|
660
726
|
const [n, ...rest] = e.content.split(': ');
|
|
727
|
+
progress.markStarted();
|
|
661
728
|
progress.toolStart(n, rest.join(': '));
|
|
662
729
|
deps.transcript?.tool({
|
|
663
730
|
v: 1, ts: now(), kind: 'tool', app: app.appId,
|
|
@@ -691,7 +758,9 @@ deps, opts = {}) {
|
|
|
691
758
|
});
|
|
692
759
|
return;
|
|
693
760
|
}
|
|
694
|
-
|
|
761
|
+
// finalText 提前算好:finish 携带字数,终态进度卡据此显示「完整回复已单独发送」提示行
|
|
762
|
+
const finalText = outcome.finalText?.trim() ?? '';
|
|
763
|
+
await progress.finish('✅ 完成', finalText.length || undefined);
|
|
695
764
|
archive(outcome.sessionId);
|
|
696
765
|
deps.transcript?.result({
|
|
697
766
|
v: 1, ts: now(), kind: 'result', app: app.appId,
|
|
@@ -710,38 +779,36 @@ deps, opts = {}) {
|
|
|
710
779
|
const scale = outcome.usage ? `(当前约 ${(ctxTokens / 10000).toFixed(1)} 万 tokens)` : '';
|
|
711
780
|
await deps.gateway.sendTextTo(msg.chatId, `💡 本会话上下文已较大${scale},建议发送 /new 开启新会话(/resume 可随时切回)。阈值可在 config.yaml 的 session.context_remind_tokens 调整,设为 0 关闭`);
|
|
712
781
|
}
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
if (
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
782
|
+
// 结果回传两段式:finalText 非空即独立多卡直接展示(~3000 字/块,连发间隔 300ms 防飞书
|
|
783
|
+
// 消息限流)——不论长短都不再进进度卡正文(主卡只留提示行),彻底消除「折叠看不到全文」
|
|
784
|
+
// 与「卡片/消息两边重复」(旧三段式 ≤400 终态自载的边界问题)。最后一张卡带「确认方案/
|
|
785
|
+
// 按意见修改」引导:点击合成用户消息入队发起新一轮任务(resume 上一会话,上下文天然在场)
|
|
786
|
+
if (finalText) {
|
|
787
|
+
const chunks = chunkText(finalText);
|
|
788
|
+
const total = chunks.length;
|
|
789
|
+
const resultReq = {
|
|
790
|
+
requestId: randomUUID(), charCount: finalText.length, workspaceName: wsName,
|
|
791
|
+
};
|
|
792
|
+
outputPending.set(resultReq.requestId, {
|
|
793
|
+
req: resultReq,
|
|
794
|
+
// 尾卡正文快照:settled 回调响应内联换卡替换整卡,须带回正文重建
|
|
795
|
+
tail: { content: chunks[total - 1], index: total, total },
|
|
796
|
+
ownerId: msg.userId, chatId: msg.chatId, settled: false,
|
|
797
|
+
});
|
|
798
|
+
if (outputPending.size > 500) {
|
|
799
|
+
// 防无界增长:清最老的条目(Map 迭代序即插入序;被清卡片的确认按钮会提示已处理)
|
|
800
|
+
outputPending.delete(outputPending.keys().next().value);
|
|
801
|
+
}
|
|
802
|
+
for (let i = 0; i < total; i++) {
|
|
803
|
+
if (i > 0)
|
|
804
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
805
|
+
if (i === total - 1) {
|
|
806
|
+
await deps.gateway.sendCardTo(msg.chatId, buildResultTailCard(resultReq, chunks[i], { index: total, total }, undefined, config.card?.width ?? 'default'));
|
|
737
807
|
}
|
|
738
808
|
else {
|
|
739
|
-
await deps.gateway.sendTextTo(msg.chatId,
|
|
809
|
+
await deps.gateway.sendTextTo(msg.chatId, `${resultChunkHeader(resultReq, i + 1, total)}\n\n${chunks[i]}`);
|
|
740
810
|
}
|
|
741
811
|
}
|
|
742
|
-
else {
|
|
743
|
-
await deps.gateway.sendTextTo(msg.chatId, text);
|
|
744
|
-
}
|
|
745
812
|
}
|
|
746
813
|
// 图片自动发(im.image.create 通道),非图片文件不自动回传——
|
|
747
814
|
// 用户从下面的文件清单里看到改了哪些文件,主动点名 basename 后由 tryDeliverRequestedFiles 处理
|
|
@@ -787,8 +854,10 @@ deps, opts = {}) {
|
|
|
787
854
|
return;
|
|
788
855
|
}
|
|
789
856
|
// 常见错误附配置指引(认证 hint 按模式分流:managed 指向配置页,inherit 指向 claude login);
|
|
790
|
-
// No conversation found 多因旧版独立目录里的会话(本版起统一 Claude 配置目录,旧会话无法跨目录 resume
|
|
791
|
-
|
|
857
|
+
// No conversation found 多因旧版独立目录里的会话(本版起统一 Claude 配置目录,旧会话无法跨目录 resume)。
|
|
858
|
+
// 飞书 API 4xx 时 axios 的 message 只有裸 "status code 400",业务错误码(230099/200621 等)
|
|
859
|
+
// 藏在 e.response.data——拼上响应体摘要,错误卡与日志可直接定位拒绝原因
|
|
860
|
+
const errText = `${String(e)}${feishuErrorBody(e) ? `\n(飞书:${feishuErrorBody(e)})` : ''}`;
|
|
792
861
|
const uiUrl = serverUrl(config);
|
|
793
862
|
const hint = errText.includes('Not logged in')
|
|
794
863
|
? config.claude?.mode === 'managed'
|
|
@@ -809,12 +878,10 @@ deps, opts = {}) {
|
|
|
809
878
|
deps.transcript?.result({
|
|
810
879
|
v: 1, ts: nowBeijingISO(), kind: 'result', app: app.appId,
|
|
811
880
|
chatId: msg.chatId, userId: msg.userId, sessionId: '',
|
|
812
|
-
subtype: 'error', text:
|
|
881
|
+
subtype: 'error', text: errText.slice(0, 500), // 实录同带响应体摘要(排查飞书 4xx 不再依赖翻日志)
|
|
813
882
|
});
|
|
814
883
|
}
|
|
815
884
|
finally {
|
|
816
|
-
// SOP 降级单文件补发(#7):任务任何结局(完成/停止/出错)都在收尾补齐完整附件
|
|
817
|
-
await notifySender.flushDowngradedFile?.().catch(() => { });
|
|
818
885
|
clearTimeout(hardTimeout);
|
|
819
886
|
activeProgress.delete(key);
|
|
820
887
|
activeQueries.delete(key);
|
|
@@ -947,16 +1014,6 @@ deps, opts = {}) {
|
|
|
947
1014
|
return { toast: { type: 'info', content: '仅任务发起人可操作' } };
|
|
948
1015
|
}
|
|
949
1016
|
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: '正在发送完整回复…' } };
|
|
959
|
-
}
|
|
960
1017
|
if (out.settled) {
|
|
961
1018
|
return { toast: { type: 'info', content: '该回复的处理已提交,无需重复操作' } };
|
|
962
1019
|
}
|
|
@@ -980,19 +1037,65 @@ deps, opts = {}) {
|
|
|
980
1037
|
}, prompt, out.req.workspaceName);
|
|
981
1038
|
}
|
|
982
1039
|
else {
|
|
983
|
-
console.warn(tag, '[
|
|
1040
|
+
console.warn(tag, '[结论尾卡] 发起人不在访问白名单,已忽略新一轮请求:', action.operatorId);
|
|
984
1041
|
}
|
|
985
1042
|
const settledText = decision === 'output-confirm'
|
|
986
1043
|
? '✅ 已确认该方案,正在发起新一轮任务(详情见下方新进度卡)'
|
|
987
1044
|
: '✏️ 修改意见已收到,正在发起新一轮任务(详情见下方新进度卡)';
|
|
988
1045
|
return {
|
|
989
1046
|
toast: { type: 'success', content: settledText },
|
|
990
|
-
//
|
|
991
|
-
card: { type: 'raw', data:
|
|
1047
|
+
// 回调响应内联换卡:替换整张尾卡,正文快照带回(按钮区收为一行 settled 文案)
|
|
1048
|
+
card: { type: 'raw', data: buildResultTailCard(out.req, out.tail.content, { index: out.tail.index, total: out.tail.total }, settledText, config.card?.width ?? 'default') },
|
|
992
1049
|
};
|
|
993
1050
|
}
|
|
994
1051
|
return { toast: { type: 'info', content: '该操作无效或已过期' } };
|
|
995
1052
|
}
|
|
1053
|
+
// 工作区切换卡(裸 /ws):无 Promise 挂起——wsPending 提供 admin 校验、stale 失效
|
|
1054
|
+
// 与工作区快照。权限语义与 /ws use 一致是「admin 可切换」而非「仅发起人」:member
|
|
1055
|
+
// 能拿卡看列表,点按钮被拒(卡片保持原样,admin 仍可点),与其它卡的 ownerId 校验刻意不同。
|
|
1056
|
+
// 分身机器人工作区已锁定(allowed_workspaces),切换一律拒绝
|
|
1057
|
+
const wsc = wsPending.get(action.value.requestId);
|
|
1058
|
+
if (wsc) {
|
|
1059
|
+
if (app.role === 'deputy') {
|
|
1060
|
+
return { toast: { type: 'info', content: '⛔ 分身机器人已锁定工作区,不可切换' } };
|
|
1061
|
+
}
|
|
1062
|
+
if (wsc.stale) {
|
|
1063
|
+
return { toast: { type: 'info', content: '该列表已过期,请以最新 /ws 卡片为准' } };
|
|
1064
|
+
}
|
|
1065
|
+
if (!deps.access.isAdmin(action.operatorId)) {
|
|
1066
|
+
console.log(tag, `[卡片回调] 非 admin ${action.operatorId} 点击工作区卡 ${action.value.requestId},已忽略`);
|
|
1067
|
+
return { toast: { type: 'info', content: '⛔ 切换工作区仅管理员可用' } };
|
|
1068
|
+
}
|
|
1069
|
+
const target = wsc.workspaces.find((w) => w.name === action.value.ws);
|
|
1070
|
+
if (!target) {
|
|
1071
|
+
return { toast: { type: 'info', content: '该工作区不存在,请重新发送 /ws' } };
|
|
1072
|
+
}
|
|
1073
|
+
// 当前项判定(disabled 按钮不回调;不支持 disabled 的客户端点当前项时兜底,防冗余
|
|
1074
|
+
// 切换清掉续接指针):实时 workspaceName 优先,跟随默认未显式切换则回退发卡时快照
|
|
1075
|
+
const wsSt = deps.store.getChannelState(wsc.channelKey);
|
|
1076
|
+
const wsCurrent = wsSt?.workspaceName || wsc.currentName;
|
|
1077
|
+
if (target.name === wsCurrent) {
|
|
1078
|
+
return { toast: { type: 'info', content: '该工作区已是当前工作区' } };
|
|
1079
|
+
}
|
|
1080
|
+
applyWorkspaceSwitch(deps.store, wsc.channelKey, target);
|
|
1081
|
+
// 等价 cmd.pointerTouched:切换清了续接指针,运行中/收尾窗口内任务的迟到归档不得回写
|
|
1082
|
+
const wsRt = runtimes.get(wsc.channelKey);
|
|
1083
|
+
if (wsRt)
|
|
1084
|
+
wsRt.pointerGen = (wsRt.pointerGen ?? 0) + 1;
|
|
1085
|
+
// 同通道其余工作区卡置 stale:旧卡显示的当前项已过时,继续可点会诱导误切回旧工作区
|
|
1086
|
+
for (const [id, e] of wsPending) {
|
|
1087
|
+
if (e.channelKey === wsc.channelKey && id !== action.value.requestId)
|
|
1088
|
+
e.stale = true;
|
|
1089
|
+
}
|
|
1090
|
+
console.log(tag, `[工作区] 卡片切换 ${wsCurrent} → ${target.name}(${action.operatorId})`);
|
|
1091
|
+
// 一次性选择语义:本卡随即终态化(移除挂起项,迟到点击按孤儿卡 toast 提示),再切重发 /ws
|
|
1092
|
+
wsPending.delete(action.value.requestId);
|
|
1093
|
+
return {
|
|
1094
|
+
toast: { type: 'success', content: `✅ 已切换工作区:${target.name}(已开启新会话,/resume 可切回历史)` },
|
|
1095
|
+
// 回调响应内联换卡:整卡替换为终态提示(纯 markdown 无按钮),不能再点
|
|
1096
|
+
card: { type: 'raw', data: buildWorkspaceSwitchedCard(target.name, target.path, config.card?.width ?? 'default') },
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
996
1099
|
const pending = confirmPending.get(action.value.requestId);
|
|
997
1100
|
if (!pending) {
|
|
998
1101
|
// 已处理/超时清理/桥接器重启后的孤儿卡:toast 提示后忽略,不重复决策
|
|
@@ -1017,7 +1120,8 @@ deps, opts = {}) {
|
|
|
1017
1120
|
catch (e) {
|
|
1018
1121
|
console.error(tag, '[卡片回调异常]', action.value.requestId, e);
|
|
1019
1122
|
// resolve 之后的代码理论不可抛;若未来插入可抛代码,按条目是否仍在区分决策是否已生效
|
|
1020
|
-
const retryable = confirmPending.has(action.value.requestId) || planPending.has(action.value.requestId) || qaPending.has(action.value.requestId)
|
|
1123
|
+
const retryable = confirmPending.has(action.value.requestId) || planPending.has(action.value.requestId) || qaPending.has(action.value.requestId)
|
|
1124
|
+
|| (wsPending.get(action.value.requestId)?.stale === false);
|
|
1021
1125
|
return { toast: { type: 'error', content: retryable ? '处理失败,请重试' : '处理出现异常,决策可能已生效,请勿盲目重试' } };
|
|
1022
1126
|
}
|
|
1023
1127
|
}
|