# kanboard-dsh-plugin **Repository Path**: gabr/kanboard-dsh-plugin ## Basic Information - **Project Name**: kanboard-dsh-plugin - **Description**: 连接管理Kanboard的dsh plugin - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-10-16 - **Last Updated**: 2026-10-27 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # Kanboard —— DSH 插件 把 DSH 接到**任意一台** Kanboard 上:服务器地址、API 路径、用户名、API Token 都在「设置 → Kanboard」里配,所以局域网或互联网上的看板都能接。 装上之后,AI 就能直接读项目、建任务、拆子任务、写评论、拖动看板、查逾期 —— 你只要用人话说需求,不用自己点界面。左栏还会多一个 **「看板」** 条目, 点开就在 DSH 内部看看板(不用切窗口),和会话之间来回切**不会重新加载**。 --- ## 1. 它给你 10 个工具 工具按"人怎么用它"分组,而不是把 Kanboard 的 120 多个 API 方法逐个暴露 (那会把每轮请求的上下文撑爆)。 | 工具 | 管什么 | | --- | --- | | `kanboard_status` | 连接自检:地址、当前用户、版本、可见项目数、是否只读。**连不上时先调它。** | | `kanboard_project` | 项目:列表 / 详情 / 建改删 / 启停 / 活动流 / 元数据 / 概览 | | `kanboard_task` | 任务:搜索 / 列表 / 详情 / 建改 / 关闭重开 / 移动列与项目 / 复制 / 逾期 / 标签 | | `kanboard_comment` | 评论:列 / 读 / 增 / 改 / 删(不指定 user_id 时自动用当前 API 用户身份) | | `kanboard_subtask` | 子任务:增删改查 + 开始/停止计时 + 查已用工时 | | `kanboard_board` | 看板快照:一次拿全「泳道 → 列 → 任务」的现状 | | `kanboard_structure` | 骨架:列 / 泳道 / 分类 / 标签 / 链接类型 | | `kanboard_pm_review` | **定向产品评审**:组评审包(自动带全局锚 + 人的补充提示词)/ 校验回答有没有真看全局 / 校验通过才许落板 | | `kanboard_ask_human` | **请人拍板**:负责人切到真人、写 `[AI]` 评论、推 ntfy / 桌面 / 手机决策器。调用后 AI 必须停手 | | `kanboard_api` | **兜底直调任意 Kanboard API 方法**(用户、组、项目权限、附件、动作、外部链接、任务元数据…) | 因为有 `kanboard_api`,能力上是**完整**的 —— 内置方法目录直接写在它的 description 里,模型可以自己查。用 `{"catalog": true}` 也能把目录取回来。 多数工具用 `action` 参数分支,例如: ``` kanboard_task { action: "create", project_id: 1, title: "修复登录超时", priority: 2, date_due: "2026-02-01" } kanboard_task { action: "list", project_id: 1, status_id: 1 } kanboard_subtask { action: "create", task_id: 7, title: "补充单元测试", time_estimated: 2 } kanboard_comment { action: "create", task_id: 7, content: "已复现,见附件日志。" } ``` --- ## 2. 配置 ### 2.1 在界面里配(推荐) DSH 里打开 **设置 → Kanboard**,填: | 字段 | 说明 | | --- | --- | | 服务器地址 | 形如 `http://192.168.1.10:8080`,**不要带结尾斜杠** | | API 路径 | 默认 `/jsonrpc.php`;部署在子路径下就写 `/kanboard/jsonrpc.php` | | 用户名 | 用哪个 Kanboard 账号调 API,权限就跟着它走 | | API Token | Kanboard「个人信息 → 应用程序接口」里生成的那串(**必填**,见 2.4) | | 或用环境变量 | 填环境变量名(如 `KANBOARD_API_TOKEN`),优先于上面的 Token,避免密钥落盘 | | 默认项目 id | 可选,只作提示 | | 超时(毫秒) | 跨公网可调到 60000 | | 允许写操作 | 关掉就是**只读模式**,AI 只能看不能改 | | 校验 HTTPS 证书 | 自签证书的内网服务取消勾选 | 点「保存」会写进当前 profile 的 `cordis.patch.yml` 并**立即生效,不用重启**。 旁边的「测试连接」可以在保存前先验证。 ### 2.2 直接改配置文件 `$DSH_HOME/profiles//cordis.patch.yml` 里加一行: ```yaml - id: kanboard name: "@local/dsh-kanboard" config: enabled: true baseUrl: 'http://192.168.1.10:8080' apiPath: '/jsonrpc.php' username: 'admin' apiToken: '你的个人 API Token' apiTokenEnv: '' defaultProjectId: 0 allowWrites: true verifyTls: true timeoutMs: 30000 ``` 不写 `config` 也不会崩 —— 所有字段都有默认值,只是连不上而已。 ### 2.3 认证用的是什么 Kanboard 的 `/jsonrpc.php` 走 HTTP Basic: - **个人 API Token**(推荐):用户名 + 该用户「个人信息 → 应用程序接口」里的访问令牌。 权限跟着这个用户走,多人协作时各用各的。 - 应用级 Token:用户名填 `jsonrpc` + 全局 API Token,等价管理员。 插件用的是第一种。 ### 2.4 没填用户名会怎样 **用户名是必填的。留空时插件的 10 个工具一个都不会去调 Kanboard**, 全部直接返回同一句提示: > 未填写 Kanboard 用户名,插件不会调用任何 Kanboard API,本次请求没有发出去。 > 请先向用户索取:…… 这是刻意的:用户名是**只能由人提供**的信息,AI 猜不到也查不到;它决定了 这次调用以谁的身份执行、权限跟着谁走。留空时如果照常发请求,只会拿回一个 HTTP 401,模型很可能就此放弃或反复重试同一个错误,而它真正该做的是回头问用户。 每个工具的描述里也都带了这句前提,所以 AI 在**动手之前**就知道该先要用户名。 `kanboard_api` 的方法目录查询(`catalog: true`)同样会被拦住 —— "没填用户名 = 插件完全不工作"比"某些功能还能用"更好解释。 ### 2.5 注入给 AI 的任务处理规则 插件会往**系统提示词**里加**两段**: 1. 「Kanboard 任务处理规则」(`lib/task-rules.js`)—— 静态的流程政策; 2. 「任务自定义属性(按项目对照)」(`lib/task-attributes.js`)—— 动态数据: 当前用户各项目定义了哪些任务属性、哪些必填、可选值是什么。 第二段是**为了新建任务不卡住**。Kanboard 那边(本仓库的自定义功能)支持项目级的 任务自定义属性,必填项不论人从界面建还是 AI 用 API 建都绕不过去 —— 漏了就报错。 所以这里把清单直接摆进提示词,AI 一次就能填对: ``` **项目 #1「罗恩软件体系」** | 属性 id | 名称 | 类型 | 必填 | 可选值 | | --- | --- | --- | --- | --- | | 1 | 严重程度 | 单选 | **是** | 高 / 中 / 低 | | 2 | 复现步骤 | 文本 | **是** | — | ``` 这一段的取舍值得说清楚: - **要联网取,但提示词组装是同步的** —— 所以做的是 *stale-while-revalidate*: 缓存(TTL 5 分钟)新鲜就直接用,过期了就顺手踢一次后台刷新、本次仍用旧内容。 于是组装提示词**永远不会被网络拖住**,最坏情况是内容旧 5 分钟。 - **拉取失败不写进提示词**:保留上一次的内容,并把重试安排到 30 秒后。 让模型看到一串"连接被拒绝"既没用、又干扰它干活。 - **取不到就返回空串**,DSH 会把空段丢掉 —— 对没在用 Kanboard 的人, 提示词一个字符都不变。 - 段注册时显式写了 `interpolate: false`,因为正文里有 markdown 表格和字面量, 不能当模板再去插值。 **接手之前先过指派这一关**(准入判断): | 任务的负责人 | AI 要做的 | | --- | --- | | **就是插件里填的那个用户** | 可以处理 | | **还没有人** | 可以处理;但**先把负责人改成自己**再动手 | | **是别人** | **拒绝执行** —— 不改栏目、不写评论、什么都不碰 | 这条是为了避免两个 agent / 两个人同时改同一个任务。AI 用 `kanboard_status` (或 `kanboard_api getMe`)拿自己的 user id,用 `kanboard_task` 的 `get` 读 `owner_id`、用 `update` 认领。 通过之后,按栏目流转: | 时机 | 栏目 | | --- | --- | | 开始实际处理**之前** | 改到 **进行中** | | 处理**完成**之后 | 改到 **完成** | | 前置不满足、或其它无法回避的原因导致**做不了** | 改回 **待办**,并**在评论里写明原因** | 外加一条写作规范: - 关于任务的所有信息(分析、结论、**完成方法 / 步骤**)都用**评论**加在任务上; - 每条评论以 `[AI]` 开头,**注明是 AI 写的**; - **不要修改任务原有的描述和标题** —— 那是提出任务的人写的,保留原文。 **为什么放提示词而不是工具描述**:这几条是"你该怎么干活"(流程政策), 不是"这个工具怎么传参"(接口说明)。DSH 有专门的机制承载它 —— `ctx.systemPrompt.section(...)`(`systemPrompt` 服务, 见其 `access.hardDependency.inject = ["systemPrompt"]`)。 塞进 10 个工具的描述里会又长又重复,而且只在模型恰好调那个工具时才生效。 **只在插件真能用时才注入**(启用 + 填了地址和用户名)。没配好时返回空串, DSH 的 `renderPrompt` 会丢掉空段,所以对没在用 Kanboard 的人**提示词一个字符都不变**。 `text` 用的是函数,DSH 每次组装提示词都重新求值,所以设置页改完配置**当场生效**。 段名 `kanboard-task-rules`、`order` 15010(排在很后面,不覆盖也不重排既有段; global-prompt 插件用 15000,这里跟在它后面)。同名段重复注册会抛错, 所以那段注册包了 try/catch —— 规则注入失败不该让 10 个工具跟着起不来。 ### 2.6 AI 改栏目时,ntfy 会收到通知吗 **会。而且不论哪种移动都会。**(2026-10-16 实测,不是看代码推的) | AI 的操作 | ntfy 推送标题 | | --- | --- | | 跨列移动(待办 → 进行中) | `[项目名] 任务#N的任务栏已改变` | | **同一列内换位置**(只改排序) | `[项目名] 任务#N的新状态` | ⚠️ **但"会发"不等于"那个人收得到"。** 四个条件全满足才到得了: 1. 他是这个项目的**成员**; 2. 他的**通知总开关**开着,且勾了 `ntfy`; 3. 按他的**通知过滤器**该收 —— **默认是「只收我创建的、和指派给我的」**, 所以**别人的任务在他那儿是静默的**; 4. 他**不是这次操作的执行者** —— Kanboard 把操作者本人排除在收件人之外 (`getUsersWithNotificationEnabled($project_id, $this->userSession->getId())`)。 第 4 条最容易被忽略:AI 用的是**插件配置里那个用户名**的身份,所以: - 配置成 `admin` → **admin 自己看不到**这些通知;任务的创建人 / 负责人会收到; - 想让「AI 干活 → 你也能收到通知」,就给 AI **单独开一个 Kanboard 账号** (比如 `dsh-ai`),插件里填它的用户名和 Token。 这样 AI 的每个动作都会通知到该任务的创建人 / 负责人(**含你自己**), ntfy 上也多出一个独立话题 `ronlab-dtgm-dsh-ai`(可单独订阅、单独静音)。 第 3 条是实测踩出来的:以 admin 建了个临时任务,chenyujie 一条都没收到 —— 她既不是创建人也不是负责人;把任务指派给她之后,后面每条推送她都收到了。 想让人收到**全部**通知,让他把过滤器改成「所有任务」。 按 2.5 那套规则,AI 每处理一个任务通常会产生 **2 次移动通知** (接手时一次、完成或退回时一次),外加认领时的 1 次指派通知。 --- ## 3. 给同事用(局域网 / 互联网多人协作) Kanboard 侧: 1. 对方在 Kanboard 上有个账号(管理员可以在「用户管理」里建,或让 AI 调 `kanboard_api { method: "createUser", params: { username, password, name, role } }`)。 2. 对方登录 Kanboard → 右上角头像 → **个人信息** → **应用程序接口** → 生成。 3. 把该用户加进对应项目并给角色(`kanboard_api { method: "addProjectUser" }`)。 4. 服务要监听 `0.0.0.0`(本仓库的部署脚本已经这么做),防火墙放行端口。 DSH 侧(每人在自己的 DSH 里): 1. 装上这个插件。 2. 设置 → Kanboard:地址填**同一个** Kanboard 地址,用户名和 Token 填**自己的**。 3. 完事。对方接下来看到/能改的项目,就完全受他那个 Kanboard 账号的角色限制。 > 注意:聊天里贴过 API Token 就等于泄露了,让每个人自己生成、自己填。 --- ## 4. 排错 | 现象 | 原因 / 怎么办 | | --- | --- | | `ECONNREFUSED` | 地址或端口不对,或者 Kanboard 服务没跑 | | `认证失败(HTTP 401)` | 用户名或 API Token 不对;确认 Token 没被复制错、账号没被停用 | | `HTTP 403` / `权限不足` | Token 对应的账号在这个项目里角色不够 | | `Kanboard API 报错 [-32602] Invalid params` | 方法参数名写错了。Kanboard 用反射按**形参名**绑定,具名参数必须逐字对上,例如 `createTag` 用 `tag` 而不是 `name` | | 返回的不是 JSON | 地址填到了首页,或者反向代理把请求导到了登录页 | | 中文显示成乱码 | 只可能是终端解码问题;Kanboard 返回的是 UTF-8,插件按 UTF-8 解析 | --- ## 5. 代码结构(二次开发) ``` dsh-plugin-kanboard/ ├── index.js 插件入口:Config、工具注册、提示词注入、/kanboard/api 路由、本地桥接线、配置读写 ├── client.js 浏览器侧:设置页 + 侧栏底部「看板」按钮(在系统默认浏览器里打开,React + 同源 fetch) ├── lib/ │ ├── kanboard-client.js JSON-RPC 客户端(零依赖,http/https 手写,支持自签证书与取消) │ ├── task-rules.js 注入给 AI 的「任务处理规则」提示词段 │ ├── git-worktree.js git worktree / 分支命名 / 只读 git 判据(在**用户本机**跑) │ ├── worktree-pool.js **worktree 固定池 + 租约 + 回收**(D1/D2/D5,见下) │ ├── worktree-sync.js worktree 与远端主分支同步(D3/D4:只做能快进的) │ ├── worktree-prepare.js 依赖预热:按顺序跑**项目给的**命令(平台不硬编码命令) │ ├── project-config.js 读+校验 `.kanboard-ai/project.yml`(缺文件时报清"缺什么、放哪里") │ ├── drain.js **收尾 / 关机状态机**(`drain` / `drain-status`,见下) │ ├── handover.js 交接检查点:九段、冻结前导、提交/推送/上传 │ ├── human-report.js 「人话汇报」:提示词规则与工具层校验共用的一份判据 │ ├── acceptance-hold.js **「等人类验收」的钉住**(⚠️ 和 drain 不是一回事,见下) │ ├── node-state.js 节点心跳:报什么、怎么判在线(纯函数) │ ├── node-state-store.js 节点心跳**写进 Kanboard 的哪里**(暂定契约,见下) │ ├── self-update.js 插件自更新 + 重启的**判据**(默认只看不动手) │ ├── ai-fix.js 起一个独立的 DSH 会话去修任务 │ ├── model-catalog.js 读**这台电脑**上 DSH 能用哪些模型 / 哪些推理等级(只读) │ ├── local-bridge.js **本地桥**:给 Kanboard 页面跨源直连的 127.0.0.1 小服务 │ └── tools.js 10 个工具的定义与渲染 ├── cordis.patch.yml bundle 默认配置 ├── package.json dsh.bundle / dsh.client 声明 └── _verify/ ├── verify.mjs 对着真实 Kanboard 跑断言的联调脚本(需要自己传凭据) ├── verify-smoke.mjs 10 项只读冒烟(改完 lib/ 快速回归,不建任何东西) ├── verify-unconfigured.mjs 30 项,"没填用户名"场景,不需要 Kanboard ├── verify-worktree.mjs 51 项,真建临时 git 仓库跑 worktree,测完删掉 ├── verify-worktree-pool.mjs 145 项,池/租约/并发抢/心跳超时/分叉停手/回收拒绝(D1–D5) ├── verify-drain.mjs 147 项,收尾状态机(ready≠pending==0)+ 九段检查点 + 人话汇报 ├── verify-acceptance.mjs 150 项,等验收钉住 + 节点心跳 + 自更新判据(**不真的重启**) ├── verify-session.mjs 25 项,起修复会话的调用形状(假 ctx,含模型/推理等级覆盖) ├── verify-bridge.mjs 110 项,真起一个本地桥打它(CORS / PNA / 白名单 / 端口占用 / apply 接线) ├── verify-prompt.mjs 86 项,任务处理规则(含"AI 不许关闭任务")与注入条件 ├── verify-no-close.mjs 29 项,"AI 不许关闭任务"在**工具层**真的堵死了 ├── verify-models.mjs 34 项,模型目录的组装与脏数据兜底 └── verify-no-embed.mjs 35 项,内嵌那套没有残留 + 新入口的形态是对的 ``` ### worktree 固定池 + 租约(D1 / D2 / D3 / D5) 设计全文见 `docs/07-worktree方案.md`。要点: * **固定池,不是"一任务一 worktree"**:池大小由并行需求决定(起点 13 = 工程师 10 + 合并员 3),**可配置**。优先级是 调用方显式给的 > 插件配置 `worktreePoolSize` > 适配文件 `worktree.pool.size` > 默认 13。 池满时**排队**(返回 `status: "queued"` + 队内位置),**不新开 worktree**。 * **租约**:字段 `holder` / `role` / `task_id` / `acquired_at` / `heartbeat_at` / `expires_at`。拿不到就排队不抢;**心跳超时即失效**(超时回收会在 `expired_leases` 里留痕,好查"曾经是谁占的")。长任务要定期 `worktree-renew`。 * **回收三语义必须分开**: | action | 做什么 | 什么时候允许 | | --- | --- | --- | | `worktree-forget` | **只删登记**,磁盘目录一个字节都不动 | 随时(但现在被租出去时会拒绝) | | `worktree-recycle` | 回池:同步主分支 + 清缓存 + 删任务分支,**目录留着** | 任务关闭后 | | `worktree-remove` | 彻底删目录 + 删登记 | 只在缩池 / 人工确认时 | ⚠️ **`recycle` 和 `remove` 都在"有未提交改动"时拒绝** —— 现有那条"只删记录 不删目录"的规矩(防手滑删掉别人未提交的代码)没有被绕过。 * **同步只做能快进的**:遇到分叉 / 子仓领先 / 工作区脏,一律**停手**并报出 `next_role: "git-merge-agent"`("该找 Git 合并员")。主分支**查 `origin/HEAD`**, 不假设叫 master 或 main。 * **池状态存哪**:默认本机 `%TEMP%\dsh-kanboard-worktree-pool.json`(可用 `worktreePoolFile` 覆盖)。存储层只要求 `load`/`save` 两个方法 (`createFilePoolStore` 是默认实现)—— 要改成"以 Kanboard 的 per-user 工作区记录为准"只需换一个适配器,池和租约逻辑一行不用改。为什么默认不放 Kanboard:心跳往返一次网络,断网就会被误判成超时。 * **定期**(每 30 分钟,`DEFAULT_POOL_SYNC_INTERVAL_MS`)同步**空闲**的 worktree;被租出去的跳过 —— 那里面有人正在改代码,替它快进会把别人的 工作区搅了。触发式的三个时机(取用时 / 合并前 / 回收时)由各 action 自己 调 `worktree-sync`,判据是纯函数 `shouldSync`(离线可测)。 ### 收尾 / 关机(`drain` / `drain-status`) 契约:`platform/DesktopDecider/BRIDGE-CONTRACT.md`(**那是规格**)。 G4「我要关机」的桌面决策器每 3 秒轮询 `drain-status`,**只有看到明确的 `ready: true` 才显示「✅ 可以安全关机」**。 > ⚠️ **`ready` 不是 `pending == 0`。** 刚发起的那一瞬间 agent 可能还没登记 > 进来,`pending` 也是 0。拿它当 all-clear 会让人放心关机,然后真的丢掉 > AI 未提交的工作。所以判定里还有两道闸:**冷静期**(默认 5 秒,盖住 > "drain 返回 → agent 登记"这段真空)和**"agent 真的停了"**(`isActive` > 三态:true / false / **判断不了**,判断不了就不说 ready)。 实现全在 `lib/drain.js`(含 `shouldAcceptNewWork`:draining 期间**不再接新活** —— 那条把 `ai-fix` 挡住了,因为人已经点了关机还要起新会话去改代码, 会让"可以安全关机"这句话立刻过期)。交接检查点是 `lib/handover.js`: * **九段、顺序固定、一段都不许省,空的写 `(none)`**(docs/11 第三节); * 冻结前导逐字照抄(平台写,agent 不许改); * 「交接状态」那一段按 **git 事实**填(分支 / sha / 是否已推送 / 未提交的改动 / worktree 路径 / 环境),远端 URL 里的凭据**打码**; * 提交 → 推送 → 写检查点 → **上传**(`[AI-交接]` 评论;本地那份写在 `.kanboard-ai/handover/<会话 id>.md`); * ⚠️ **默认不自动提交**(`drainCommitUncommitted: true` 才开)。一个"把工作区 全 commit 掉"的收尾看着省事,但会静默提交 `.env` 之类的脏东西,而且推到 远端就不可逆 —— 所以默认只**如实报告**还有未提交的改动。 * 任何一步失败 → `ready` 保持 false,并把"卡在哪一步、人该怎么办"写进 `blocked_reasons`。**绝不假装成功。** ### 人话汇报(所有任务的描述开头) 用户的原话:「**所有的任务,无论是测试新建 bug,还是 AI 产品经理新建需求、任务, 描述里的开头都要有"人话汇报"这一个环节,限 100 字内说清楚给不懂技术的人类知道**」。 规范的写法(**描述的第一行**): ```markdown **人话汇报**:导出报表时如果数据超过一万行,进度条会卡住不动,用户以为程序死了。 ## 复现 - ... ``` * 提示词里的规则在 `lib/task-rules.js`(第 4.1 节); * **工具层校验**在 `lib/tools.js` 的 `kanboard_task create` —— 描述开头没有 这一段就**拒绝创建**,报错里带正面例子 + "不许出现类名/文件名/函数名"; * 两处共用 `lib/human-report.js` 的同一份判据(免得提示词说 100 字、 校验按 120 字放行,AI 被来回打回); * ⚠️ **只在工具层拦**(AI 走的路)。**人从 Kanboard 界面建任务不受影响** —— 界面那边是软提示,那半在 Kanboard 仓里,不在这个插件。 * 容错:加粗/不加粗、全角/半角冒号、行首列表符都认;略微超长(≤130 字) 只给**软警告**,不拒绝 —— 免得为了"第 101 个字"把标准悄悄放松。 * 字数按**中文逐字**算(标点空白不计)。第一版用 `Intl.Segmenter` 按词分词, 那会把"导出报表会卡住"数成 4 个字、变相放行到 250 个汉字 —— 自测抓出来的。 ### 「等人类验收」的钉住(⚠️ 和 drain 不是一回事) 用户原话:「**在等待人类验证的过程,这个等待人类验证的任务所在的 worktree 不要再 继续派发新的任务,做新的更改了,等待人类验证完,再继续。**」 | | 范围 | 触发 | 结束 | | --- | --- | --- | --- | | `drain` | **整台机器** | 人点「我要关机」 | 收尾完成 / 人取消 | | **验收钉住** | **一个 slot** | 它上面那个任务进了**待验收**列 | 那个任务离开那一列 / 被关掉 | 实现:`lib/acceptance-hold.js`(判据)+ `lib/worktree-pool.js` 的 `holdForAcceptance` / `reconcileAcceptance` / `releaseAcceptance`。 * 被钉住的 slot **`available: false`** —— `acquire` 拿不到它,`release` / `recycle` / `remove` 也**都不许**碰它(钉子不是租约:**不过期、不靠心跳**, 只有明确的释放路径能解开)。 * ⚠️ **借不到时必须说清是哪一种原因**:`queued` 会带 `queue_reason: "waiting-acceptance"` + 被钉的明细,消息里写明"这**不是** 池太小,调大池大小也不会让它提前回来" —— 和"池真的满了"(`no-hold`)分开。 * **释放路径真的通**:`reconcileAcceptance` 每 30 秒(复用合并状态那个 定时器)问一次"那些任务还在待验收列吗",**人一关任务下一轮就回池** —— 不需要任何人记得手动清理。 * **判断不了的时候**:初次判断不了 → **不钉**(不白占一个 slot),但把原因 带回去(`decideHold` 的 `unchecked`);**已经钉住之后**判断不了 → **保住钉子**(解开 = 验收现场被覆盖,不可逆),并记进 `check_errors`。 * 待验收列按**标题**认(`acceptanceColumnHint`,默认「待验收」)。认不出来时 **不猜 id**,报"判断不了"。 ### 节点状态心跳(给多机调度器用) 给 `docs/10-多机调度.md` 的拉模式调度器提供输入。**复用现有的 30 秒定时器** (合并状态那套),不另起一个。 > ⚠️ **这是我暂定的契约,调度器那一路要按它来**(需求说"先别定死")。 > 完整约定写在 `lib/node-state-store.js` 的开头。三个要点: > > 1. **存在哪**:一个专用项目(默认 `[DSH 节点]`)里,**每个节点一个任务**, > 任务 `reference = node/<机器名>`,心跳载荷写在该任务的**元数据** > `dsh_node_state`(JSON 字符串)。 > ⚠️ **不写项目元数据**:那是整个项目共享一个键,所有节点会互相覆盖, > 结果永远只看得到最后一个报到的节点。 > 2. **读法**:`getProjectByName` → `getAllTasks` → 每个任务 `getTaskMetadata` > → `JSON.parse`,再按 `classifyNodes` 判在线。 > 3. **在线 = 最近一次心跳在 90 秒之内**(`NODE_STATE_STALE_AFTER_MS`)。 > 拔网线不会有人发"我下线了",只能靠**过期**判断。 > > 载荷:`{schema, node_id, at, active_agents, pool_slots, capabilities:{projects:[{project_id, repo_name}]}, plugin_version, notes}` > —— ⚠️ **不带任何本机绝对路径**(能力只表达"能干什么",不表达"东西放在哪")。 ### 插件自更新 + 自动重启(**默认只看不动手**) 用户原话:「对多台电脑的 DSH 插件,要能够自动更新,自动重启(自动控制停下所有 agent 的工作(有后台任务的情况下等待后台任务完成再重启),自动重启)」 * **自动更新**:定期 `git fetch`(默认 6 小时),落后就更新;**只做能快进的** (`--ff-only`,插件目录里不许 merge)。 * **自动重启**:复用 `drain` / `drain-status` —— **它就是"能不能安全重启"的判据**, 没有另起一套。流程是"发起 drain → 等 `ready: true` → 才重启"。 * ⚠️ **默认(`updateAutoApply: false`)只检查 + 报告,不动手**。理由:这台机器上 随时可能有会话在跑,默认自动重启会**当场掐断正在进行的构建**。 * ⚠️ **drain 没 ready 就绝不重启**:`decideUpdateAction` 会返回 `wait-drain`,这一轮跳过、下个周期再看,并把"为什么还不能"写进 `reason`。 * ⚠️ **重启的最后一下交给一个独立的小进程**(`detached` 的 node 脚本): 插件在还活着的时候把**交接单**落盘(更新前的 commit、重启命令、要等的 pid、 日志路径),小进程等父进程退出 → 拉起 → **起不来就 `git reset --hard` 回退**, 结果写进 `%TEMP%\dsh-kanboard-restart\restart.log`。 不自己执行那一下的理由:进程被杀之后**所有代码都不再执行**,包括失败回退和 通知 —— 那会让"更新了但起不来"变成**彻底的静默失败**。 * 形态检测:`detectForm` 认出 `desktop-app`(Electron 桌面壳)/ `node-web` (命令行)。**认不出来就不自动重启**(不猜一条命令把 DSH 起坏)。 * 更新失败 / 重启失败都**如实报**,并带 `rollback_hint`(回到哪个 commit)。 ### 本地桥(`lib/local-bridge.js`) 看板上的「添加 worktree / 让 AI 处理」要跑 `git worktree add`、要起会话 —— 这些必须发生在**点鼠标那个人的电脑**上。所以浏览器**直接**连本机的 `http://127.0.0.1:19390`(默认端口),不再经 Kanboard 的 PHP 转发: ``` 浏览器 --跨源--> 本机 127.0.0.1:19390(本地桥)--> 跑 git / 起会话 ``` * 为什么不能走 PHP:Kanboard 跑在服务器上时,它的 `127.0.0.1` 指的是**服务器 自己** —— 同事的本机路径会被送到服务器上去执行。 * 为什么要 PNA 头:从内网页面访问 `127.0.0.1` 时浏览器会额外发预检,要求 `Access-Control-Allow-Private-Network: true`。 * 为什么有白名单:本地端口"这台机器上任何网页都连得到",只回 `baseUrl` 那个 origin(可用 `bridgeOrigins` 配多个),别的来源一律 403。 * 端口被占**不影响插件加载** —— 只少一个桥,10 个工具照旧。 桥现在放开这几个 action: `worktree-list` / `worktree-add` / `worktree-pool` / `worktree-pool-init` / `worktree-lease` / `worktree-renew` / `worktree-release` / `worktree-sync` / `worktree-sync-all` / `worktree-recycle` / `worktree-forget` / `worktree-remove` / `worktree-project-config` / `worktree-hold-acceptance` / `worktree-acceptance` / `worktree-reconcile-acceptance` / `worktree-release-acceptance` / `drain` / `drain-status` / `plugin-update` / `ai-fix` / `merge-refresh` / `models` / `version`。 **其中 `models` 是"选模型 → 选推理等级"那两级菜单用的**:模型列表是 **正在跑的那台电脑上的 DSH 配置**,Kanboard 服务端(很可能在另一台机器上) 压根不知道,所以只能跨源问本机。拿不到列表时看板会退回"用 DSH 默认"并说明原因, 不会卡在那一步。 (`get` / `test` / `save` / `bridge-status` 那种能读改插件凭据的 **永远不放**进来,名单见 `index.js` 的 `CREDENTIAL_ACTIONS`。) ### 依赖预热(D4):命令只从**项目适配文件**来 平台**不认识** `npm` / `pip` / `cargo`。它只提供"建池时按顺序跑一组**项目给的** 命令"这个能力 —— 命令来自仓库里的 `.kanboard-ai/project.yml`: ```yaml worktree: prepare: - { run: "某个项目自己的安装命令" } - { run: "某个项目自己的构建命令", where: "相对仓库根的子目录" } after_sync: [] pool: size: 13 submodules: # 没有子模块的项目整段省略 enabled: true check_command: "判定子仓是否领先的命令;退出码非 0 = 要合并员" ``` **缺这个文件时会明确报出"缺什么、放哪里、长什么样"**(`worktree-project-config` 这条 action 就是只读地看这一眼),而且**池照样建**(目录是好的),只是把 `prepared: false` + 原因标在 slot 上 —— 排活的 AI 因此知道那个 worktree 现在跑不了测试。绝不静默不干活。 `worktree.prepare` 里 `where` 只许是仓库内的相对子目录(绝对路径和 `..` 会被拒绝), 命令用 shell 执行 —— 它跟代码一起版本化、一起 review,所以能执行任意命令这件事 是设计,不是漏洞。 改完代码**不需要构建** —— `client.js` 是静态 bundle,`lib/*.js` 是原生 ESM。 宿主侧(`index.js` / `lib/*.js`)改完要**重载插件或重启 DSH**: Node 会缓存已加载的 ES 模块,运行中的进程不会自动读到新文件。 `client.js` 改完**刷新页面**即可 —— 宿主每次刷新都重新读盘上的这个文件。 不带参数就能跑的几个(改完随手跑,几秒): ```powershell node _verify/verify-unconfigured.mjs node _verify/verify-prompt.mjs node _verify/verify-worktree.mjs node _verify/verify-worktree-pool.mjs # 池/租约/同步/回收(141 项) node _verify/verify-session.mjs node _verify/verify-bridge.mjs node _verify/verify-smoke.mjs http://192.168.1.10:8080 admin <个人 API Token> ``` 跑完整联调验证: ```powershell node _verify/verify.mjs http://192.168.1.10:8080 admin <个人 API Token> ``` 它会覆盖大多数 action,专门用来抓"参数名对不上"这类错误(Kanboard 只会回 一个笼统的 `Invalid params`,不看源码猜不出来)。注意它**会创建**两个演示项目 并保留下来(最后会打印名字),不想留东西就只跑 `verify-smoke.mjs`。 ### 加一个新 action 在 `lib/tools.js` 对应工具的 `actions` 表里加一行: ```js my_action: { write: true, // 标了写,只读模式就会拦住它 note: '一句话说明', // 会进 action 的枚举说明 async run(a, { client, signal }) { const params = { project_id: requirePositiveInt(a.project_id, 'project_id') }; const done = await client.call('someApiMethod', params, { signal }); return ok('做完了。', { done: done === true }); }, }, ``` `schema` 的枚举、并发策略、只读拦截都会自动跟上。 --- ## 6. 左栏底部的「看板」按钮:在**系统默认浏览器**里打开 左栏底部有一个 **「看板」** 按钮(图标是三列卡片)。**点它就用系统默认浏览器 打开 Kanboard** —— 打开的是设置里那个「服务器地址」,**不用为这件事再填一次东西**。 没配地址时它点不动,鼠标悬停会告诉你"去「设置 → Kanboard」填服务器地址"。 ### 6.1 为什么不再内嵌(这一节是整段改动的理由) 原来是在 DSH 界面里嵌一个常驻 iframe 把看板显示出来。**那条路走不通** —— 用户报的是"一点登录,我填的用户名密码就消失了,提示我要填用户名密码"。 **不是密码错,是会话丢了。** 根因链(每一步都有证据,不是推理): 1. Kanboard 的会话 cookie **没写 `SameSite`** —— `SessionManager::configure()` 那句 `session_set_cookie_params(...)` 没传它,PHP 的 `session.cookie_samesite` 也是空的(`C:\php83\php.exe -r` 读出来是 `[]`)。 于是浏览器按默认的 **`Lax`** 处理。 2. `Lax` 的 cookie **在跨站 iframe 里不会被存下来**。DSH 桌面端是 `dsh-app://app`、 Web GUI 是 `127.0.0.1:19387`,而 Kanboard 在 `192.168.20.85:8080` —— 不同 site,正好命中。 3. 登录 POST 拿不到会话 → CSRF 失败 → `Request::getValues()` **静默返回空数组** → 登录表单拿这个空数组回填 → 表现**就是**"字段被清空 + 提示用户名密码必填"。 **实测(真 Chromium,自造的最小复现,没碰任何真实数据):** | 场景 | 会话 cookie | 登录 POST | 结果 | | --- | --- | --- | --- | | **跨站 iframe**(原来的做法) | **一个都没存下来** | 甚至没发出去 | 登不上 | | 同站 iframe(同 IP 只差端口) | 正常存下 | 带上了 | **成功** | | **顶层导航(现在这条)** | 正常存下 | 带上了 | **成功** | | 跨站 iframe + `SameSite=None; Secure` | 没存下(http 上 `Secure` 被拒) | 没发出去 | 登不上 | | 同站 iframe + `SameSite=None; Secure` | **没存下** | 没有 | **本来能登的也登不上了** | | `SameSite=None` 不带 `Secure` | 被浏览器拒收 | — | 登不上 | > ⚠️ **结论:没有任何 cookie 写法能修好"跨站 iframe"这个场景。** 尤其是 > `SameSite=None; Secure` 这个"标准答案",在内网 http 上是**反效果** —— > 它会把原本能用的同站/顶层场景一起弄坏(上表倒数第二行)。所以插件**刻意 > 不动 Kanboard 的 cookie 配置**。 > > 于是用户拍板:**放弃内嵌**,改成在系统默认浏览器里打开。顶层导航是 `Lax` > 放行的,登录一直都能用。 ### 6.2 为什么挂在 `sidebar.footer.action`,而不是面板列表 原来的「看板」是注册在 `sidebar.panellist` 里的**导航**条目:行点击由 DSH 自己 处理(`layout.selectPanel(id)`),而且它要求同一个 id 在 `main` 插槽里有一个 keyed 面板 —— sidebar 的 README 写得很明白,"选择不存在的主面板条目会抛错"。 现在它是一个**动作**(打开外部浏览器),既不切主视图、也没有主面板,硬留在那一列 只会变成一个点不动的坏条目。所以改用**动作席位** `sidebar.footer.action` (DSH 自己的 ui-cordis 就挂在那儿),它接收 `{ wide }`:展开时图标 + 文字, 收起成 56px 轨道时只留图标。 **副作用(有意的)**:这个入口从"面板列表"移到了侧栏**底部**,而且 **没有选中态**(不设 `aria-current`)—— 它不再是一个可切换的面板,就不该表现得 像其中一个。`inject` 也跟着从 `['slots', 'layout']` 收成 `['slots']`: 不切主视图了,就不要那个服务。 ### 6.3 "用系统默认浏览器打开"是怎么做到的 一句话:**一个普通的 ``**。 先查过 DSH 有没有现成的"打开外部链接"能力(`cordis_inspect_list` + 解 asar 读实现): * host 侧的 `ctx.shell` 是**执行 bash 命令**的服务,不是打开链接; * `@deepseek-ai/dsh-host-open-in-app` 的 `/open-in-app/open` 收的是 `{app, path}` —— 用某个编辑器打开某个**目录**,不是打开一个 URL; * `@deepseek-ai/dsh-client-ui-primitives` 里确实导出了 `openExternalLink`, 但它**只由 React context 提供**(`MarkdownDelegateProvider`),是给 markdown 里的链接用的回调,插件从外面拿不到。 而 DSH 自己的 markdown 链接就是这么做的(web-frontend 里的 `Pb` 组件用的就是 `target="_blank" rel="noopener noreferrer"`)。桌面端的 Electron 主进程对 **主窗口**注册了: ```js // 从打包好的 DSH lib/main.js 里摘出来的原文 window.webContents.setWindowOpenHandler(({ url }) => { if (["http:", "https:"].includes(new URL(url).protocol)) shell.openExternal(url); return { action: "deny" }; }); ``` 也就是说,`target="_blank"` 出去的 http(s) 地址会被交给系统默认程序 —— **这就是官方支持的路径**,不是绕过去。 用真正的 `` 而不是 `window.open(...)`:浏览器不拦用户点击的链接; 而且 `rel="noopener"` 保证新页面拿不到本页的 `window.opener`(否则那个页面能把 我们的窗口导航走)。顺带一提,给 `window.open` 传 `noopener` 时返回值按规范就是 `null`,所以也没法用它判断"是不是被拦了" —— 用链接更干净。 > ⚠️ **网页版 DSH**(在浏览器里打开的那个)上,它的行为就是**开一个新标签页** —— > 这也正是想要的。 **实测**(真 Chromium,夹具页里放的就是上面那个 ``,真鼠标点击,不是 `.click()`): ``` 浏览器自己报的 windowOpen 事件: {"url":"http://192.168.20.85:8080/","windowName":"_blank","userGesture":true, …} 点完之后浏览器里的页面: http://192.168.20.85:8080/?controller=AuthController&action=login 新页面里读到:title="登录",有登录表单,window.opener === null ``` (`window.opener === null` 就是 `rel="noopener"` 生效的证据。) > ⚠️ **"交给系统默认浏览器"那一步是桌面端 Electron 主进程做的**,而运行中的 > 桌面 app 不能被我重启或驱动,所以那一层只做了**读代码确认**(上面那段 > `setWindowOpenHandler` 是从打包好的 `lib/main.js` 里读出来的原文), > **没有真机点过**。普通 Chromium 里验到的是"开新标签页"。 ### 6.4 自己怎么看"到底哪里不对" 打开 **设置 → Kanboard**,最下面那块**只读状态**是**本地桥**的:有没有在监听、 **真实端口**、监听地址、密钥配没配、来源白名单、有没有上报成功、以及一条可以 直接粘到终端里验通不通的自测命令。 **内嵌那套诊断已经删了**(来源、几何、遮罩、`shell.overlay` 里的子元素……)—— 已经没有内嵌了,留一张和现状无关的表只会误导人。 排错时看两件事: * **点了没反应** → 看那个按钮的 `title`(鼠标悬停):它要么在说"还没配地址", 要么在说"读不到插件配置"。 * **桥那一段显示"没有在监听"** → 看板右键菜单会连不上;改插件配置的 `bridgePort`(配 0 就是让系统随机挑一个),或者重启 DSH。 --- ## 7. 已知限制 - **工具失败不抛异常**,而是返回 `{ ok: false, summary: "..." }`。这是故意的: 模型能读懂并自己换参数重试,抛异常只会让整轮卡住。 - 列表类结果默认截断(任务 100 条、活动 25 条),需要更多请显式传 `limit`。 - `getBoard` 只返回**进行中**的任务;已关闭的要走 `kanboard_task { action: 'list', status_id: 0 }`。 - 附件(上传/下载)没做成工具,走 `kanboard_api` 的 `createTaskFile` / `downloadTaskFile`。这两个方法收发 base64,大文件要留神上下文长度。 - **侧栏那个「看板」按钮不会把看板"读"回来**:它只是打开浏览器。所以看板上 选中了哪些任务、看板上的筛选和滚动位置,都不会出现在 DSH 里 —— 这是"放弃内嵌" 的必然结果,也是它换来的好处(登录能用了)。 - **那个按钮的位置在侧栏底部**,不在面板列表里。它不是一个可切换的面板, 所以没有选中态、点了也不切主视图。 ## 8. 许可 MIT。