dsh-baton-mcp 0.5.9 → 0.6.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 CHANGED
@@ -69,10 +69,55 @@ That is all. If Harness says it could not register, run `npx -y dsh-baton-mcp se
69
69
  - **Codex** waits inside `dsh_wait`, up to 270 s per call, just under its 300 s tool timeout. An unfinished wait returns only the state, so Codex calls again without summarising. Codex gets no `wait_command`: its terminal cannot wake it, so it would poll.
70
70
  - Codex asks you to approve MCP tool calls: approve the `dsh-baton` tools. For unattended `codex exec` runs, add `--approve-for-me`.
71
71
 
72
+ **How a hand-over is checked** (from 0.6.0)
73
+
74
+ You don't have to take DeepSeek's word for it. Before the run, dsh-baton notes what is in the folder. When the run ends it shows exactly which files were created, changed or deleted, and runs your project's own tests itself (`npm test`, or whatever the client passes as `checks`). A second, read-only DeepSeek agent then goes through the request point by point and ends with a table: requirement, met or not, and where (file:line). Claude or GPT reads just that, sends anything missing back in one message, and opens code only where a row says "not met". Unless `cwd` names another folder, the new Harness session starts in the folder you're already working in.
75
+
76
+ <p align="center">
77
+ <img src="./docs/benchmarks/token-usage-2026-10/charts/evidence-chain.svg" width="100%" alt="Evidence chain of one real hand-over: the user asks, Codex writes the spec and checks, dsh-baton snapshots the folder, DeepSeek Harness writes 11 files, dsh-baton lists the changed files and runs npm test itself, Codex sends one fix back on its own, dsh-baton checks again, Codex accepts, and the benchmark's own test passes 13 of 13">
78
+ </p>
79
+
80
+ ## Token usage
81
+
82
+ **In short: hand over the big, long jobs.** A feature that spans many files, a large refactor, a whole small app: that is where it saves the most. Claude Code's own bill drops by 65–81%, and Codex gets 2 to 4 times as many tasks out of a ChatGPT Plus 5-hour window.
83
+
84
+ **The honest part:** for small and medium tasks, Codex on GPT-6.1 Sol is cheap enough that doing it alone costs less in total than handing it over. The hand-over pays off when you want Claude or GPT to plan and review, a cheaper model to do the typing, and you'd rather not keep hitting the 5-hour limit.
85
+
86
+ **Who this is for:** most of us pay for Claude or ChatGPT with a plan (Plus, Pro, Max 5x…), while DeepSeek is pay-as-you-go from a balance you top up, so every hand-over is money spent outside your plan. If your plan's limits already cover how you work, you don't need this: keep it simple and keep the money. If you're like me and keep running long, messy jobs with round after round of changes, and keep hitting the limit, a little DeepSeek spend gets you a lot more out of the plan you already pay for.
87
+
88
+ <p align="center">
89
+ <img src="./docs/benchmarks/token-usage-2026-10/charts/natural-combined.svg" width="100%" alt="Cost per task, working alone vs handed over with 0.6.0: Claude Code (Opus 5.5) and Codex (GPT-6.1 Sol, GPT-6 Astra), each on its own scale">
90
+ </p>
91
+
92
+ Cost per task at list price, DeepSeek V4.1 Flash 1017 at its peak price:
93
+
94
+ | Client · model | Task | Alone | Handed over, client only | Handed over, with DeepSeek |
95
+ |---|---|---|---|---|
96
+ | Claude Code · Opus 5.5 high | Medium | $1.07 | $0.37 (−65%) | $0.55 (−49%) |
97
+ | Claude Code · Opus 5.5 high | Large | $2.13 | $0.41 (−81%) | $0.70 (−67%) |
98
+ | Codex · GPT-6.1 Sol high | Medium | $0.17 | $0.09 (−49%) | $0.29 (+73%) |
99
+ | Codex · GPT-6.1 Sol high | Large | $0.41 | $0.10 (−75%) | $0.41 (−2%) |
100
+ | Codex · GPT-6 Astra high | Medium | $0.79 | $0.45 (−43%) | $0.65 (−18%) |
101
+
102
+ Share of the ChatGPT Plus 5-hour window per task: Sol medium 1.9% → 0.8%, Sol large 4.3% → 1.0%, Astra medium 7% → 3%.
103
+
104
+ <details>
105
+ <summary>What was tested, and how</summary>
106
+
107
+ - **Two tasks, asked the way a user would** (the requests are in Chinese, linked as written):
108
+ - Medium: a command-line task manager in Node, no dependencies, split into modules, with tests. [Request](docs/benchmarks/token-usage-2026-10/natural/medium.md) · [acceptance check](docs/benchmarks/token-usage-2026-10/checks/natural-medium.mjs)
109
+ - Large: a task manager web app, Node server plus page, REST API, stats, tests and a README. [Request](docs/benchmarks/token-usage-2026-10/natural/large.md) · [acceptance check](docs/benchmarks/token-usage-2026-10/checks/natural-large.mjs)
110
+ - **The only difference** in the hand-over runs is one line added at the end: 交给 DeepSeek Harness 去做。 ("Hand it to DeepSeek Harness.")
111
+ - Same model and effort in both arms, three runs per cell (Astra: one run, medium only), and every run judged by the same acceptance check, which the model never sees. All 12 hand-over runs passed it the first time.
112
+ - Prices are list prices. DeepSeek is counted at its peak price, the most conservative case; the real DeepSeek bill came to about 58% of that.
113
+ - Every run, the raw data and the scripts: [run log](docs/benchmarks/token-usage-2026-10/LOG.md) and [full report](docs/benchmarks/token-usage-2026-10.md).
114
+
115
+ </details>
116
+
72
117
  ## Tools
73
118
 
74
119
  <p align="center">
75
- <img src="./assets/readme/tools.svg" width="100%" alt="Workspaces: list, create, rename. Sessions: list, read, activity, rename. Runs: send, status, wait, cancel, list runs">
120
+ <img src="./assets/readme/tools.svg" width="100%" alt="Nine tools in the tool list: list workspaces, list sessions, read session, send, status, wait, answer, cancel, call. Six less used tools are reached through dsh_call: create and rename workspace, session activity, rename session, update queued, list runs">
76
121
  </p>
77
122
 
78
123
  <p align="center">
@@ -85,20 +130,20 @@ That is all. If Harness says it could not register, run `npx -y dsh-baton-mcp se
85
130
  | Tool | What it does |
86
131
  |---|---|
87
132
  | `dsh_list_workspaces` | Workspaces in the Harness sidebar |
88
- | `dsh_create_workspace` | Register a folder as a workspace (creates the folder if missing); returns the existing one if already registered |
133
+ | `dsh_create_workspace` | Through `dsh_call`. Register a folder as a workspace (creates the folder if missing); returns the existing one if already registered |
89
134
  | `dsh_list_sessions` | Sessions newest first, with title, folder and running state; filter by folder or text. Archived sessions are left out |
90
135
  | `dsh_read_session` | Recent prompts and replies of one session (with tool names); `archived: true` for an archived session |
