dsh-baton-mcp 0.5.9 → 0.6.0-next.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 +26 -3
- package/README.zh-CN.md +26 -3
- package/host/package.json +2 -2
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/cli.mjs +2 -1
- package/src/core.mjs +36 -6
- package/src/evidence.mjs +101 -0
- package/src/folder.mjs +70 -0
- package/src/mcp.mjs +57 -27
package/README.md
CHANGED
|
@@ -69,6 +69,29 @@ 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): the client sends the task with `checks`, the project's own test commands such as `npm test`. dsh-baton notes the folder's files before the run, and when the run ends it lists the files created, changed and deleted and runs the checks itself; this `evidence` comes from dsh-baton, not from the DeepSeek reply. The message also asks Harness to have a read-only sub-agent check the work against every requirement and end with a table (requirement, met, file:line). The client accepts from the evidence and that table, sends anything not met back in one message, and reads code only for rows not met. A new session starts in the folder the conversation works in unless `cwd` names another one.
|
|
73
|
+
|
|
74
|
+
## Token usage
|
|
75
|
+
|
|
76
|
+
Measured on 2026-10-25/26 with plain-language requests (what a user would type, plus "hand it to DeepSeek Harness"), the same model in both arms, three runs per cell, and every run checked by the same hidden acceptance test. Cost per task at list price; DeepSeek at its peak price, the most conservative case:
|
|
77
|
+
|
|
78
|
+
| Task | Claude Code alone | Claude Code → 0.6.0 | Codex alone | Codex → 0.6.0 |
|
|
79
|
+
|---|---|---|---|---|
|
|
80
|
+
| Medium: CLI, modules, tests | $1.07 | $0.37 client, $0.55 with DeepSeek | $0.17 | $0.09 client, $0.29 with DeepSeek |
|
|
81
|
+
| Large: web app, API, tests | $2.13 | $0.41 client, $0.70 with DeepSeek | $0.41 | $0.10 client, $0.41 with DeepSeek |
|
|
82
|
+
|
|
83
|
+
- **Claude Code** spends 65–81% less, and still about half or less once DeepSeek is added.
|
|
84
|
+
- **Codex** (gpt-6.1-sol, high) spends half to three quarters less of its own model, so a ChatGPT Plus 5-hour window lasts more than twice as long: 0.8% instead of 1.9% per medium task, 1.0% instead of 4.3% per large one. Sol is cheap, so with DeepSeek added a medium task costs more in total than doing it in Codex, and a large one about the same; the saving is in the subscription quota. With a pricier model the total drops too.
|
|
85
|
+
- All 12 runs of 0.6.0 passed the acceptance test the first time.
|
|
86
|
+
|
|
87
|
+
The [full report](./docs/benchmarks/token-usage-2026-10.md) has the method, every run, the earlier builds that were compared, raw data and scripts.
|
|
88
|
+
|
|
89
|
+
<p align="center">
|
|
90
|
+
<img src="./docs/benchmarks/token-usage-2026-10/charts/natural-total-claude-release.svg" width="100%" alt="Claude Code cost per task: alone vs handed over, with and without the DeepSeek share">
|
|
91
|
+
<img src="./docs/benchmarks/token-usage-2026-10/charts/natural-total-codex-release.svg" width="100%" alt="Codex cost per task: alone vs handed over, with and without the DeepSeek share">
|
|
92
|
+
<img src="./docs/benchmarks/token-usage-2026-10/charts/natural-codex-quota-release.svg" width="100%" alt="Codex Plus 5-hour window used per task">
|
|
93
|
+
</p>
|
|
94
|
+
|
|
72
95
|
## Tools
|
|
73
96
|
|
|
74
97
|
<p align="center">
|
|
@@ -88,9 +111,9 @@ That is all. If Harness says it could not register, run `npx -y dsh-baton-mcp se
|
|
|
88
111
|
| `dsh_create_workspace` | Register a folder as a workspace (creates the folder if missing); returns the existing one if already registered |
|
|
89
112
|
| `dsh_list_sessions` | Sessions newest first, with title, folder and running state; filter by folder or text. Archived sessions are left out |
|
|
90
113
|
| `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
|
|
114
|
+
| `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
115
|
| `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 |
|
|
116
|
+
| `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
117
|
| `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
118
|
| `dsh_session_activity` | What a session is doing now: running turn, sub-agents (any depth), background jobs; `archived: true` for an archived session |
|
|
96
119
|
| `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 |
|
|
@@ -98,7 +121,7 @@ That is all. If Harness says it could not register, run `npx -y dsh-baton-mcp se
|
|
|
98
121
|
| `dsh_rename_session` | Rename a session |
|
|
99
122
|
| `dsh_rename_workspace` | Rename a workspace (sidebar title only; the folder is not moved) |
|
|
100
123
|
| `dsh_list_runs` | Recent runs, optionally of one session |
|
|
101
|
-
| `dsh_call` |
|
|
124
|
+
| `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
125
|
|
|
103
126
|
</details>
|
|
104
127
|
|
package/README.zh-CN.md
CHANGED
|
@@ -69,6 +69,29 @@ 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 起):客户端发任务时附上 `checks`,也就是项目自己的测试命令,比如 `npm test`。dsh-baton 在运行前记下文件夹里的文件,运行结束后自己列出新建、修改、删除了哪些文件,并自己跑一遍这些命令;这份 `evidence`(证据)来自 dsh-baton,不是 DeepSeek 的自述。消息末尾还会请 Harness 派一个只读子智能体,对照每条需求复查,最后附一张表(需求、是否满足、依据在哪个文件第几行)。客户端凭证据和这张表验收,没满足的一次性发回去修,只对没满足的行看代码。新开对话默认建在当前对话所在的文件夹,除非用 `cwd` 指定别的文件夹。
|
|
73
|
+
|
|
74
|
+
## Token 用量实测
|
|
75
|
+
|
|
76
|
+
2026-10-25/26 实测:用户用大白话提需求(末尾加一句「交给 DeepSeek Harness 去做」),两边用同一个模型,每格跑 3 次,每次都用同一套隐藏的验收脚本检查。下表是每个任务的平均花费,按标价计;DeepSeek 按高峰价,是最保守的算法:
|
|
77
|
+
|
|
78
|
+
| 任务 | Claude Code 自己做 | Claude Code → 0.6.0 | Codex 自己做 | Codex → 0.6.0 |
|
|
79
|
+
|---|---|---|---|---|
|
|
80
|
+
| 中号:命令行工具、分模块、测试 | $1.07 | 主 Agent $0.37,含 DeepSeek $0.55 | $0.17 | 主 Agent $0.09,含 DeepSeek $0.29 |
|
|
81
|
+
| 大号:网页应用、接口、测试 | $2.13 | 主 Agent $0.41,含 DeepSeek $0.70 | $0.41 | 主 Agent $0.10,含 DeepSeek $0.41 |
|
|
82
|
+
|
|
83
|
+
- **Claude Code** 自己的花费少 65–81%,加上 DeepSeek 后总价也只有自己做的一半左右甚至更少。
|
|
84
|
+
- **Codex**(gpt-6.1-sol,high)自己模型的花费少一半到四分之三,ChatGPT Plus 的 5 小时额度能多撑一倍以上:中号任务从 1.9% 降到 0.8%,大号从 4.3% 降到 1.0%。Sol 本身很便宜,加上 DeepSeek 后,中号的总价比 Codex 自己做还贵,大号基本持平;省下的是订阅额度。主模型越贵,总价也越省。
|
|
85
|
+
- 0.6.0 的 12 次运行全部一次通过验收。
|
|
86
|
+
|
|
87
|
+
[完整报告](./docs/benchmarks/token-usage-2026-10.md)里有方法、每一次运行、对比过的其他版本、原始数据和脚本。
|
|
88
|
+
|
|
89
|
+
<p align="center">
|
|
90
|
+
<img src="./docs/benchmarks/token-usage-2026-10/charts/natural-total-claude-release.zh.svg" width="100%" alt="Claude Code 每个任务的花费:自己做与转交,含与不含 DeepSeek">
|
|
91
|
+
<img src="./docs/benchmarks/token-usage-2026-10/charts/natural-total-codex-release.zh.svg" width="100%" alt="Codex 每个任务的花费:自己做与转交,含与不含 DeepSeek">
|
|
92
|
+
<img src="./docs/benchmarks/token-usage-2026-10/charts/natural-codex-quota-release.zh.svg" width="100%" alt="Codex Plus 每个任务占用的 5 小时额度">
|
|
93
|
+
</p>
|
|
94
|
+
|
|
72
95
|
## 工具
|
|
73
96
|
|
|
74
97
|
<p align="center">
|
|
@@ -88,9 +111,9 @@ Codex 和 Claude 轮流往同一个 Harness 对话发消息,每条都标明是
|
|
|
88
111
|
| `dsh_create_workspace` | 把一个文件夹登记为工作区(文件夹不存在会先创建);已登记的直接返回原来那个 |
|
|
89
112
|
| `dsh_list_sessions` | 列出对话(最新在前),带标题、目录、是否在跑;可按目录或关键字过滤。已归档的对话不会列出 |
|
|
90
113
|
| `dsh_read_session` | 读某个对话最近的消息和回复(含用到的工具名);已归档的对话会带 `archived: true` |
|
|
91
|
-
| `dsh_send` | 发消息。带 `session_id`
|
|
114
|
+
| `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
115
|
| `dsh_status` | 查看运行进度:状态、最近的文字和工具调用、最终回复;回合结束但子智能体/后台任务还在跑时给出 `waiting_on`;中途有人在 Harness 界面里发消息时给出 `human_interjections`;被 Harness 重启打断的任务状态为 `interrupted` |
|
|
93
|
-
| `dsh_wait` |
|
|
116
|
+
| `dsh_wait` | 等运行结束;发消息时带了 `checks` 的,结束后会附上 `evidence`(改了哪些文件、测试结果)(或者等到它停下来问人,见 `dsh_answer`):Codex 每次最多等 270 秒,其他客户端 50 秒。没做完会带 `timed_out`,只返回状态,再调用一次即可。能在后台等待的客户端改用 `dsh_send` 返回的 `wait_command`。`dsh_status` 只用来看一眼,不用来等待 |
|
|
94
117
|
| `dsh_answer` | 回答运行中途提出的问题(Harness 的 ask_user_question):每道题可以填选项编号或选项文字、自由填写,或者跳过;可以逐题回答,也可以一次答完。运行一提问,`dsh_wait` 和 `wait_command` 就会立刻返回,并带上题目(`needs_input`、`question`;Harness 那边模型推荐的选项单独放在 `harness_suggested` 里,不再写在选项文字中)。只有你说过让它自己决定时,助手才会按自己的判断回答,这时它会带上 `reason`,并告诉你选了什么、为什么;否则它会把题目转给你,开头先写它自己的建议和理由。你也可以直接在 Harness 窗口里回答,谁先答就算谁的 |
|
|
95
118
|
| `dsh_session_activity` | 对话此刻在做什么:正在跑的回合、子智能体(任意层级)、后台任务;已归档的对话会带 `archived: true` |
|
|
96
119
|
| `dsh_update_queued` | 按 `run_id` 修改还在忙碌对话队列里等待的消息:`edit` 改文字,`remove` 撤回,`steer` 立刻插进正在跑的回合。消息一旦进入对话就改不了了 |
|
|
@@ -98,7 +121,7 @@ Codex 和 Claude 轮流往同一个 Harness 对话发消息,每条都标明是
|
|
|
98
121
|
| `dsh_rename_session` | 给对话改名 |
|
|
99
122
|
| `dsh_rename_workspace` | 给工作区改名(只改侧边栏标题,不动文件夹) |
|
|
100
123
|
| `dsh_list_runs` | 列出最近的运行,可按会话筛选 |
|
|
101
|
-
| `dsh_call` |
|
|
124
|
+
| `dsh_call` | 按名字调用任意 `dsh_*` 工具:0.6.0 起,不常用的工具(新建和重命名工作区、重命名对话、对话动态、修改排队消息、运行列表)不再出现在工具列表里,好让每次请求更短,改由这里调用;对话的工具列表还是更新前的旧版时也用它。名字不存在时会列出所有可用工具 |
|
|
102
125
|
|
|
103
126
|
</details>
|
|
104
127
|
|
package/host/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-baton-host",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0-next.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.
|
|
23
|
+
"dsh-baton-mcp": "0.6.0-next.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.
|
|
3
|
+
"version": "0.6.0-next.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.
|
|
10
|
+
"version": "0.6.0-next.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "dsh-baton-mcp",
|
|
15
|
-
"version": "0.
|
|
15
|
+
"version": "0.6.0-next.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, '..')
|
|
@@ -526,6 +527,12 @@ async function useHost() {
|
|
|
526
527
|
return up
|
|
527
528
|
}
|
|
528
529
|
|
|
530
|
+
// Said with a finished run's reply: review from the evidence dsh-baton produced, read code only
|
|
531
|
+
// where it points, and send every fix back in one message.
|
|
532
|
+
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.'
|
|
533
|
+
|
|
534
|
+
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.'
|
|
535
|
+
|
|
529
536
|
// The asking model marks its favourite option in the label ("(Recommended)", "(推荐)").
|
|
530
537
|
// Clients relaying the question tended to repeat that mark as their own advice, so the
|
|
531
538
|
// label is cleaned and the mark moves to harness_suggested: what the asker suggests, kept
|
|
@@ -586,6 +593,23 @@ const shaped = (st) => {
|
|
|
586
593
|
return Array.isArray(st.answers) ? { ...out, answers: st.answers.map((a) => (Array.isArray(a?.selected) ? { ...a, selected: a.selected.map(cleanLabel) } : a)) } : out
|
|
587
594
|
}
|
|
588
595
|
|
|
596
|
+
// The folder a run works in: given with dsh_send, or that of its workspace or session.
|
|
597
|
+
async function runFolder(a, st) {
|
|
598
|
+
if (a.cwd) return path.resolve(a.cwd.replace(/^~(?=$|[\\/])/, os.homedir()))
|
|
599
|
+
if (st?.cwd) return st.cwd
|
|
600
|
+
try {
|
|
601
|
+
if (a.workspace_id) return (await api.workspaces()).find((w) => w.id === a.workspace_id)?.path
|
|
602
|
+
const sid = a.session_id ?? st?.session_id
|
|
603
|
+
if (sid) return (await api.list_sessions({ query: sid, limit: 5 })).find((x) => x.session_id === sid)?.cwd
|
|
604
|
+
} catch { /* no folder, no evidence */ }
|
|
605
|
+
return undefined
|
|
606
|
+
}
|
|
607
|
+
const withEvidence = (st) => {
|
|
608
|
+
if (!st || !ENDED.has(st.state) || !st.run_id) return st
|
|
609
|
+
const ev = evidence.collect(config.batonHome, st.run_id)
|
|
610
|
+
return ev ? { ...st, evidence: ev } : st
|
|
611
|
+
}
|
|
612
|
+
|
|
589
613
|
const tag = (backend, value) => (Array.isArray(value) ? value.map((v) => ({ ...v, backend })) : { ...value, backend })
|
|
590
614
|
|
|
591
615
|
export const api = {
|
|
@@ -603,16 +627,22 @@ export const api = {
|
|
|
603
627
|
async read_session(a) {
|
|
604
628
|
return (await useHost()) ? tag('host', await hostCall('read_session', a)) : tag('headless', readSession(a))
|
|
605
629
|
},
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
630
|
+
// checks stay here: dsh-baton runs them itself when the run ends (see evidence.mjs).
|
|
631
|
+
// A hand-over (a message with checks) also asks Harness to have a read-only sub-agent
|
|
632
|
+
// review the work against the spec and end with a table, so the client can decide from
|
|
633
|
+
// that table and the evidence instead of reading the code itself.
|
|
634
|
+
async send({ checks, ...a }) {
|
|
635
|
+
if (Array.isArray(checks) && checks.length && typeof a.message === 'string') a = { ...a, message: `${a.message}${REVIEW_REQUEST}` }
|
|
636
|
+
const st = (await useHost()) ? tag('host', await hostCall('send', a)) : tag('headless', await waitForSessionId(startRun(a)))
|
|
637
|
+
evidence.prepare(config.batonHome, { run_id: st.run_id, cwd: await runFolder(a, st), checks })
|
|
638
|
+
return st
|
|
609
639
|
},
|
|
610
640
|
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 })))
|
|
641
|
+
return withEvidence(shaped(isHostRun(run_id) ? tag('host', await hostCall('status', { run_id, tail })) : tag('headless', runStatus(run_id, { tail }))))
|
|
612
642
|
},
|
|
613
643
|
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 }))
|
|
644
|
+
if (isHostRun(run_id)) return withEvidence(shaped(tag('host', await hostCall('wait', { run_id, timeout_s }, { timeoutMs: (timeout_s + 15) * 1000 }))))
|
|
645
|
+
return withEvidence(tag('headless', await waitRun(run_id, { timeout_s })))
|
|
616
646
|
},
|
|
617
647
|
async cancel({ run_id, session_id }) {
|
|
618
648
|
if (run_id && !isHostRun(run_id)) return tag('headless', cancelRun(run_id))
|
package/src/evidence.mjs
ADDED
|
@@ -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
|
|
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
|
-
|
|
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)
|
|
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: '
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
137
|
-
inputSchema: { type: 'object', required: ['run_id'], properties: { run_id: str('Run id from dsh_send'), tail: int('How many recent events',
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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: '
|
|
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 (
|
|
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
|
|
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
|
|
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: '
|
|
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.
|
|
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
|
-
|
|
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}` }) }
|