# AGENTS.md — dsh-feishu > 本插件基于 [sugarforever/dsh-lark](https://github.com/sugarforever/dsh-lark)(commit `ee639df`,fork 时上游 HEAD)**独立维护**,不再跟踪 upstream 同步。上游 LICENSE(`Copyright (c) 2026 sugarforever`,MIT)已保留以满足 MIT 协议 modified-work 声明。 > > 所有改动记录见 [CHANGELOG.md](./CHANGELOG.md) 的「Unreleased」段;**未对 DSH 源码做任何改动**。 ## 定位 **dsh-feishu 的定位:把 DSH 的原生特性接入飞书,而不是再造一个 agent 平台/助手。** - **明确排除**:不做 openclaw / hermes 那种 24h 常驻 agent 助手(自带记忆、多平台网关、任务调度)。那种是独立产品;本插件只做"DSH 原生特性 → 飞书聊天"这一层接入,**DSH 本身才是 agent 本体**。 - **对齐对象**:DSH 官方 Web UI 是一等公民,本插件让飞书入口获得可比的体验。两者共享同一套 DSH 服务(`agents` / `sessions` / `workspaceRegistry` / `agentPresets` / `sessionController` / `userQuestions` / `approval`),飞书只是多出一个聊天端口。 设计与实现原则: - **复用 DSH 原生服务**,不重复造 Web UI 已具备的能力;插件只做"把 Web UI 的某个能力翻译成飞书聊天交互"这一层。 - **优先用飞书原生机制做映射层**:多 thread 并行 = 飞书话题(每个 DSH session 映射一个话题)。 - **功能对齐优先于功能创新**:新增能力前先对照 DSH Web UI 是否有同名/等价能力,有则对齐,无再讨论。 - ⚠️ 唯一有意分歧:`/compact` 已移除(与 DSH 自带 `command-compact` 同名冲突,见「关键坑」)。 ## 与 DSH Web UI 功能对齐 | 能力 | 现状 | 说明 | |---|---|---| | 审批弹窗 | 已有 | `feishu-approvals.ts`(`/approve` `/deny` `/approvals`)+ `ask_user_question` 卡片(`feishu-questions.ts`),Web UI 与飞书共享同一份 pending 状态 | | ask_user_question 卡片 | 已有 | `feishu-questions.ts`:Card JSON 2.0 form 容器,选项按钮 + 输入框 + 跳过按钮;选项直接回调,自定义回答通过 `form_value` 提交,跳过提交空答案;settled 卡片更新(patch)。**多问题顺序迭代**:一次 `ask_user_question` 传多个问题时,答完一题自动发下一题卡片,最终 `{ answers: [q1, q2, ...] }` 整批返回。**注意**:注册用 `ctx.on(..., { prepend: true })` 成为 waterfall 最外层(见「关键坑」第 6 条),避免被 api-remotes 的 WebUI answerer 阻塞 | | 工作区选择 | 部分 | `workspaceRegistry` 已注入;config/settings 有 `workspace` 字段,v2 session 已带 workspace 组合;聊天内运行时切换/浏览待补 | | agent 模式选择 | 部分 | config/settings 有 `agentPreset` + `provider` + `model`,飞书 `/model` 可切默认模型(调用 `ctx.sessionController.selectModel({ sessionId, provider, model, reasoningEffort? })` 一站式同步 agent scoped ref + WebUI + 持久化);agent preset 聊天侧选择待补 | | 多 thread 并行工作 | 规划中 | 实现路径:**飞书话题**——每个 DSH session 映射一个话题,多话题即多 thread 并行。当前 `/new` `/thread` 已通(DSH `agents` registry + `sessions`),话题映射与并行可见性未实现 | | todo 展示 | 已有 | `feishu-todos.ts`:订阅 apiproxy mux `todo/write` 事件,turquoise 卡片含进度条和状态图标 | | 统一 per-step 卡片 | 已有 | `feishu-streaming.ts`:一个 mux 订阅处理所有事件类型,每个 step 一张卡片包含 reasoning + text + 工具调用 + 结果预览 + step 时长/token footer | | 工具调用展示 | 已有 | per-step 卡片内嵌工具调用,`tool/call` 显示工具名(内联代码)+ args,`tool/result` 原地更新为成功/失败状态,wathet→green/red 颜色变化 | | 工具结果预览 | 已有 | 按 `resultView.card` 类型分发渲染:terminal(代码块)、web search(来源列表)、web fetch(状态)、search(文件匹配)、read(带行号内容)、diff(差异)、generic | | 工具调用摘要 | 已有 | 通过 mux `frame.view` 获取 `presentCall`/`presentResult` 的 `description`、`title`,显示在工具名称上方 | | 中间消息展示 | 已有 | `feishu-streaming.ts`:订阅 apiproxy mux `assistant/chunk`,在 `tool/call` 到达时 flush 为卡片。不依赖 `/stream` 开关 | | Turn Complete 卡片 | 已有 | turn 结束后发送绿色卡片,展示总时长/LLM 时间/工具时间、TTFT/token 速度/步数、输入输出 token/缓存命中率 | | `/reasoning` 命令 | 已有 | 查看/设置 reasoning effort(off/low/high/max),通过 `agentDefaultModel.saveSelection` 持久化 | | `/status` 命令 | 已有 | 直接在 `executeSlashCommand` 处理(不需要 live agent),返回飞书卡片展示 session id/title/workspace/preset/model/reasoning/tokens/context/缓存命中率/TTFT/吞吐量/LLM 时间/工具时间/**Permission**/**Enter while busy**(Queue/Steer) | | `/stop` 命令 | 已有 | `bridge.stopSession(chatMessage)`:先把该 chat 的**停止代际 +1**,再 `agent.cancel({ kind: 'user' }, { keepInbox: false })`。`bridge.reply` 的 queue 路径在 `await whenIdle()` 后检查代际,若 `/stop` 在等待期间发生则丢消息、抛 `TurnDroppedError`(channel 静默丢弃,不开新轮)。**已解决"排队消息仍进下一 turn"的问题**。不用 `sessionController.cancel`(其硬编码 `keepInbox:true`) | | session 映射持久化 | 已有 | `/new` 和 `/thread` 的 chat→session 映射持久化到 `~/.dsh/lark-session-map.json`,重启后自动恢复 | | 图片接收 | 已有 | `channel.ts` 的 `admitImagesForMessage`:`im.v1.messageResource.get` 下载用户发送的图片,经 attachment store 落盘并附 `imageBlocks` 到用户 turn | | 文件接收 | 已有 | `channel.ts` 的 `admitFilesForMessage`:`im.v1.messageResource.get({ type: 'file' })` 下载用户发送的文件,写入 `~/.dsh/feishu-inbox/`,并在 `inboundMessage.content` 追加 `[文件: ]`,agent 可读;不用 `/tmp`(与 agent 工具沙箱隔离) | | agent 主动发文件 | 已有 | `feishu-send-file.ts` 注册 host 全局 model tool `feishu_send_file`(`ctx.tools.register(defineTool(...))`,参数 `path` + 可选 `caption`):`exec.agent.id` → `bridge.resolveChat(sessionId)` 反查所属 chat → 校验(≤30MB/非空)→ `channel.send(chatId, { file: { source, fileName } })` 由 SDK 内部 `im.v1.file.create`(`file_type:'stream'`) 上传并发送;WebUI-only session 返回明确错误。对齐 WebUI `ui-deliverables` 的"产物文件可交付",但飞书是真正的二进制 push | | 运行中注入 steer | 已有 | `/steer <内容>` 斜杠命令(`index.ts`)+ `bridge.steer`(`harness.ts`):解析已有 agent 并校验 `status === 'running'` 后调 `agent.steer(...)`,把消息注入 DSH `next-step` inbox(**当前进行中的 turn** 下一步即消费),等价 WebUI busy 时的 steer。运行中发**普通消息仍走 `followup` 排队为新轮**(对齐 WebUI 默认 queue),`/steer` 是显式 opt-in | | 权限模式选择 | 已有 | `/permission [模式]`(`index.ts` + `feishu-permission.ts`,保留 `/sandbox` 为隐藏别名),命名/显示名**对齐 WebUI `ui-permission-presets`**(命令 `/permission`,预设 `Read Only` / `Workspace Write` / `Full access`):无参发送**交互式选择卡片**(按钮点选切换,复用 `cardChannel.onCardAction`、跨重连自动重绑),带参走直接切换文本路径。显示/切换复用 `ctx.get('sandboxPolicy')` 服务 + 会话日志追加 log-only `sandbox/mode` 事件(复刻 `setSandboxMode`)。`/status` 卡片显示 `**Permission:**`。`dsh-sandbox-policy` 未作插件依赖 | > 状态说明:`已有` = 已实现并经测试;`部分` = 底层通路已通,但聊天侧交互/可见性未完整;`规划中` = 已定实现路径、未动工;`待实现` = 尚未开始。改代码前先更新此表。 ## 包名与仓库 | | | |---|---| | npm 包名 | `@starxer/dsh-feishu` | | GitHub | https://github.com/Starxer/dsh-feishu | | 本地目录 | `~/workspace-lyf/deepseek-harness/workspace/dsh-feishu`(已由 dsh-lark 改名) | | 上游来源 | `sugarforever/dsh-lark@ee639df`(fork 起点,不再同步) | ## 仓库结构 ``` dsh-feishu/ ├── src/ # 插件源码(独立维护的改动在这里) ├── tests/ # 单元测试(vitest) ├── client/ # 编译产物(web UI 客户端) ├── lib/ # 编译产物(DSH host 端运行时) ├── docs/ # 架构文档 ├── cordis.patch.yml # 插件元数据(DSH loader 读取) ├── package.json # name: @starxer/dsh-feishu, link: pnpm link 到 DSH profile └── CHANGELOG.md # 本仓库的独立改动记录 ``` `lib/` 与 `client/client.js` 由 `npm run build` 生成,不在 git 跟踪(`.gitignore`)。 ## 构建 ```sh cd ~/workspace-lyf/deepseek-harness/workspace/dsh-feishu npm run build # 同时产 lib/index.js + client/client.js npm run typecheck npm run test ``` ## DSH 集成位置 | 文件 | 角色 | |---|---| | `~/.dsh/profiles/web/package.json` | pnpm link 引用本目录(`"@starxer/dsh-feishu": "link:/home/lyf/workspace-lyf/deepseek-harness/workspace/dsh-feishu"`) | | `~/.dsh/profiles/web/cordis.patch.yml` | 启用 `lark-channel`(DSH loader 通过 plugin name 加载)| | `~/.dsh/settings.yaml` | `lark-channel` section(`appId`、`appSecretRef`、`domain` 等) | | `~/.dsh/.credentials.yaml` | `DSH_LARK_APP_SECRET: `(由 `appSecretRef` 引用) | 修改插件源码后必须: ```sh npm run build # 让 lib/index.js 同步 systemctl --user restart dsh ``` ## 与上游关系 仓库创建时是 upstream `sugarforever/dsh-lark` 的 fork,但 **当前已不再跟踪上游同步**——见顶部说明。如需手工对比上游改动做 cherry-pick: ```sh git remote add upstream https://github.com/sugarforever/dsh-lark.git git fetch upstream git log HEAD..upstream/main --oneline # 上游新增(未合并) git cherry-pick # 按需选 commit ``` 上游当前节奏:单人 AI 辅助开发,release 频繁(v0.2.x 已发),单点风险高。**不建议大 merge 上游 main**——本仓库与上游已存在功能性分歧(DSH 兼容性、`/compact` 命令处理)。 ## 关键坑(必读) 1. **不要注册 `name: "compact"` 命令**——DSH 自带 `command-compact` 插件同名,启动时抛 `command "compact" is already registered` 导致 boot 失败。详见 CHANGELOG 「Unreleased — Forked」。 2. **不要把 `'compaction'` 放进 `inject`**——DSH rc.7 把 `compaction-basic` 移进了 per-session preset realm,host 平面不再有全局 `compaction` 服务,插件会 `pending (waiting for service: compaction)` 无限等待、boot 失败。 3. **改动前先备份**(`cp src/.ts src/.ts.bak.$(date +%Y%m%d_%H%M%S)`),验证 dsh 启动正常后**手动删 `.bak.*`**——已经加入 `.gitignore`,避免意外提交。 4. **Card JSON 2.0 不支持 `action` 容器标签**——按钮直接放 `body.elements`,用 `behaviors` 代替顶层 `value`。form 容器内按钮需 `form_action_type: "submit"` + `name`。`im.v1.message.patch` 只支持 v2 格式(`schema: '2.0'` + `body.elements`)。详见 [飞书按钮文档](https://github.com/larksuite/cli/blob/main/skills/lark-im/references/card/components/button.md)。 5. **`/model` 用 `ctx.sessionController.selectModel(...)` 一站式**——0.1.2-alpha.1 之前 plugin 需要手动调三步(`agentDefaultModel.saveSelection` 持久化 + `bridge.setCurrentSelection` 改 ref + `apiProxy.sessions.selectModel` 同步 WebUI)。`sessionController.selectModel` 内部 `commands.selectModel` 走 `resolveAgent`(恢复 session)+ `resolveCallConfig`(校验)+ `agents.selectForNextRequest(agent, ref)`(写 agent scoped ref,WebUI 立即读到)+ `agentDefaultModel.saveSelection`(持久化),一站式完成。新架构下 plugin 不再维护 `selections: Map` 或调 `installModelSelection`——`ApiSessionAgentController` 内部 `WeakMap` 已经替它做。**注意**:`commands.selectModel` 内部走 `resolveAgent` → 对**未创建过**的 sessionId 会尝试 resume(`sessionPersistence.list()` 找不到时 reject),不静默创建。 6. **`user-questions/request` / `approval/request` 必须 `prepend` 注册**——这两个事件是 agent-scoped waterfall,`api-remotes`(WebUI BFF)在 boot 时已注册同名 waterfall listener 且**先注册**(最外层),它在 `forwardWaterfall` 里**阻塞等待 WebUI 回答**。飞书 listener 如果普通 push(内层)注册,只有当外层 `next()` 被调用时才会执行——WebUI 不回答,飞书卡片永远不渲染(模型 `ask_user_question` 一直挂到超时/中止)。所以 `feishu-questions.ts` / `feishu-approvals.ts` 都用 `ctx.on(event, handler, { prepend: true })` 成为最外层,且只在 session 绑定到飞书 chat 时认领(`resolveChat` 命中),否则 `next()` 回退给 WebUI。详见 CHANGELOG 「问题/审批卡片不显示修复」。 7. **给用户回报错误要带具体原因,不要回统一道歉文案**——插件任何出错(agent turn 失败、图片/文件拉取失败、斜杠命令执行失败、发文件失败)都通过 `src/error-text.ts` 的 `errorText(error, fallback)` 生成具体原因(`error.message` + 数字错误码 `(code: N)`),再带前缀发回飞书(如 `处理这条消息时出错:<原因>`);`config.errorMessage` 只保留为确实无原因时的兜底。不要退回原来的统一 `errorMessage`。见 CHANGELOG 「报错消息带具体原因/错误码」。 ## 测试 - `npm run test` 跑 vitest 单元测试(用 jsdom mock SDK),不依赖 DSH 真实运行 - 端到端验证:改完后 `systemctl --user restart dsh`,看 `journalctl --user -u dsh -n 30` 是否干净启动(`dsh web: http://127.0.0.1:3080`,无 `error|failed|already registered`) ## 关联记录 - 主项目工作区:`~/workspace-lyf/deepseek-harness/`(DSH 源码,本插件不修改此处) - DSH 项目文档:`~/workspace-lyf/deepseek-harness/docs/architecture.md` - 部署运维 skill:`~/.hermes/skills/autonomous-ai-agents/deepseek-harness-ops/`(含 rc.7 兼容性记录)