91
- | `dsh_send` | Send a message: `session_id` continues that session; `cwd` or `workspace_id` starts a new one (a `cwd` that is not a workspace yet is registered first). Returns a `run_id` at once. In Harness the message is marked "Sent by Claude / ChatGPT from another chat". An archived session is refused: unarchive it in Harness, or start a new session. To a busy session, `mode` `followup` queues the message after the current turn and `steer` adds it to the running turn; left out, it does what Harness's own setting says (Settings → sending while busy: queue or steer), and `followup` when that cannot be read. `mode_source` says which applied |
136
+ | `dsh_send` | Send a message: `session_id` continues that session; otherwise a new one starts in `cwd` or `workspace_id`, or, with neither, in the folder the conversation works in (a `cwd` that is not a workspace yet is registered first; a `cwd` outside the conversation's folder is allowed and noted in `folder_note`). `checks`: test commands dsh-baton runs itself when the run ends (see How a hand-over is checked). Returns a `run_id` at once. In Harness the message is marked "Sent by Claude / ChatGPT from another chat". An archived session is refused: unarchive it in Harness, or start a new session. To a busy session, `mode` `followup` queues the message after the current turn and `steer` adds it to the running turn; left out, it does what Harness's own setting says (Settings → sending while busy: queue or steer), and `followup` when that cannot be read. `mode_source` says which applied |
92
137
  | `dsh_status` | Run progress: state, recent text and tool calls, final reply; `waiting_on` when the turn is over but sub-agents or jobs still run; `human_interjections` when someone typed in the Harness UI mid-run; state `interrupted` for a run cut short by a Harness restart |
93
- | `dsh_wait` | Wait for the run to end (or until it stops to ask a person, see `dsh_answer`): up to 270 s per call for Codex, 50 s for other clients. Still going: returns `timed_out` with just the state; call it again. Clients that can wait in the background use `dsh_send`'s `wait_command` instead. `dsh_status` is a one-off look, not a way to wait |
138
+ | `dsh_wait` | Wait for the run to end; a finished run carries `evidence` (files changed, check results) when the message was sent with `checks` (or until it stops to ask a person, see `dsh_answer`): up to 270 s per call for Codex, 50 s for other clients. Still going: returns `timed_out` with just the state; call it again. Clients that can wait in the background use `dsh_send`'s `wait_command` instead. `dsh_status` is a one-off look, not a way to wait |
94
139
  | `dsh_answer` | Answer the questions a run stopped to ask (Harness's ask_user_question): per question, an option number or label, free text, or skip; one at a time or all at once. `dsh_wait` and `wait_command` return as soon as a run asks, with the questions (`needs_input`, `question`; the option Harness's model would pick is in `harness_suggested`, apart from the labels). The assistant answers by its own judgment only when you told it to decide, and then passes `reason` and tells you what it chose and why; otherwise it brings you the questions, starting with its own recommendation and reason. You can also answer in the Harness window: whoever answers first wins |
95
- | `dsh_session_activity` | What a session is doing now: running turn, sub-agents (any depth), background jobs; `archived: true` for an archived session |
96
- | `dsh_update_queued` | Change a message still waiting in the queue of a busy session, by its `run_id`: `edit` its text, `remove` it, or `steer` it into the running turn now. Once it has reached the conversation it can no longer be changed |
140
+ | `dsh_session_activity` | Through `dsh_call`. What a session is doing now: running turn, sub-agents (any depth), background jobs; `archived: true` for an archived session |
141
+ | `dsh_update_queued` | Through `dsh_call`. Change a message still waiting in the queue of a busy session, by its `run_id`: `edit` its text, `remove` it, or `steer` it into the running turn now. Once it has reached the conversation it can no longer be changed |
97
142
  | `dsh_cancel` | Stop everything a session is doing, by `run_id` or `session_id`: turn, sub-agents, jobs. Harness also drops the messages waiting in its queue; their runs end as `cancelled` |
98
- | `dsh_rename_session` | Rename a session |
99
- | `dsh_rename_workspace` | Rename a workspace (sidebar title only; the folder is not moved) |
100
- | `dsh_list_runs` | Recent runs, optionally of one session |
101
- | `dsh_call` | Backup entry: calls any `dsh_*` tool by name, for a conversation whose tool list predates an update; an unknown name lists the tools |
143
+ | `dsh_rename_session` | Through `dsh_call`. Rename a session |
144
+ | `dsh_rename_workspace` | Through `dsh_call`. Rename a workspace (sidebar title only; the folder is not moved) |
145
+ | `dsh_list_runs` | Through `dsh_call`. Recent runs, optionally of one session |
146
+ | `dsh_call` | Calls any `dsh_*` tool by name: the less used tools (create and rename workspace, rename session, session activity, update queued, list runs) are not in the tool list from 0.6.0 on, to keep every request short, and are reached this way; also for a conversation whose tool list predates an update. An unknown name lists the tools |
102
147
 
103
148
  </details>
104
149
 
@@ -141,13 +186,16 @@ When the plugin is unreachable, the MCP server falls back to Harness's `dsh head
141
186
 
142
187
  ## Install details
143
188
 
189
+ <details>
190
+ <summary>What the plugin does, updating, removing, upgrading from dsh-bridge, installing from source, Windows</summary>
191
+
144
192
  **What the plugin does when switched on.** It copies the MCP server to `~/.dsh-baton/app`, a folder that stays put, and writes the MCP entry into `~/.claude.json` and `~/.codex/config.toml` directly, keeping a `*.bak-dsh-baton` backup of each. It starts the server with your own Node.js ≥ 22.15 when it finds one on `PATH`; without one, with Harness's own runtime (the Harness app in Node mode, `ELECTRON_RUN_AS_NODE=1`), so nothing else has to be installed. Either way it works with only the desktop apps installed, when `claude` / `codex` are not on your `PATH`. It runs again on each Harness start: an entry that is already right, or a working one that starts another copy of dsh-baton, is left alone. In Harness, the source switches to the npm registry ("Official npm registry", 「npm 官方源」) by itself once you type the package name, and no restart is needed. To turn the registration off, set `DSH_BATON_AUTO_REGISTER=0` for Harness.
145
193
 
146
194
  **Updates need no restart.** When a newer version lands in `~/.dsh-baton/app` (a plugin update, `setup`, or a copy by hand), conversations already open in Claude Code and Codex use it from their next `dsh_*` call: the server checks the folder before each call, starts the new version, and says once in that call's result that it was updated. A `dsh_wait` that is already running finishes on the old version first. If the new version cannot start, the old one keeps working and the result says why. A tool added in the new version shows up in Claude Code at once (the server tells the client its tool list changed); Codex keeps the tool list a conversation started with, so it does not show a new tool until the next conversation; from 0.5.9 on it can still use one at once through `dsh_call`, which the update note points to, and the tools it already has run the new code right away. This works from the version after 0.5.8 on; a server started from 0.5.8 or earlier needs one last restart (or `/mcp` reconnect) to pick it up.
147
195
 
148
196
  **`npx -y dsh-baton-mcp setup`** does the same registration from a terminal: for a plugin that could not register, or when you come from an MCP directory. With the plugin already registered it changes nothing, then points you to the plugin if it is missing.
149
197
 
150
- **Updating.** Harness has an update button only for its official plugins, so: Plugins → dsh-baton-host → **Uninstall**, then **Add plugin** → `dsh-baton-host`. For a while after a release, Harness installs the previous version instead (pnpm's `minimumReleaseAge` holds back versions published very recently, and the install dialog says so); to get the new one at once, type the version: `dsh-baton-host@0.5.0`. Switched on again, the plugin brings the MCP server to the same version; new conversations use it. If the two are ever out of step, the first tool call that notices it says so.
198
+ **Updating.** Harness has an update button only for its official plugins, so: Plugins → dsh-baton-host → **Uninstall**, then **Add plugin** → `dsh-baton-host`. For a while after a release, Harness installs the previous version instead (pnpm's `minimumReleaseAge` holds back versions published very recently, and the install dialog says so); to get the new one at once, type the version: `dsh-baton-host@0.6.0`. A pre-release must always be added by its exact version, such as `dsh-baton-host@0.6.0-next.0`: Harness resolves `@next` to a broken placeholder. Plain `dsh-baton-host` gets the latest release. Switched on again, the plugin brings the MCP server to the same version; new conversations use it. If the two are ever out of step, the first tool call that notices it says so, naming the older side and the newer side's exact version to install.
151
199
 
152
200
  **Removing.** Harness → Plugins → dsh-baton-host → **Uninstall**, then `npx -y dsh-baton-mcp setup --remove`. The plugin cannot tell an uninstall from Harness quitting, so it leaves the MCP entries to that command, which also deletes `~/.dsh-baton/app`; run records stay in `~/.dsh-baton` until you delete it.
153
201
 
@@ -177,6 +225,8 @@ codex mcp add dsh-baton -- "$(command -v node)" "$PWD/dsh-baton/src/server.mjs"
177
225
 
178
226
  </details>
179
227
 
228
+ </details>
229
+
180
230
  ## Security and limits
181
231
 
182
232
  <p align="center">
package/README.zh-CN.md CHANGED
@@ -50,7 +50,7 @@ Codex 和 Claude 轮流往同一个 Harness 对话发消息,每条都标明是
50
50
 
51
51
  插件启用后会为 Claude Code 和 Codex 注册 dsh-baton(对所有文件夹生效),Harness 里会弹出提示。
52
52
 
53
- **2. 检查:**在 Claude Code 或 Codex 里**新开**一个对话,让它调用 `dsh_list_workspaces`,会列出你的 Harness 工作区。
53
+ **2. 检查**:在 Claude Code 或 Codex 里**新开**一个对话,让它调用 `dsh_list_workspaces`,会列出你的 Harness 工作区。
54
54
 
55
55
  就这些。如果 Harness 提示没能注册,在终端运行 `npx -y dsh-baton-mcp setup`(这需要 Node.js ≥ 22.15),它做的是同样的注册。更新、卸载和 Windows 说明在[后面](#安装细节)。
56
56
 
@@ -69,10 +69,55 @@ Codex 和 Claude 轮流往同一个 Harness 对话发消息,每条都标明是
69
69
  - **Codex** 在 `dsh_wait` 里等待,每次最多 270 秒,刚好低于它 300 秒的工具超时。没做完时只返回状态,Codex 会直接再调用,不会去总结进度。Codex 不会收到 `wait_command`:它的终端无法在命令结束时唤醒它,只能轮询。
70
70
  - Codex 调用 MCP 工具时会请你批准,批准 `dsh-baton` 的工具即可;无人值守的 `codex exec` 加上 `--approve-for-me`。
71
71
 
72
+ **转交之后怎么验收**(0.6.0 起)
73
+
74
+ 不用光听 DeepSeek 自己说做完了。运行前,dsh-baton 先记下文件夹里有什么;运行结束后,它会列出到底新建、修改、删除了哪些文件,并亲自跑一遍项目自己的测试(`npm test`,或者客户端在 `checks` 里给的命令)。接着,另一个只读的 DeepSeek 子智能体会对照需求逐条检查,最后给一张表:哪条需求、做到没有、依据在哪个文件第几行。Claude 或 GPT 只看这些,没做到的一次性发回去改,只有表里写着「没做到」的地方才去翻代码。不用 `cwd` 指定的话,新对话就开在你当前工作的文件夹里。
75
+
76
+ <p align="center">
77
+ <img src="./docs/benchmarks/token-usage-2026-10/charts/evidence-chain.zh.svg" width="100%" alt="一次真实转交的证据链:用户提需求,Codex 写规格和验收命令,dsh-baton 给文件夹拍快照,DeepSeek Harness 写出 11 个文件,dsh-baton 自己列出改动并跑 npm test,Codex 主动打回一处,dsh-baton 再次验收,Codex 通过,基准测试自己的验收 13/13 通过">
78
+ </p>
79
+
80
+ ## Token 用量实测
81
+
82
+ **一句话:大活、长活交给它**。一次性做一个跨很多文件的功能、一次大重构、一整个小应用,这种时候最省:Claude Code 自己的花费少 65–81%,Codex 在 ChatGPT Plus 一个 5 小时额度里能多做 2 到 4 倍的任务。
83
+
84
+ **说实话**:中小任务,Codex 用 GPT-6.1 Sol 自己做,总价反而比转交更便宜。转交值得的场景是:你想让 Claude 或 GPT 负责规划和把关,让更便宜的模型去动手写,又不想老是撞上 5 小时额度上限。
85
+
86
+ **适合谁**:大多数人用 Claude 或 ChatGPT 走的是订阅套餐(Plus、Pro、Max 5x 之类),而 DeepSeek 是充值后按用量扣费,所以每次转交都是套餐之外多花的钱。如果你的套餐额度本来就够用,就不用折腾这套流程,省一点是一点。如果你像我一样,经常跑又长又杂、要反复修改的大任务,老是撞上额度上限,那花一点 DeepSeek 的钱,就能让你已经付费的套餐多干很多活。
87
+
88
+ <p align="center">
89
+ <img src="./docs/benchmarks/token-usage-2026-10/charts/natural-combined.zh.svg" width="100%" alt="每个任务的花费,自己做与用 0.6.0 转交:Claude Code(Opus 5.5)和 Codex(GPT-6.1 Sol、GPT-6 Astra),各用自己的刻度">
90
+ </p>
91
+
92
+ 每个任务的花费,按标价计,DeepSeek V4.1 Flash 1017 按高峰价:
93
+
94
+ | 客户端 · 模型 | 任务 | 自己做 | 转交,只算客户端 | 转交,含 DeepSeek |
95
+ |---|---|---|---|---|
96
+ | Claude Code · Opus 5.5 high | 中号 | $1.07 | $0.37(−65%) | $0.55(−49%) |
97
+ | Claude Code · Opus 5.5 high | 大号 | $2.13 | $0.41(−81%) | $0.70(−67%) |
98
+ | Codex · GPT-6.1 Sol high | 中号 | $0.17 | $0.09(−49%) | $0.29(+73%) |
99
+ | Codex · GPT-6.1 Sol high | 大号 | $0.41 | $0.10(−75%) | $0.41(−2%) |
100
+ | Codex · GPT-6 Astra high | 中号 | $0.79 | $0.45(−43%) | $0.65(−18%) |
101
+
102
+ 每个任务占 ChatGPT Plus 5 小时额度:Sol 中号 1.9% → 0.8%,Sol 大号 4.3% → 1.0%,Astra 中号 7% → 3%。
103
+
104
+ <details>
105
+ <summary>测了什么、怎么测的</summary>
106
+
107
+ - **两个任务,用普通用户的口吻提**(原文如下,可以直接点开看):
108
+ - 中号:用 Node 写一个命令行任务管理工具,零依赖,分模块,带测试。[需求原文](docs/benchmarks/token-usage-2026-10/natural/medium.md) · [验收脚本](docs/benchmarks/token-usage-2026-10/checks/natural-medium.mjs)
109
+ - 大号:一个任务管理网页应用,Node 服务器加前端页面,REST 接口、统计、测试和 README。[需求原文](docs/benchmarks/token-usage-2026-10/natural/large.md) · [验收脚本](docs/benchmarks/token-usage-2026-10/checks/natural-large.mjs)
110
+ - **转交组唯一的区别**,是在需求末尾多一句:交给 DeepSeek Harness 去做。
111
+ - 两组用同一个模型、同样的推理强度,每格跑 3 次(Astra 只跑了中号,各 1 次);每次都用同一套验收脚本检查,模型看不到这个脚本。转交组 12 次全部一次通过。
112
+ - 价格按标价算。DeepSeek 按高峰价,是最保守的算法;实际 DeepSeek 账单大约是这个数的 58%。
113
+ - 每一次运行、原始数据和脚本:[运行日志](docs/benchmarks/token-usage-2026-10/LOG.md)、[完整报告](docs/benchmarks/token-usage-2026-10.md)。
114
+
115
+ </details>
116
+
72
117
  ## 工具
73
118
 
74
119
  <p align="center">
75
- <img src="./assets/readme/zh/tools.svg" width="100%" alt="工作区:列出、新建、改名。对话:列出、读取、当前活动、改名。运行:发送、状态、等待、取消、列出最近运行">
120
+ <img src="./assets/readme/zh/tools.svg" width="100%" alt="工具列表里有 9 个:列出工作区、列出对话、读取对话、发送、状态、等待、回答、取消、dsh_call。另外 6 个不常用的经 dsh_call 调用:新建和改名工作区、对话动态、给对话改名、修改排队消息、列出运行">
76
121
  </p>
77
122
 
78
123
  <p align="center">
@@ -85,20 +130,20 @@ Codex 和 Claude 轮流往同一个 Harness 对话发消息,每条都标明是
85
130
  | 工具 | 作用 |
86
131
  |---|---|
87
132
  | `dsh_list_workspaces` | 列出 Harness 侧边栏里的工作区 |
88
- | `dsh_create_workspace` | 把一个文件夹登记为工作区(文件夹不存在会先创建);已登记的直接返回原来那个 |
133
+ | `dsh_create_workspace` | 经 `dsh_call` 调用。把一个文件夹登记为工作区(文件夹不存在会先创建);已登记的直接返回原来那个 |
89
134
  | `dsh_list_sessions` | 列出对话(最新在前),带标题、目录、是否在跑;可按目录或关键字过滤。已归档的对话不会列出 |
90
135
  | `dsh_read_session` | 读某个对话最近的消息和回复(含用到的工具名);已归档的对话会带 `archived: true` |
91
- | `dsh_send` | 发消息。带 `session_id` 续接那个对话;带 `cwd` 或 `workspace_id` 新开对话(`cwd` 还不是工作区时会先登记)。立即返回 `run_id`。Harness 里这条消息会标注「由 Claude / ChatGPT 从另一个聊天发送」。已归档的对话会被拒绝:先在 Harness 里取消归档,或新开对话。对话正忙时,`mode` 为 `followup` 会排在当前回合之后,`steer` 会插进正在跑的回合;不传时跟随 Harness 自己的设置(设置 → 繁忙时的发送行为:排队发送或插话发送),读不到设置时按 `followup`。返回里的 `mode_source` 说明用的是哪种 |
136
+ | `dsh_send` | 发消息。带 `session_id` 续接那个对话;否则新开对话,建在 `cwd` 或 `workspace_id` 指定的位置,都不带时建在当前对话所在的文件夹(`cwd` 还不是工作区时会先登记;指定的 `cwd` 在当前对话文件夹之外也可以,结果里的 `folder_note` 会提醒)。`checks`:运行结束后 dsh-baton 自己跑的测试命令(见「转交之后怎么验收」)。立即返回 `run_id`。Harness 里这条消息会标注「由 Claude / ChatGPT 从另一个聊天发送」。已归档的对话会被拒绝:先在 Harness 里取消归档,或新开对话。对话正忙时,`mode` 为 `followup` 会排在当前回合之后,`steer` 会插进正在跑的回合;不传时跟随 Harness 自己的设置(设置 → 繁忙时的发送行为:排队发送或插话发送),读不到设置时按 `followup`。返回里的 `mode_source` 说明用的是哪种 |
92
137
  | `dsh_status` | 查看运行进度:状态、最近的文字和工具调用、最终回复;回合结束但子智能体/后台任务还在跑时给出 `waiting_on`;中途有人在 Harness 界面里发消息时给出 `human_interjections`;被 Harness 重启打断的任务状态为 `interrupted` |
93
- | `dsh_wait` | 等运行结束(或者等到它停下来问人,见 `dsh_answer`):Codex 每次最多等 270 秒,其他客户端 50 秒。没做完会带 `timed_out`,只返回状态,再调用一次即可。能在后台等待的客户端改用 `dsh_send` 返回的 `wait_command`。`dsh_status` 只用来看一眼,不用来等待 |
138
+ | `dsh_wait` | 等运行结束;发消息时带了 `checks` 的,结束后会附上 `evidence`(改了哪些文件、测试结果)(或者等到它停下来问人,见 `dsh_answer`):Codex 每次最多等 270 秒,其他客户端 50 秒。没做完会带 `timed_out`,只返回状态,再调用一次即可。能在后台等待的客户端改用 `dsh_send` 返回的 `wait_command`。`dsh_status` 只用来看一眼,不用来等待 |
94
139
  | `dsh_answer` | 回答运行中途提出的问题(Harness 的 ask_user_question):每道题可以填选项编号或选项文字、自由填写,或者跳过;可以逐题回答,也可以一次答完。运行一提问,`dsh_wait` 和 `wait_command` 就会立刻返回,并带上题目(`needs_input`、`question`;Harness 那边模型推荐的选项单独放在 `harness_suggested` 里,不再写在选项文字中)。只有你说过让它自己决定时,助手才会按自己的判断回答,这时它会带上 `reason`,并告诉你选了什么、为什么;否则它会把题目转给你,开头先写它自己的建议和理由。你也可以直接在 Harness 窗口里回答,谁先答就算谁的 |
95
- | `dsh_session_activity` | 对话此刻在做什么:正在跑的回合、子智能体(任意层级)、后台任务;已归档的对话会带 `archived: true` |
96
- | `dsh_update_queued` | 按 `run_id` 修改还在忙碌对话队列里等待的消息:`edit` 改文字,`remove` 撤回,`steer` 立刻插进正在跑的回合。消息一旦进入对话就改不了了 |
140
+ | `dsh_session_activity` | 经 `dsh_call` 调用。对话此刻在做什么:正在跑的回合、子智能体(任意层级)、后台任务;已归档的对话会带 `archived: true` |
141
+ | `dsh_update_queued` | 经 `dsh_call` 调用。按 `run_id` 修改还在忙碌对话队列里等待的消息:`edit` 改文字,`remove` 撤回,`steer` 立刻插进正在跑的回合。消息一旦进入对话就改不了了 |
97
142
  | `dsh_cancel` | 按 `run_id` 或 `session_id` 停掉整个对话的工作:当前回合、子智能体、后台任务。Harness 会同时清掉队列里等待的消息,这些运行记为 `cancelled` |
98
- | `dsh_rename_session` | 给对话改名 |
99
- | `dsh_rename_workspace` | 给工作区改名(只改侧边栏标题,不动文件夹) |
100
- | `dsh_list_runs` | 列出最近的运行,可按会话筛选 |
101
- | `dsh_call` | 备用入口:按名字调用任意 `dsh_*` 工具,用于对话的工具列表还是更新前的旧版时;名字不存在时会列出所有可用工具 |
143
+ | `dsh_rename_session` | 经 `dsh_call` 调用。给对话改名 |
144
+ | `dsh_rename_workspace` | 经 `dsh_call` 调用。给工作区改名(只改侧边栏标题,不动文件夹) |
145
+ | `dsh_list_runs` | 经 `dsh_call` 调用。列出最近的运行,可按会话筛选 |
146
+ | `dsh_call` | 按名字调用任意 `dsh_*` 工具:0.6.0 起,不常用的工具(新建和重命名工作区、重命名对话、对话动态、修改排队消息、运行列表)不再出现在工具列表里,好让每次请求更短,改由这里调用;对话的工具列表还是更新前的旧版时也用它。名字不存在时会列出所有可用工具 |
102
147
 
103
148
  </details>
104
149
 
@@ -143,21 +188,24 @@ dsh-baton rename-workspace ~/code/new-app 新名字
143
188
 
144
189
  ## 安装细节
145
190
 
146
- **插件启用时做了什么。**它把 MCP 服务器复制到 `~/.dsh-baton/app` 这个固定的文件夹,再直接把 MCP 注册写进 `~/.claude.json` 和 `~/.codex/config.toml`,各留一份 `*.bak-dsh-baton` 备份。电脑上有 Node.js ≥ 22.15(在 `PATH` 里能找到)就用它启动服务器;没有的话,就用 Harness 自带的运行环境(Harness 应用本身以 Node 模式运行,`ELECTRON_RUN_AS_NODE=1`),不用另外安装任何东西。两种情况下,只装了桌面应用、`claude` / `codex` 不在 `PATH` 里也能用。每次 Harness 启动都会再检查一遍:已经正确的注册、或者指向另一份可用 dsh-baton 的注册,都不会改动。在 Harness 里输入包名后,安装源会自动切到「npm 官方源」(英文界面是 "Official npm registry"),不用另外选择,也不用重启。不想让插件自动注册,给 Harness 设置 `DSH_BATON_AUTO_REGISTER=0`。
191
+ <details>
192
+ <summary>插件做了什么、更新、卸载、从 dsh-bridge 升级、从源码安装、Windows</summary>
193
+
194
+ **插件启用时做了什么**。它把 MCP 服务器复制到 `~/.dsh-baton/app` 这个固定的文件夹,再直接把 MCP 注册写进 `~/.claude.json` 和 `~/.codex/config.toml`,各留一份 `*.bak-dsh-baton` 备份。电脑上有 Node.js ≥ 22.15(在 `PATH` 里能找到)就用它启动服务器;没有的话,就用 Harness 自带的运行环境(Harness 应用本身以 Node 模式运行,`ELECTRON_RUN_AS_NODE=1`),不用另外安装任何东西。两种情况下,只装了桌面应用、`claude` / `codex` 不在 `PATH` 里也能用。每次 Harness 启动都会再检查一遍:已经正确的注册、或者指向另一份可用 dsh-baton 的注册,都不会改动。在 Harness 里输入包名后,安装源会自动切到「npm 官方源」(英文界面是 "Official npm registry"),不用另外选择,也不用重启。不想让插件自动注册,给 Harness 设置 `DSH_BATON_AUTO_REGISTER=0`。
147
195
 
148
- **更新不用重启。**新版本装进 `~/.dsh-baton/app` 后(插件更新、`setup` 或手动复制),Claude Code 和 Codex 里已经开着的对话,下一次调用 `dsh_*` 工具时就会用上:服务器每次调用前检查这个文件夹,启动新版本,并在这次调用的结果里提示一次已经更新。正在进行的 `dsh_wait` 会先在旧版本里跑完。新版本启动失败时,旧版本继续工作,结果里会说明原因。新版本新增的工具,Claude Code 里马上就能用(服务器会通知客户端工具列表变了);Codex 一直用对话开始时的工具列表,新工具要到下一个新对话才会显示;从 0.5.9 起,旧对话里也能通过 `dsh_call` 马上用上新功能(更新提示里会写明),已有的工具也会立刻用上新代码。这个功能从 0.5.8 之后的版本开始有效;由 0.5.8 或更早版本启动的服务器,还需要最后重启一次(或在 `/mcp` 里重新连接)。
196
+ **更新不用重启**。新版本装进 `~/.dsh-baton/app` 后(插件更新、`setup` 或手动复制),Claude Code 和 Codex 里已经开着的对话,下一次调用 `dsh_*` 工具时就会用上:服务器每次调用前检查这个文件夹,启动新版本,并在这次调用的结果里提示一次已经更新。正在进行的 `dsh_wait` 会先在旧版本里跑完。新版本启动失败时,旧版本继续工作,结果里会说明原因。新版本新增的工具,Claude Code 里马上就能用(服务器会通知客户端工具列表变了);Codex 一直用对话开始时的工具列表,新工具要到下一个新对话才会显示;从 0.5.9 起,旧对话里也能通过 `dsh_call` 马上用上新功能(更新提示里会写明),已有的工具也会立刻用上新代码。这个功能从 0.5.8 之后的版本开始有效;由 0.5.8 或更早版本启动的服务器,还需要最后重启一次(或在 `/mcp` 里重新连接)。
149
197
 
150
198
  **`npx -y dsh-baton-mcp setup`** 在终端里做同样的注册:用于插件没能注册的情况,或者从 MCP 目录过来的用户。插件已经注册过时它什么都不改;插件没装的话,它会提示去添加。
151
199
 
152
- **更新。**Harness 只给官方插件提供"更新"按钮,所以要:插件 → dsh-baton-host → **卸载**,再**添加插件** → `dsh-baton-host`。新版本刚发布的一段时间里,Harness 会装上一个版本(pnpm 的 `minimumReleaseAge` 会暂缓刚发布的版本,安装弹窗里会说明);想马上用新版,输入带版本号的名字:`dsh-baton-host@0.5.0`。重新启用后,插件会把 MCP 服务器也更新到同一版本,新开的对话就会用上。万一两边版本对不上,第一次发现不一致的工具调用会提示。
200
+ **更新**。Harness 只给官方插件提供"更新"按钮,所以要:插件 → dsh-baton-host → **卸载**,再**添加插件** → `dsh-baton-host`。新版本刚发布的一段时间里,Harness 会装上一个版本(pnpm 的 `minimumReleaseAge` 会暂缓刚发布的版本,安装弹窗里会说明);想马上用新版,输入带版本号的名字:`dsh-baton-host@0.6.0`。预发布版必须写确切版本号添加,比如 `dsh-baton-host@0.6.0-next.0`:Harness 会把 `@next` 解析成一个坏掉的占位包。只输入 `dsh-baton-host` 装的是最新正式版。重新启用后,插件会把 MCP 服务器也更新到同一版本,新开的对话就会用上。万一两边版本对不上,第一次发现不一致的工具调用会提示,并写明哪一边旧、另一边的确切版本号。
153
201
 
154
- **卸载。**Harness → 插件 → dsh-baton-host → **卸载**,再运行 `npx -y dsh-baton-mcp setup --remove`。插件分不清"被卸载"和"Harness 退出",所以 MCP 注册交给这条命令删,它也会删掉 `~/.dsh-baton/app`;运行记录留在 `~/.dsh-baton`,不需要的话自己删掉。
202
+ **卸载**。Harness → 插件 → dsh-baton-host → **卸载**,再运行 `npx -y dsh-baton-mcp setup --remove`。插件分不清"被卸载"和"Harness 退出",所以 MCP 注册交给这条命令删,它也会删掉 `~/.dsh-baton/app`;运行记录留在 `~/.dsh-baton`,不需要的话自己删掉。
155
203
 
156
- **从 dsh-bridge 升级。**在 Harness 里先卸载 `dsh-bridge-host`,再添加 `dsh-baton-host`;启用后它会替换掉旧的 `dsh-bridge` MCP 注册。`DSH_BRIDGE_*` 环境变量和已有的 `~/.dsh-bridge` 文件夹暂时继续可用。工具前缀变成 `mcp__dsh-baton__…`,权限白名单里写了旧前缀的要改一下。0.4 之前通过粘贴 `~/.dsh-baton/app/host` 装的插件仍然能用,但不会跟着 npm 更新:卸载它,再按包名添加 `dsh-baton-host`。
204
+ **从 dsh-bridge 升级**。在 Harness 里先卸载 `dsh-bridge-host`,再添加 `dsh-baton-host`;启用后它会替换掉旧的 `dsh-bridge` MCP 注册。`DSH_BRIDGE_*` 环境变量和已有的 `~/.dsh-bridge` 文件夹暂时继续可用。工具前缀变成 `mcp__dsh-baton__…`,权限白名单里写了旧前缀的要改一下。0.4 之前通过粘贴 `~/.dsh-baton/app/host` 装的插件仍然能用,但不会跟着 npm 更新:卸载它,再按包名添加 `dsh-baton-host`。
157
205
 
158
206
  **从源码安装**(想跟进 `main` 或改代码):`git clone https://github.com/JialaoLiu/dsh-baton.git`,然后在 Harness 里从克隆目录的 `host` 文件夹添加插件(添加插件 → 粘贴路径)。启用后注册的就是克隆目录本身,改动在发布前就能跑起来;更新用 `git pull`。在终端里运行 `node dsh-baton/src/cli.mjs setup` 也能注册克隆目录。
159
207
 
160
- **Windows。**步骤相同;[WINDOWS.md](WINDOWS.md) 说明了不同之处(用命名管道代替套接字文件),以及插件页装不上或卸不掉时的修法。
208
+ **Windows**。步骤相同;[WINDOWS.md](WINDOWS.md) 说明了不同之处(用命名管道代替套接字文件),以及插件页装不上或卸不掉时的修法。
161
209
 
162
210
  <details>
163
211
  <summary>手动注册与 Claude 桌面应用</summary>
@@ -179,6 +227,8 @@ codex mcp add dsh-baton -- "$(command -v node)" "$PWD/dsh-baton/src/server.mjs"
179
227
 
180
228
  </details>
181
229
 
230
+ </details>
231
+
182
232
  ## 安全与限制
183
233
 
184
234
  <p align="center">
package/host/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-baton-host",
3
- "version": "0.5.9",
3
+ "version": "0.6.0",
4
4
  "description": "DeepSeek Harness plugin for dsh-baton: adding it is the whole install. Switched on, it registers the dsh-baton MCP server for Claude Code and Codex, then takes the baton over a local, owner-only control socket, runs it in a specific session through the host's own session controller, and hands the result back when the turn is done.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -20,7 +20,7 @@
20
20
  "LICENSE"
21
21
  ],
22
22
  "dependencies": {
23
- "dsh-baton-mcp": "0.5.9"
23
+ "dsh-baton-mcp": "0.6.0"
24
24
  },
25
25
  "license": "MIT",
26
26
  "dsh": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-baton-mcp",
3
- "version": "0.5.9",
3
+ "version": "0.6.0",
4
4
  "mcpName": "io.github.JialaoLiu/dsh-baton",
5
5
  "description": "Pass the baton from Claude Code, Codex or any MCP client to a local DeepSeek Harness: an MCP server that hands off tasks, continues a specific session, shows the run live in Harness and hands the result back when it is done.",
6
6
  "type": "module",
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/JialaoLiu/dsh-baton",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.5.9",
10
+ "version": "0.6.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "dsh-baton-mcp",
15
- "version": "0.5.9",
15
+ "version": "0.6.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
package/src/cli.mjs CHANGED
@@ -65,8 +65,9 @@ async function main() {
65
65
  case 'wait': {
66
66
  const st = await core.api.wait({ run_id: rest[0], timeout_s: Number(flags.timeout) || 24 * 3600 })
67
67
  print({
68
- ...(st.decided_for_user ? { hint: 'Required in your reply: you chose an answer for the user during this run, so start your reply by telling the user, in their language, what you chose and why (decided_for_user: answers and reason), then give the result. Passing on only final_text is not enough.', decided_for_user: st.decided_for_user } : {}),
68
+ ...(st.decided_for_user ? { hint: 'Required in your reply: you chose an answer for the user during this run, so start your reply by telling the user, in their language, what you chose and why (decided_for_user: answers and reason), then give the result. Passing on only final_text is not enough. ' + core.ENDED_HINT, decided_for_user: st.decided_for_user } : {}),
69
69
  run_id: st.run_id, state: st.state, session_id: st.session_id, timed_out: !!st.timed_out, error: st.error, final_text: st.final_text,
70
+ ...(st.state === 'completed' && !st.decided_for_user ? { hint: core.ENDED_HINT } : {}),
70
71
  ...(st.needs_input ? { needs_input: st.needs_input, question: st.question, ...(st.question ? { relay: core.relayText(st.question) } : {}), hint: st.question ? 'The run asks the user something. If the user allowed you to decide, choose by your own judgment and answer with dsh_answer (or dsh-baton answer), passing reason, and tell the user what you chose and why. Otherwise show the user relay, written in the user\'s language, with its first line filled in with your own pick and a reason from your own judgment of the question and the task (harness_suggested is only the asker\'s pick, never a reason by itself), and ask the user first. Then wait again.' : 'The run waits for a person in the Harness UI. Tell the user, then wait again.' } : {}) })
71
72
  process.exitCode = st.state === 'completed' ? 0 : st.needs_input ? 3 : 1
72
73
  return
package/src/core.mjs CHANGED
@@ -9,6 +9,7 @@ import net from 'node:net'
9
9
  import { spawn, execFileSync } from 'node:child_process'
10
10
  import { fileURLToPath } from 'node:url'
11
11
  import { setting, batonHome, socketEndpoint, hostEndpoints } from '../host/lib/socket.js'
12
+ import * as evidence from './evidence.mjs'
12
13
 
13
14
  const HERE = path.dirname(fileURLToPath(import.meta.url))
14
15
  export const ROOT = path.resolve(HERE, '..')
@@ -167,11 +168,18 @@ export async function hostAvailable() {
167
168
  export const hostVersionSeen = () => hostVersion
168
169
 
169
170
  // The plugin and the MCP server update separately (Harness and npx), so they can drift.
170
- // A different major.minor gets a note asking to update the older side; patch releases mix.
171
+ // A different major.minor gets a note asking to update the older side to the newer side's
172
+ // exact version; patch releases mix. Not @latest: on a pre-release that would go back a version
173
+ // (and Harness resolves a tag such as @next to a broken tarball, so the plugin needs the number).
171
174
  const minorOf = (v) => String(v).split('.').slice(0, 2).join('.')
175
+ const parts = (v) => String(v).split('-')[0].split('.').map(Number)
176
+ const older = (a, b) => { const x = parts(a), y = parts(b); for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i]; return false }
172
177
  export function versionWarning(plugin = hostVersion, server = VERSION) {
173
178
  if (!plugin || minorOf(plugin) === minorOf(server)) return null
174
- return `The Harness plugin dsh-baton-host is ${plugin} but this MCP server is ${server}. Update the older one: the server with \`npx -y dsh-baton-mcp@latest setup\`, the plugin in Harness (Plugins → dsh-baton-host → Uninstall, then Add plugin → dsh-baton-host).`
179
+ const fix = older(plugin, server)
180
+ ? `Update the plugin in Harness: Plugins → dsh-baton-host → Uninstall, then Add plugin → dsh-baton-host@${server}.`
181
+ : `Update the server: \`npx -y dsh-baton-mcp@${plugin} setup\`.`
182
+ return `The Harness plugin dsh-baton-host is ${plugin} but this MCP server is ${server}. ${fix}`
175
183
  }
176
184
 
177
185
  // Who a relayed message is from, shown on it in the Harness chat: DSH_BATON_SENDER if set,
@@ -526,6 +534,12 @@ async function useHost() {
526
534
  return up
527
535
  }
528
536
 
537
+ // Said with a finished run's reply: review from the evidence dsh-baton produced, read code only
538
+ // where it points, and send every fix back in one message.
539
+ export const REVIEW_REQUEST = '\n\n---\nWhen the work is done, start a read-only sub-agent to check it against every requirement above, then end your final reply with its table: requirement | met (yes / no / unclear) | evidence (file:line or test name). Fix anything marked no before replying.'
540
+
541
+ export const ENDED_HINT = 'evidence is produced by dsh-baton (files changed, checks it ran), not by the Harness reply: trust it over final_text. If a check failed, a requirement has no check, or a file the task needs is missing from evidence.changes, send all fixes back in one message to the same session, quoting the failing output; do not reproduce failures or write test scripts yourself. If every check passed and final_text ends with a review table whose rows are all met, accept it without reading code; read code only for rows marked no or unclear.'
542
+
529
543
  // The asking model marks its favourite option in the label ("(Recommended)", "(推荐)").
530
544
  // Clients relaying the question tended to repeat that mark as their own advice, so the
531
545
  // label is cleaned and the mark moves to harness_suggested: what the asker suggests, kept
@@ -586,6 +600,23 @@ const shaped = (st) => {
586
600
  return Array.isArray(st.answers) ? { ...out, answers: st.answers.map((a) => (Array.isArray(a?.selected) ? { ...a, selected: a.selected.map(cleanLabel) } : a)) } : out
587
601
  }
588
602
 
603
+ // The folder a run works in: given with dsh_send, or that of its workspace or session.
604
+ async function runFolder(a, st) {
605
+ if (a.cwd) return path.resolve(a.cwd.replace(/^~(?=$|[\\/])/, os.homedir()))
606
+ if (st?.cwd) return st.cwd
607
+ try {
608
+ if (a.workspace_id) return (await api.workspaces()).find((w) => w.id === a.workspace_id)?.path
609
+ const sid = a.session_id ?? st?.session_id
610
+ if (sid) return (await api.list_sessions({ query: sid, limit: 5 })).find((x) => x.session_id === sid)?.cwd
611
+ } catch { /* no folder, no evidence */ }
612
+ return undefined
613
+ }
614
+ const withEvidence = (st) => {
615
+ if (!st || !ENDED.has(st.state) || !st.run_id) return st
616
+ const ev = evidence.collect(config.batonHome, st.run_id)
617
+ return ev ? { ...st, evidence: ev } : st
618
+ }
619
+
589
620
  const tag = (backend, value) => (Array.isArray(value) ? value.map((v) => ({ ...v, backend })) : { ...value, backend })
590
621
 
591
622
  export const api = {
@@ -603,16 +634,22 @@ export const api = {
603
634
  async read_session(a) {
604
635
  return (await useHost()) ? tag('host', await hostCall('read_session', a)) : tag('headless', readSession(a))
605
636
  },
606
- async send(a) {
607
- if (await useHost()) return tag('host', await hostCall('send', a))
608
- return tag('headless', await waitForSessionId(startRun(a)))
637
+ // checks stay here: dsh-baton runs them itself when the run ends (see evidence.mjs).
638
+ // A hand-over (a message with checks) also asks Harness to have a read-only sub-agent
639
+ // review the work against the spec and end with a table, so the client can decide from
640
+ // that table and the evidence instead of reading the code itself.
641
+ async send({ checks, ...a }) {
642
+ if (Array.isArray(checks) && checks.length && typeof a.message === 'string') a = { ...a, message: `${a.message}${REVIEW_REQUEST}` }
643
+ const st = (await useHost()) ? tag('host', await hostCall('send', a)) : tag('headless', await waitForSessionId(startRun(a)))
644
+ evidence.prepare(config.batonHome, { run_id: st.run_id, cwd: await runFolder(a, st), checks })
645
+ return st
609
646
  },
610
647
  async status({ run_id, tail = 20 }) {
611
- return shaped(isHostRun(run_id) ? tag('host', await hostCall('status', { run_id, tail })) : tag('headless', runStatus(run_id, { tail })))
648
+ return withEvidence(shaped(isHostRun(run_id) ? tag('host', await hostCall('status', { run_id, tail })) : tag('headless', runStatus(run_id, { tail }))))
612
649
  },
613
650
  async wait({ run_id, timeout_s = 300 }) {
614
- if (isHostRun(run_id)) return shaped(tag('host', await hostCall('wait', { run_id, timeout_s }, { timeoutMs: (timeout_s + 15) * 1000 })))
615
- return tag('headless', await waitRun(run_id, { timeout_s }))
651
+ if (isHostRun(run_id)) return withEvidence(shaped(tag('host', await hostCall('wait', { run_id, timeout_s }, { timeoutMs: (timeout_s + 15) * 1000 }))))
652
+ return withEvidence(tag('headless', await waitRun(run_id, { timeout_s })))
616
653
  },
617
654
  async cancel({ run_id, session_id }) {
618
655
  if (run_id && !isHostRun(run_id)) return tag('headless', cancelRun(run_id))
@@ -0,0 +1,101 @@
1
+ // Evidence for a finished run that does not come from the Harness model: which files the run
2
+ // created, changed or deleted (a snapshot of the folder taken at dsh_send, compared when the
3
+ // run ends), and the result of check commands the client gave with dsh_send, run here by
4
+ // dsh-baton in that folder. The Harness reply can say "all tests pass"; this says whether
5
+ // they do. Kept in a file per run, because the client may wait from another process.
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+ import crypto from 'node:crypto'
9
+ import { spawnSync } from 'node:child_process'
10
+
11
+ const SKIP = new Set(['node_modules', '.git', '.hg', '.svn', 'dist', 'build', '.next', '.cache', 'coverage', '__pycache__', '.venv', 'venv', 'target'])
12
+ const MAX_FILES = 3000
13
+ const MAX_TEXT = 512 * 1024
14
+ const CHECK_TIMEOUT_MS = 120_000
15
+ const CHECKS_TOTAL_MS = 180_000
16
+ const TAIL_LINES = 15
17
+
18
+ const fileOf = (home, runId) => path.join(home, 'evidence', `${String(runId).replace(/[^\w.-]/g, '_')}.json`)
19
+
20
+ // path -> { hash, lines } for the files under dir (bounded; big or binary files keep only a hash).
21
+ function snapshot(dir) {
22
+ const files = {}
23
+ let count = 0, truncated = false
24
+ const walk = (d) => {
25
+ let entries = []
26
+ try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch { return }
27
+ for (const e of entries) {
28
+ if (count >= MAX_FILES) { truncated = true; return }
29
+ const p = path.join(d, e.name)
30
+ if (e.isDirectory()) { if (!SKIP.has(e.name)) walk(p); continue }
31
+ if (!e.isFile()) continue
32
+ count++
33
+ try {
34
+ const buf = fs.readFileSync(p)
35
+ const text = buf.length <= MAX_TEXT && !buf.includes(0)
36
+ files[path.relative(dir, p).split(path.sep).join('/')] = {
37
+ hash: crypto.createHash('sha1').update(buf).digest('hex'),
38
+ lines: text ? (buf.length ? buf.toString('utf8').split('\n').length - (buf.at(-1) === 10 ? 1 : 0) : 0) : null,
39
+ }
40
+ } catch { /* unreadable: left out */ }
41
+ }
42
+ }
43
+ walk(dir)
44
+ return { files, truncated }
45
+ }
46
+
47
+ export function prepare(home, { run_id, cwd, checks }) {
48
+ if (!run_id || !cwd || !fs.existsSync(cwd)) return
49
+ try {
50
+ fs.mkdirSync(path.join(home, 'evidence'), { recursive: true, mode: 0o700 })
51
+ const list = Array.isArray(checks) ? checks.filter((c) => typeof c === 'string' && c.trim()).slice(0, 10) : []
52
+ fs.writeFileSync(fileOf(home, run_id), JSON.stringify({ cwd, checks: list, before: snapshot(cwd), at: Date.now() }), { mode: 0o600 })
53
+ // Keep the folder small: the 50 newest runs.
54
+ const dir = path.join(home, 'evidence')
55
+ const old = fs.readdirSync(dir).map((n) => ({ n, t: fs.statSync(path.join(dir, n)).mtimeMs })).sort((a, b) => b.t - a.t).slice(50)
56
+ for (const { n } of old) fs.rmSync(path.join(dir, n), { force: true })
57
+ } catch { /* evidence is extra; sending never fails over it */ }
58
+ }
59
+
60
+ function runCheck(command, cwd, timeout) {
61
+ const t0 = Date.now()
62
+ const r = spawnSync(command, { cwd, shell: true, encoding: 'utf8', timeout, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, CI: '1' } })
63
+ const out = `${r.stdout ?? ''}${r.stderr ? `\n${r.stderr}` : ''}`.trimEnd().split('\n')
64
+ return {
65
+ command,
66
+ exit_code: r.status,
67
+ passed: r.status === 0,
68
+ ...(r.error?.code === 'ETIMEDOUT' || r.signal ? { timed_out: true } : {}),
69
+ seconds: Math.round((Date.now() - t0) / 100) / 10,
70
+ output_tail: out.slice(-TAIL_LINES).join('\n'),
71
+ }
72
+ }
73
+
74
+ // The evidence for a run that ended, computed once and then read back.
75
+ export function collect(home, runId) {
76
+ const file = fileOf(home, runId)
77
+ let rec
78
+ try { rec = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null }
79
+ if (rec.result) return rec.result
80
+ const after = snapshot(rec.cwd)
81
+ const before = rec.before.files
82
+ const created = [], modified = [], deleted = []
83
+ for (const [p, f] of Object.entries(after.files)) {
84
+ if (!before[p]) created.push({ path: p, lines: f.lines })
85
+ else if (before[p].hash !== f.hash) modified.push({ path: p, lines_before: before[p].lines, lines_after: f.lines })
86
+ }
87
+ for (const p of Object.keys(before)) if (!after.files[p]) deleted.push(p)
88
+ const checks = []
89
+ const deadline = Date.now() + CHECKS_TOTAL_MS
90
+ for (const c of rec.checks) {
91
+ const left = deadline - Date.now()
92
+ checks.push(left > 1000 ? runCheck(c, rec.cwd, Math.min(CHECK_TIMEOUT_MS, left)) : { command: c, skipped: 'out of time' })
93
+ }
94
+ const result = {
95
+ source: 'dsh-baton, not the Harness model',
96
+ changes: { created, modified, deleted, ...(after.truncated || rec.before.truncated ? { truncated: true } : {}) },
97
+ ...(checks.length ? { checks, all_checks_passed: checks.every((c) => c.passed) } : {}),
98
+ }
99
+ try { fs.writeFileSync(file, JSON.stringify({ ...rec, before: undefined, result }), { mode: 0o600 }) } catch { /* recomputed next time */ }
100
+ return result
101
+ }
package/src/folder.mjs ADDED
@@ -0,0 +1,70 @@
1
+ // The folder the client's conversation works in, so a new Harness session starts next to it
2
+ // instead of in a folder the model guessed (an old workspace from dsh_list_workspaces, or
3
+ // ~/.codex). Codex sends its thread id with every tool call; that thread's rollout file starts
4
+ // with a session_meta line that records the thread's cwd. The MCP server's own folder is not
5
+ // enough there: the Codex app starts it in one folder for all of its projects. Other clients
6
+ // (Claude Code) start the server in the project folder.
7
+ import fs from 'node:fs'
8
+ import os from 'node:os'
9
+ import path from 'node:path'
10
+
11
+ const codexHome = () => process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
12
+ const DAYS_SEARCHED = 120
13
+ const found = new Map()
14
+
15
+ const newestFirst = (dir) => { try { return fs.readdirSync(dir).filter((n) => /^\d+$/.test(n)).sort().reverse() } catch { return [] } }
16
+
17
+ // sessions/<yyyy>/<mm>/<dd>/rollout-<time>-<thread id>.jsonl, newest days first.
18
+ function codexThreadCwd(threadId) {
19
+ if (!/^[\w-]+$/.test(threadId ?? '')) return undefined
20
+ if (found.has(threadId)) return found.get(threadId)
21
+ const root = path.join(codexHome(), 'sessions')
22
+ let days = 0
23
+ for (const y of newestFirst(root)) for (const m of newestFirst(path.join(root, y))) for (const d of newestFirst(path.join(root, y, m))) {
24
+ if (++days > DAYS_SEARCHED) return undefined
25
+ const dir = path.join(root, y, m, d)
26
+ const file = fs.readdirSync(dir).find((n) => n.endsWith(`-${threadId}.jsonl`))
27
+ if (!file) continue
28
+ try {
29
+ const fd = fs.openSync(path.join(dir, file), 'r')
30
+ const buf = Buffer.alloc(64 * 1024)
31
+ const n = fs.readSync(fd, buf, 0, buf.length, 0)
32
+ fs.closeSync(fd)
33
+ const cwd = JSON.parse(buf.subarray(0, n).toString('utf8').split('\n')[0]).payload?.cwd
34
+ if (typeof cwd === 'string' && cwd) { found.set(threadId, cwd); return cwd }
35
+ } catch { /* unreadable: fall back */ }
36
+ return undefined
37
+ }
38
+ return undefined
39
+ }
40
+
41
+ // The server's own folder counts only when it looks like a project: not the home folder,
42
+ // not the file-system root and not a client's own settings folder.
43
+ function serverCwd() {
44
+ const cwd = process.cwd()
45
+ const home = os.homedir()
46
+ const real = (p) => { try { return fs.realpathSync(p) } catch { return p } }
47
+ const inside = (dir) => { const r = path.relative(real(dir), real(cwd)); return r === '' || (!r.startsWith('..') && !path.isAbsolute(r)) }
48
+ if (cwd === home || cwd === path.parse(cwd).root) return undefined
49
+ if (inside(codexHome()) || inside(path.join(home, '.claude'))) return undefined
50
+ return cwd
51
+ }
52
+
53
+ export function conversationFolder(meta, isCodex) {
54
+ if (isCodex) {
55
+ const threadId = meta?.['x-codex-turn-metadata']?.thread_id ?? meta?.threadId
56
+ const cwd = codexThreadCwd(threadId)
57
+ if (cwd) return cwd
58
+ }
59
+ return serverCwd()
60
+ }
61
+
62
+ const norm = (p) => {
63
+ const r = path.resolve(p.replace(/^~(?=$|[\\/])/, os.homedir()))
64
+ return process.platform === 'win32' || process.platform === 'darwin' ? r.toLowerCase() : r
65
+ }
66
+ // True when folder is the conversation's folder or inside it.
67
+ export function within(folder, conversation) {
68
+ const r = path.relative(norm(conversation), norm(folder))
69
+ return r === '' || (!r.startsWith('..') && !path.isAbsolute(r))
70
+ }
package/src/mcp.mjs CHANGED
@@ -4,6 +4,7 @@ import readline from 'node:readline'
4
4
  import path from 'node:path'
5
5
  import * as core from './core.mjs'
6
6
  import { stableNodePath } from './setup.mjs'
7
+ import { conversationFolder, within } from './folder.mjs'
7
8
 
8
9
  const SERVER = { name: 'dsh-baton', version: core.VERSION }
9
10
  const PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05']
@@ -30,7 +31,7 @@ const codeModeYield = () => {
30
31
  const ms = (waitSeconds() + 20) * 1000
31
32
  return `Required when you call tools from a script cell ("Script running with cell ID …"): the script that calls dsh_wait must start with the line // @exec: {"yield_time_ms": ${ms}}, never a shorter value; if the cell still yields, resume it with wait and yield_time_ms ${ms} (or the largest value wait accepts), never a short value such as 30000 or 55000.`
32
33
  }
33
- const NO_POLL = 'While waiting, make no other calls: no dsh_status, dsh_session_activity or dsh_read_session, and do not list or read the target folder or files to check progress. The final dsh_wait result carries the reply of the run; check files only after the run ends, if needed.'
34
+ const NO_POLL = 'While waiting, make no other calls (no dsh_status, dsh_session_activity or dsh_read_session) and do not list or read the target folder to check progress.'
34
35
  const ARCHIVED = 'Sessions not in dsh_list_sessions are archived or gone: do not look for them on disk.'
35
36
  // What to do when a run stops to ask a person (dsh_wait, wait_command and the instructions say it).
36
37
  const ASKED = 'If the user told you to decide such questions yourself, choose by your own judgment of the question and the task, call dsh_answer with reason (one sentence on why), then tell the user what you chose and why. Otherwise relay the questions to the user and start with your own advice, on a line of its own: "My recommendation: <option>, because <reason>", the reason drawn from your own judgment of the question and the task (harness_suggested only shows what the asking model would pick; it is never a reason by itself, and your pick may differ). Then list the options, wait for the user\'s decision and submit it with dsh_answer. Never answer on the user\'s behalf without that permission.'
@@ -49,6 +50,17 @@ const nodeMode = process.versions.electron ? 'ELECTRON_RUN_AS_NODE=1 ' : ''
49
50
  const waitCommand = (runId) =>
50
51
  /^[\w.-]+$/.test(runId ?? '') ? `${nodeMode}${quote(stableNodePath())} ${quote(path.join(core.ROOT, 'src', 'cli.mjs'))} wait ${runId}` : undefined
51
52
 
53
+ // The result of a run that ended, kept short: the client already knows what it sent, and a
54
+ // completed run's event log is only the work behind final_text (dsh_status still has it).
55
+ // A run that did not complete keeps its last events, to show where it stopped.
56
+ function ended({ recent_events, message_preview, backend, ...st }) {
57
+ const out = { ...st, ...(st.state !== 'completed' && recent_events?.length ? { recent_events: recent_events.slice(-5) } : {}) }
58
+ // decided_for_user first, so a client that skims to final_text still meets it.
59
+ return st.decided_for_user
60
+ ? { hint: `${DECIDED_HINT} ${core.ENDED_HINT}`, decided_for_user: st.decided_for_user, ...out }
61
+ : { ...out, hint: core.ENDED_HINT }
62
+ }
63
+
52
64
  // Wait in short chunks, reporting progress between them (clients that support it can
53
65
  // show it or reset their timer). An unfinished wait returns only the state, not the run's
54
66
  // recent events, so the client has nothing to summarise and simply waits again.
@@ -61,8 +73,7 @@ async function waitInChunks({ run_id }, progress) {
61
73
  const t0 = Date.now()
62
74
  const st = await core.api.wait({ run_id, timeout_s: Math.max(1, Math.min(WAIT_CHUNK_S, left)) })
63
75
  if (st.needs_input) return { ...st, ...(st.question ? { relay: core.relayText(st.question) } : {}), hint: NEEDS_INPUT_HINT(st) }
64
- // First in the result, so a client that skims to final_text still meets it.
65
- if (!st.timed_out) return st.decided_for_user ? { hint: DECIDED_HINT, decided_for_user: st.decided_for_user, ...st } : st
76
+ if (!st.timed_out) return ended(st)
66
77
  // Never spin: a chunk that came back early still takes a moment before the next one.
67
78
  if (Date.now() - t0 < 500) await new Promise((r) => setTimeout(r, 500))
68
79
  if (Date.now() >= deadline) {
@@ -78,6 +89,11 @@ async function waitInChunks({ run_id }, progress) {
78
89
  }
79
90
  }
80
91
 
92
+ // Long event texts are cut in results: the client rarely needs them whole (dsh_read_session has them).
93
+ const clip = (st) => (Array.isArray(st?.recent_events)
94
+ ? { ...st, recent_events: st.recent_events.map((e) => Object.fromEntries(Object.entries(e).map(([k, v]) => [k, typeof v === 'string' && v.length > 300 ? `${v.slice(0, 300)}…` : v]))) }
95
+ : st)
96
+
81
97
  // MCP tool annotations. Every tool only talks to the local Harness (no open world).
82
98
  const hints = (readOnlyHint, destructiveHint, idempotentHint) => ({ readOnlyHint, destructiveHint, idempotentHint, openWorldHint: false })
83
99
  const READ = hints(true, false, true)
@@ -88,38 +104,46 @@ const TOOLS = [
88
104
  {
89
105
  name: 'dsh_list_workspaces',
90
106
  annotations: annotations('List workspaces'),
91
- description: 'List the workspaces (project folders) in the DeepSeek Harness sidebar; dsh_list_sessions lists the sessions inside them and dsh_create_workspace adds one. Returns id, path, title and session_count for each; the id is the workspace_id for dsh_send and dsh_rename_workspace. Read-only. Needs the dsh-baton-host plugin.',
107
+ description: 'List the DeepSeek Harness workspaces (project folders): id, path, title and session_count. The id is the workspace_id for dsh_send. Read-only. Needs the dsh-baton-host plugin.',
92
108
  inputSchema: { type: 'object', properties: {} },
93
109
  run: () => core.api.workspaces(),
94
110
  },
95
111
  {
96
112
  name: 'dsh_create_workspace',
113
+ hidden: true, // not in tools/list; reachable directly or through dsh_call
97
114
  annotations: annotations('Create workspace', EDIT),
98
- description: 'Add a folder to the DeepSeek Harness sidebar as a workspace without starting a session; dsh_list_workspaces only lists existing ones, and dsh_send with a cwd registers the folder by itself. Creates the folder unless create_dir is false. Idempotent: an already registered folder returns its existing workspace. Returns id, path, title and created (false when it already existed). Needs the dsh-baton-host plugin.',
115
+ description: 'Register a folder as a Harness workspace without starting a session (dsh_send with cwd does this too). Creates the folder unless create_dir is false; an already registered folder returns its workspace. Returns id, path, title and created. Needs the dsh-baton-host plugin.',
99
116
  inputSchema: { type: 'object', required: ['path'], properties: { path: str('Absolute folder path (~ allowed)'), title: str('Sidebar title; defaults to the folder name'), create_dir: { type: 'boolean', description: 'Create the folder if missing', default: true } } },
100
117
  run: (a) => core.api.create_workspace(a),
101
118
  },
102
119
  {
103
120
  name: 'dsh_list_sessions',
104
121
  annotations: annotations('List sessions'),
105
- description: `List saved DeepSeek Harness sessions (conversations), newest first, to find a session_id; dsh_list_runs lists only the messages sent through dsh_send. Returns session_id, title, cwd, updated_at, running and bridge_runs (active dsh-baton run ids) for each. There is no paging: the list stops at limit, so to see older sessions raise limit or narrow it with cwd or query. Read-only. ${ARCHIVED}`,
122
+ description: `List Harness sessions, newest first, to find a session_id: session_id, title, cwd, updated_at, running and bridge_runs. No paging: raise limit or narrow it with cwd or query. Read-only. ${ARCHIVED}`,
106
123
  inputSchema: { type: 'object', properties: { cwd: str('Only sessions of this workspace folder'), query: str('Case-insensitive text to match in title, folder or session id'), limit: int('Max sessions returned', 20), include_subagents: { type: 'boolean', description: 'Also list sub-agent sessions', default: false } } },
107
124
  run: (a) => core.api.list_sessions(a),
108
125
  },
109
126
  {
110
127
  name: 'dsh_read_session',
111
128
  annotations: annotations('Read session'),
112
- description: 'Read what was said in one session (its past messages), to catch up before continuing it; dsh_session_activity shows what it is doing now and dsh_status follows one run. Returns title, cwd, total_messages and the last messages, each with role (user or assistant), time, text and the names of tools the assistant called (tool output is not included). Read-only.',
129
+ description: 'Read the last messages of a session (role, time, text and the names of tools called; no tool output), to catch up before continuing it. Read-only.',
113
130
  inputSchema: { type: 'object', required: ['session_id'], properties: { session_id: str('Session id from dsh_list_sessions, e.g. session-…'), last: int('How many of the most recent messages to return; total_messages tells how many there are', 10) } },
114
131
  run: (a) => core.api.read_session(a),
115
132
  },
116
133
  {
117
134
  name: 'dsh_send',
118
135
  annotations: annotations('Send message', hints(false, false, false)),
119
- description: 'Send a message to a DeepSeek Harness session and return at once with a run_id, without waiting for the reply; dsh_wait then waits for it. With session_id it continues that existing session (same context, no new session); with cwd or workspace_id it starts a new session there (a cwd that is not a workspace yet is registered in the sidebar first). The turn runs in the background. If the session is already working, mode followup queues the message after the current turn and steer adds it to the running turn; each message gets its own run_id. Leave mode out to do what the user set in Harness (Settings, sending while busy: queue or steer); followup when that setting cannot be read. The result says which mode was used and why (mode_source: explicit, harness or default). A queued message can still be edited, removed or turned into a steer with dsh_update_queued. The message is delivered verbatim as a user message, with no prefix or label added: write it in the language the user works in, self-contained (Harness does not see this conversation), and say who is asking only if it matters. Then wait for the reply with dsh_wait (or the wait_command it returns, where offered).',
120
- inputSchema: { type: 'object', required: ['message'], properties: { message: str('The task or reply to send'), session_id: str('Existing session to continue'), cwd: str('Workspace folder for a new session'), workspace_id: str('Harness workspace for a new session (from dsh_list_workspaces)'), mode: { type: 'string', enum: ['followup', 'steer'], description: "followup queues after the current turn; steer injects into a running turn. Omit to follow Harness's busy-send setting" } } },
121
- run: async (a) => {
122
- const st = await core.api.send({ ...a, sender: core.senderOf(core.setting('SENDER') || clientName) })
136
+ description: 'Send a message to a Harness session and return at once with a run_id; then wait for the reply with dsh_wait (or the wait_command it returns, where offered). session_id continues that session; without it a new session starts in cwd or workspace_id, or, with neither, in the folder this conversation works in (a new cwd is registered first). If the session is busy, mode followup queues the message and steer adds it to the running turn; leave mode out to follow the Harness setting. The message arrives verbatim as a user message (with checks, a short request for a sub-agent review table is appended): make it self-contained (Harness does not see this conversation), in the user\'s language.',
137
+ inputSchema: { type: 'object', required: ['message'], properties: { message: str('The task or reply to send'), session_id: str('Existing session to continue'), cwd: str('Workspace folder for a new session; leave out to use the folder this conversation works in'), workspace_id: str('Harness workspace for a new session (from dsh_list_workspaces)'), mode: { type: 'string', enum: ['followup', 'steer'], description: "followup queues after the current turn; steer injects into a running turn. Omit to follow Harness's busy-send setting" }, checks: { type: 'array', items: { type: 'string' }, description: 'The project\'s own test commands, e.g. npm test (ask Harness in the message to write tests for each requirement). Do not write check scripts yourself. dsh-baton runs them in the folder when the run ends and returns exit codes and the last output lines as evidence.' } } },
138
+ run: async (a, { meta } = {}) => {
139
+ // A new session goes to this conversation's folder unless the call names another one;
140
+ // a different folder is allowed but said back, so a wrong guess does not go unnoticed.
141
+ const here = a.session_id ? undefined : conversationFolder(meta, isCodex())
142
+ const args = here && !a.cwd && !a.workspace_id ? { ...a, cwd: here } : a
143
+ const folder = here && a.cwd && !within(a.cwd, here)
144
+ ? { folder_note: `This conversation works in ${here}; the new session was started in ${a.cwd} as asked. If that was not intended, cancel it and send again without cwd.` }
145
+ : args !== a ? { cwd: here } : {}
146
+ const st = { ...(await core.api.send({ ...args, sender: core.senderOf(core.setting('SENDER') || clientName) })), ...folder }
123
147
  // Codex has no wake-on-exit for shell commands: offered wait_command, it runs it in a
124
148
  // terminal and polls that, so it gets dsh_wait only.
125
149
  if (isCodex()) return { ...st, hint: `Run started. Call dsh_wait with this run_id; it returns when the run ends or stops to ask the user something. ${NO_POLL} ${codeModeYield()}` }
@@ -133,21 +157,21 @@ const TOOLS = [
133
157
  {
134
158
  name: 'dsh_status',
135
159
  annotations: annotations('Run status'),
136
- description: 'Look once at one run sent with dsh_send, without waiting; dsh_wait waits for it to end and dsh_session_activity covers the whole session. Returns state (queued/running/waiting/completed/failed/cancelled, or interrupted when a Harness restart cut it short), session_id, recent events (text, tool calls), final reply, waiting_on when the turn is over but sub-agents or background jobs are still working, and human_interjections when someone typed in the Harness UI during the run (its result then reflects that input too). A one-off look, never a way to wait: to wait for the reply use dsh_wait, and if dsh_wait timed out, call dsh_wait again.',
137
- inputSchema: { type: 'object', required: ['run_id'], properties: { run_id: str('Run id from dsh_send'), tail: int('How many recent events', 20) } },
138
- run: (a) => core.api.status(a),
160
+ description: 'Look once at a run without waiting: state (queued/running/waiting/completed/failed/cancelled/interrupted), recent events, final reply, waiting_on (sub-agents or jobs still working) and human_interjections. Never a way to wait: use dsh_wait.',
161
+ inputSchema: { type: 'object', required: ['run_id'], properties: { run_id: str('Run id from dsh_send'), tail: int('How many recent events', 5) } },
162
+ run: async (a) => clip(await core.api.status({ tail: 5, ...a })),
139
163
  },
140
164
  {
141
165
  name: 'dsh_wait',
142
166
  annotations: annotations('Wait for run'),
143
- description: () => `Wait for a run sent with dsh_send to end and return its final status and reply; dsh_status only looks once.${isCodex() ? '' : ' Prefer the background wait_command from dsh_send when your client can run background shell commands and wake you when they exit: it costs no turns while waiting.'} It also returns at once when the run stops to ask a person: needs_input is answer (the questions are in question; show the user relay with its first line filled in with your own pick and reason, harness_suggested being only the asker's pick, then reply with dsh_answer) or harness-ui (an approval only a person can give in Harness). Each call waits up to ${waitSeconds()} s (set by the server to stay under this client's tool timeout); if the run is still going it returns timed_out: true with just the state: then call dsh_wait again with the same run_id, without summarising progress.${isCodex() ? ` ${NO_POLL} ${codeModeYield()}` : ''} Sends progress notifications while waiting when the client asks for them.`,
167
+ description: () => `Wait for a run sent with dsh_send to end and return its state and final reply.${isCodex() ? '' : ' Prefer the background wait_command from dsh_send if your client wakes you when a background command exits: it costs no turns.'} It also returns at once when the run stops to ask a person: needs_input answer (question and relay; reply with dsh_answer) or harness-ui (an approval only a person can give in Harness). Each call waits up to ${waitSeconds()} s; if it returns timed_out: true, call dsh_wait again with the same run_id, without a progress note.${isCodex() ? ` ${NO_POLL} ${codeModeYield()}` : ''}`,
144
168
  inputSchema: { type: 'object', required: ['run_id'], properties: { run_id: str('Run id from dsh_send') } },
145
169
  run: (a, { progress }) => waitInChunks(a, progress),
146
170
  },
147
171
  {
148
172
  name: 'dsh_answer',
149
173
  annotations: annotations('Answer question', hints(false, false, false)),
150
- description: `Answer the questions a run sent with dsh_send is waiting on (Harness's ask_user_question, shown in dsh_wait / dsh_status as question); dsh_send sends a new message instead, and dsh_update_queued changes one still in the queue. Per question: choose option numbers or labels, text for a free-text answer (with a single choice it replaces the option), or skip. When the user let you decide, pass reason. Several questions can be answered in one call or one at a time: the answer reaches Harness once every question has one (submitted: true; until then remaining lists the rest). The run then continues; wait for it again. Whoever answers first wins: if the user answers in the Harness window, this call reports there is no open question. ${ASKED}`,
174
+ description: 'Answer the questions a waiting run asked (question in dsh_wait or dsh_status). Per question: option numbers or labels in choose, text for a free answer, or skip. Pass reason when the user let you decide. The answer reaches Harness once every question has one (submitted: true; remaining lists the rest); then wait for the run again. If the user already answered in Harness, it reports that there is no open question.',
151
175
  inputSchema: {
152
176
  type: 'object', required: ['run_id', 'answers'], properties: {
153
177
  run_id: str('Run id that is waiting (needs_input: answer)'),
@@ -176,43 +200,48 @@ const TOOLS = [
176
200
  },
177
201
  {
178
202
  name: 'dsh_rename_session',
203
+ hidden: true, // not in tools/list; reachable directly or through dsh_call
179
204
  annotations: annotations('Rename session', EDIT),
180
- description: 'Set the title of one DeepSeek Harness session (a conversation); dsh_rename_workspace renames a workspace (project folder) instead. Only the title changes; the conversation and any running turn are untouched. Can be called again at any time to change it back or to something else. Returns session_id and the saved title.',
205
+ description: 'Set the title of a Harness session; nothing else changes. Returns session_id and title.',
181
206
  inputSchema: { type: 'object', required: ['session_id', 'title'], properties: { session_id: str('Session id from dsh_list_sessions'), title: str('New title; surrounding spaces are trimmed and it must not be empty') } },
182
207
  run: (a) => core.api.rename_session(a),
183
208
  },
184
209
  {
185
210
  name: 'dsh_rename_workspace',
211
+ hidden: true, // not in tools/list; reachable directly or through dsh_call
186
212
  annotations: annotations('Rename workspace', EDIT),
187
- description: 'Set the sidebar title of a DeepSeek Harness workspace (project folder); dsh_rename_session renames a single session instead. The folder on disk is not moved or renamed. Identify it by workspace_id or path. Can be called again to change it back. Returns id, path and the saved title.',
213
+ description: 'Set the sidebar title of a workspace, by workspace_id or path; the folder is not renamed. Returns id, path and title.',
188
214
  inputSchema: { type: 'object', required: ['title'], properties: { workspace_id: str('Workspace id from dsh_list_workspaces'), path: str('Workspace folder, used when workspace_id is not given'), title: str('New title; surrounding spaces are trimmed and it must not be empty') } },
189
215
  run: (a) => core.api.rename_workspace(a),
190
216
  },
191
217
  {
192
218
  name: 'dsh_session_activity',
219
+ hidden: true, // not in tools/list; reachable directly or through dsh_call
193
220
  annotations: annotations('Session activity'),
194
- description: 'Show what one session is doing right now, across all its work; dsh_status follows a single run and dsh_read_session shows past messages. Returns busy, activity (its running turn, sub-agents at any depth, background jobs and reminders) and bridge_runs (active dsh-baton runs). Read-only. Check it before dsh_send to a session that might be busy, or before dsh_cancel.',
221
+ description: 'What a session is doing now: busy, activity (running turn, sub-agents, background jobs, reminders) and bridge_runs. Read-only. Check it before dsh_send to a busy session or before dsh_cancel.',
195
222
  inputSchema: { type: 'object', required: ['session_id'], properties: { session_id: str('Session id from dsh_list_sessions') } },
196
223
  run: (a) => core.api.activity(a),
197
224
  },
198
225
  {
199
226
  name: 'dsh_cancel',
200
227
  annotations: annotations('Cancel session work', hints(false, true, true)),
201
- description: 'Stop everything a session is doing (running turn, sub-agents, background jobs, reminders), by run_id or session_id; the session itself is kept, and this is the only tool that stops work. It retries a few times until the session is quiet, then returns session_id, run (when run_id was given), attempts, still_busy, activity (whatever is still going), queued_runs and removed_from_queue. Harness also drops the messages still waiting in the session queue: their runs end as cancelled and are listed in removed_from_queue; to drop just one queued message, use dsh_update_queued instead. Safe to repeat: on an idle session it does nothing and returns still_busy: false. Use dsh_session_activity first to see what would be stopped.',
228
+ description: 'Stop everything a session is doing (turn, sub-agents, background jobs, reminders), by run_id or session_id; the session is kept. Messages queued in it are dropped too (removed_from_queue); to drop one queued message use dsh_update_queued. Safe to repeat. Returns still_busy, activity and removed_from_queue.',
202
229
  inputSchema: { type: 'object', properties: { run_id: str('Run id from dsh_send; stops the session that run belongs to'), session_id: str('Session id, to stop work dsh-baton did not start') } },
203
230
  run: (a) => core.api.cancel(a),
204
231
  },
205
232
  {
206
233
  name: 'dsh_update_queued',
234
+ hidden: true, // not in tools/list; reachable directly or through dsh_call
207
235
  annotations: annotations('Change queued message', hints(false, true, false)),
208
- description: 'Change one message sent with dsh_send that is still waiting in Harness\'s queue behind a running turn; dsh_send sends a new one and dsh_cancel stops the whole session. action edit replaces its text with message, remove deletes it (its run ends as cancelled), steer moves it into the running turn now. Only for a run whose state is queued: once its message has reached the conversation it can no longer be changed, and the error says so. Returns the run (with message_preview after an edit) and the action taken.',
236
+ description: 'Change a dsh_send message still queued behind a running turn: edit replaces its text, remove deletes it, steer sends it into the running turn now. Only while its run is queued.',
209
237
  inputSchema: { type: 'object', required: ['run_id', 'action'], properties: { run_id: str('Run id from dsh_send of the queued message'), action: { type: 'string', enum: ['edit', 'remove', 'steer'], description: 'edit replaces the text, remove deletes the message, steer sends it into the running turn now' }, message: str('New text for action edit (plain text, not empty)') } },
210
238
  run: (a) => core.api.update_queued(a),
211
239
  },
212
240
  {
213
241
  name: 'dsh_list_runs',
242
+ hidden: true, // not in tools/list; reachable directly or through dsh_call
214
243
  annotations: annotations('List runs'),
215
- description: 'List the runs sent through dsh_send (the latest 100 are kept), newest first, to recover a lost run_id or review past hand-overs (session_id narrows it to one session); dsh_list_sessions lists Harness sessions instead. Returns run_id, session_id, state, started_at, ended_at, message_preview, final_text and error for each; dsh_status shows one run in detail. Read-only.',
244
+ description: 'List the runs sent with dsh_send, newest first (the last 100 are kept), to recover a run_id: run_id, session_id, state, times, message_preview, final_text and error. Read-only.',
216
245
  inputSchema: { type: 'object', properties: { session_id: str('Only runs sent to this session'), limit: int('Max runs returned', 10) } },
217
246
  run: (a) => core.api.list_runs(a),
218
247
  },
@@ -230,7 +259,7 @@ const catalog = () => TOOLS.filter((t) => t.name !== 'dsh_call').map((t) => ({
230
259
  TOOLS.push({
231
260
  name: 'dsh_call',
232
261
  annotations: annotations('Call dsh-baton tool by name', hints(false, true, false)),
233
- description: 'Backup entry that calls any dsh-baton tool by name, for when this conversation\'s tool list is out of date: dsh-baton was updated without a restart and a tool it now has (named in the update note) is not among your tools. Normally call the dsh_* tools directly; this runs the same code with the same arguments and returns exactly what the named tool returns, so it can do whatever that tool does (including sending, cancelling or answering). With a name that does not exist it changes nothing and returns the list of available tools with their input schemas.',
262
+ description: 'Call a dsh-baton tool by name, for a tool this conversation does not list (dsh-baton was updated without a restart; the update note names it). Runs the same code as a direct call, so it can do whatever that tool does. An unknown name changes nothing and returns the tools with their input schemas.',
234
263
  inputSchema: {
235
264
  type: 'object', required: ['name'], properties: {
236
265
  name: str('Tool name, e.g. dsh_send or a tool added by an update'),
@@ -260,13 +289,13 @@ async function handle(req) {
260
289
  protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
261
290
  capabilities: { tools: {} },
262
291
  serverInfo: SERVER,
263
- instructions: `Hands tasks to a local DeepSeek Harness and passes the results back. Typical flow: dsh_list_workspaces (or dsh_create_workspace) to pick a folder, dsh_list_sessions to find a session, dsh_send {session_id, message} to continue it (or {cwd, message} to start one), then wait for the reply: ${isCodex() ? 'call dsh_wait with the run_id until it returns the final state (not dsh_status)' : 'run the wait_command it returns as a background shell command if your client wakes you when one exits, otherwise call dsh_wait'}. Then dsh_read_session to read the conversation, dsh_session_activity to see what a session is doing, dsh_update_queued to edit or withdraw a message still waiting in its queue, and dsh_cancel to stop it; dsh_list_runs recovers a lost run_id. A run that stops to ask the user something ends the wait at once with needs_input and the questions; answer with dsh_answer. ${ASKED} ${ARCHIVED} If a note says dsh-baton was updated and names a tool you do not have, call it through dsh_call {name, arguments}.`,
292
+ instructions: `Hands tasks to a local DeepSeek Harness and passes the results back. To hand over a task, call dsh_send {message, checks} directly (no need to list anything first; the new session starts in the folder this conversation works in, or pass cwd for another folder): message is the full, explicit spec (ask for tests that cover each requirement), checks are the project's own test commands such as npm test; do not write check scripts yourself. To continue a session, dsh_send {session_id, message}. Then wait for the reply: ${isCodex() ? 'call dsh_wait with the run_id until it returns the final state (not dsh_status)' : 'run the wait_command it returns as a background shell command if your client wakes you when one exits, otherwise call dsh_wait'}. A run that stops to ask the user something ends the wait with needs_input and the questions; answer with dsh_answer. ${ASKED} ${ARCHIVED} If a note says dsh-baton was updated and names a tool you do not have, call it through dsh_call {name, arguments}.`,
264
293
  }
265
294
  }
266
295
  case 'ping':
267
296
  return {}
268
297
  case 'tools/list':
269
- return { tools: TOOLS.map(({ run, ...t }) => ({ ...t, description: typeof t.description === 'function' ? t.description() : t.description })) }
298
+ return { tools: TOOLS.filter((t) => !t.hidden).map(({ run, hidden, ...t }) => ({ ...t, description: typeof t.description === 'function' ? t.description() : t.description })) }
270
299
  case 'tools/call': {
271
300
  const tool = TOOLS.find((t) => t.name === req.params?.name)
272
301
  if (!tool) throw Object.assign(new Error(`Unknown tool: ${req.params?.name}`), { code: -32602 })
@@ -275,8 +304,9 @@ async function handle(req) {
275
304
  const token = req.params?._meta?.progressToken
276
305
  const progress = token === undefined ? undefined : (done, message) =>
277
306
  send({ jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: token, progress: done, message } })
278
- const result = await tool.run(req.params.arguments || {}, { progress })
279
- const content = [{ type: 'text', text: JSON.stringify(result, null, 2) }]
307
+ const result = await tool.run(req.params.arguments || {}, { progress, meta: req.params?._meta })
308
+ // Compact JSON: indentation is paid for again on every later request of the conversation.
309
+ const content = [{ type: 'text', text: JSON.stringify(result) }]
280
310
  // Said once per server process, the first time a call finds the versions apart.
281
311
  const warning = !versionWarned && core.versionWarning()
282
312
  if (warning) { versionWarned = true; content.push({ type: 'text', text: `Note for the user: ${warning}` }) }