dsh-baton-mcp 0.6.4 → 0.6.5-next.1
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 +8 -4
- package/README.zh-CN.md +8 -4
- package/host/README.md +5 -2
- package/host/lib/index.js +252 -13
- package/host/package.json +2 -2
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/cli.mjs +24 -3
- package/src/core.mjs +58 -5
- package/src/evidence.mjs +74 -23
- package/src/folder.mjs +3 -3
- package/src/mcp.mjs +80 -18
- package/src/register.mjs +104 -10
- package/src/server.mjs +4 -1
package/README.md
CHANGED
|
@@ -156,7 +156,8 @@ Nothing to configure for waiting; if you want to know what each client does whil
|
|
|
156
156
|
<summary>How each client waits</summary>
|
|
157
157
|
|
|
158
158
|
- **Claude Code** waits for a run in the background (the `wait_command` that `dsh_send` returns) and is woken once, when the run ends.
|
|
159
|
-
- **Codex** waits inside `dsh_wait`. The plugin sets Codex's tool timeout for dsh-baton to an hour (`tool_timeout_sec = 3600`), so one call waits up to 59 minutes: a run that ends within the hour costs one model request to wait for, however long it takes, and the result comes back as an ordinary tool result in the chat. A longer run returns `timed_out` with just the state after 59 minutes, and Codex calls again without summarising. Pressing Stop ends the wait at once. Codex gets no `wait_command`: its terminal cannot wake it, so it would poll. Conversations
|
|
159
|
+
- **Codex** waits inside `dsh_wait`. The plugin sets Codex's tool timeout for dsh-baton to an hour (`tool_timeout_sec = 3600`), so one call waits up to 59 minutes: a run that ends within the hour costs one model request to wait for, however long it takes, and the result comes back as an ordinary tool result in the chat. A longer run returns `timed_out` with just the state after 59 minutes, and Codex calls again without summarising. Pressing Stop ends the wait at once. Codex gets no `wait_command`: its terminal cannot wake it, so it would poll. Conversations still open since before the plugin set the timeout, or an entry you wrote yourself without it, wait 270 s per call, just under Codex's default 300 s timeout; after Codex restarts, they get the long wait too. Measured in the Codex desktop app: a 28-minute run took one `dsh_wait` call; a 62-minute run took two, the first returning `timed_out` after 59 minutes and the second getting the result 3 minutes later, with no `dsh_status` calls in between.
|
|
160
|
+
- **Sessions with a Harness goal.** A goal makes Harness start the next round by itself as soon as a turn ends. A run sent to such a session still ends with its own round, and its result carries `goal` (phase, rounds started of the most allowed, whether Harness goes on with it) and says the work goes on. To keep following without sending anything, the client calls `dsh_wait` with `session_id` (Claude Code runs the `follow_command` in the background instead). With `until` `goal` (the default) it reports once, when the goal stops; with `until` `turn` it reports at the end of a round once 10 minutes (`every_minutes`) have passed since the last report, with every round since. Each report carries each round's closing text and tools, the files changed in the folder since the last report, and the goal's state, plus a `cursor` that the next call passes back, so switching between the two modes neither repeats nor misses a round. Both return at once when Harness asks a question (answer it with `dsh_answer` and `session_id`) or waits for an approval, when the goal is complete, paused, blocked or cleared, or when Harness will not go on with it by itself (for example after a Harness restart, until you press Continue). In Codex one such call waits at most 15 minutes, so a message you type reaches it within that time.
|
|
160
161
|
- **Changing the request while it waits.** In Codex, send the change with **Steer**: Codex stops waiting at once, steers it into the same Harness session (`dsh_send` with `mode` `steer`) and waits on the new run; Harness does both in the same turn. Sent with **Queue**, Codex sees it only when the run ends; you can also press Stop first and then send it. In Claude Code, just send it: it waits in the background, so it steers the change in right away. A steered run ends together with the original and they share one result; a queued run comes back on its own. Runs working in the same folder at the same time are named in each other's results, so neither removes the other's files.
|
|
161
162
|
|
|
162
163
|
</details>
|
|
@@ -196,8 +197,8 @@ The server's MCP instructions are a single sentence (from 0.6.2); how to hand ov
|
|
|
196
197
|
| `dsh_list_models` | The models Harness can run (provider, id, name, reasoning efforts) and its default model |
|
|
197
198
|
| `dsh_read_session` | Recent prompts and replies of one session (with tool names); `archived: true` for an archived session |
|
|
198
199
|
| `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](#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. `model` only when the user names one ("DeepSeek-V4 Pro", "v4-pro"; `reasoning_effort` likewise): it is matched against Harness's own model list, ignoring case, spaces, hyphens and a leading DeepSeek, so a model Harness adds later (V4.1 Pro) works as soon as Harness lists it; a name that matches nothing or several fails the send, before anything is created, with the list (`provider/model` picks one). It is set on that session before the message goes in, and Harness's default model is put back as it was (Harness saves a session's pick as the default too). Left out, a continued session keeps its own model and a new one uses Harness's default. The reply says which in `model` and `model_source` |
|
|
199
|
-
| `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; `model` the run was sent with |
|
|
200
|
-
| `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 3540 s per call for Codex (its tool timeout for dsh-baton less 60 s; 270 s when that is not set), 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 |
|
|
200
|
+
| `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; `model` the run was sent with. With `session_id` instead of `run_id`: what the session is doing, its Harness goal and its last turn's reply |
|
|
201
|
+
| `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 3540 s per call for Codex (its tool timeout for dsh-baton less 60 s; 270 s when that is not set), 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. With `session_id` instead of `run_id` it follows the session's Harness goal without sending anything (`until`, `cursor`, `every_minutes`; see How each client waits) |
|
|
201
202
|
| `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 |
|
|
202
203
|
| `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 |
|
|
203
204
|
| `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 |
|
|
@@ -227,6 +228,7 @@ dsh-baton send --session session-xxxx "Next: add tests"
|
|
|
227
228
|
dsh-baton send --session session-xxxx --mode steer "Interjection: don't change the API"
|
|
228
229
|
dsh-baton wait hrun-xxxx # blocks, prints the final reply; exit 0 on success
|
|
229
230
|
dsh-baton status hrun-xxxx
|
|
231
|
+
dsh-baton follow session-xxxx --until turn --every 10m # a session's Harness goal: blocks until the next report
|
|
230
232
|
dsh-baton activity session-xxxx # running turn, sub-agents, background jobs
|
|
231
233
|
dsh-baton cancel --session session-xxxx
|
|
232
234
|
dsh-baton rename session-xxxx "New title"
|
|
@@ -275,7 +277,9 @@ When you update or remove the plugin, come from dsh-bridge, run from source, or
|
|
|
275
277
|
|
|
276
278
|
**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.
|
|
277
279
|
|
|
278
|
-
**Codex's tool timeout.** Codex stops an MCP tool call after 300 s unless the server's table sets `tool_timeout_sec`. The plugin writes `tool_timeout_sec = 3600` into `[mcp_servers.dsh-baton]` in `~/.codex/config.toml`, and adds it to an existing table that has none; a value you set yourself is kept. The server reads it and makes each `dsh_wait` that long less 60 s. Codex reads the setting when a conversation
|
|
280
|
+
**Codex's tool timeout.** Codex stops an MCP tool call after 300 s unless the server's table sets `tool_timeout_sec`. The plugin writes `tool_timeout_sec = 3600` into `[mcp_servers.dsh-baton]` in `~/.codex/config.toml`, and adds it to an existing table that has none; a value you set yourself is kept. The server reads it and makes each `dsh_wait` that long less 60 s. Codex reads the setting when it starts or loads a conversation and keeps it: new conversations get it without restarting Codex, and so does every conversation after Codex restarts, while conversations open since before keep Codex's 300 s limit. dsh-baton tells them apart by when the conversation and the Codex process started (`~/.dsh-baton/codex-timeout.json` holds when the value was written) and keeps their waits at 270 s.
|
|
281
|
+
|
|
282
|
+
**Codex's cwd for dsh-baton.** Codex starts an MCP server in the conversation's folder unless its table sets `cwd`; when that folder is gone (a project on an external drive that is not connected) the server cannot start and the conversation has no `dsh_*` tools. The plugin writes `cwd` (your home folder) into `[mcp_servers.dsh-baton]` and adds it to an existing table that has none; a `cwd` you set yourself is kept.
|
|
279
283
|
|
|
280
284
|
**Updating to 0.6.4.** Nothing to restart, approve or set. Updating the plugin in Harness installs the new server into `~/.dsh-baton/app` and writes the timeout; Codex's running server picks up the new version on its next call (it checks for a newer copy before each `tools/list` and `tools/call`). New conversations wait up to 59 minutes per call; conversations already open go on as before, with 270 s waits. The Stop hook that plugins 0.6.0 to 0.6.3 added is taken out by itself, together with its trust record; there is nothing to untick in Codex. New conversations, and every conversation after Codex restarts, have no hook at all.
|
|
281
285
|
|
package/README.zh-CN.md
CHANGED
|
@@ -156,7 +156,8 @@ Codex 和 Claude 轮流往同一个 Harness 对话发消息,每条都标明是
|
|
|
156
156
|
<summary>各客户端怎么等待</summary>
|
|
157
157
|
|
|
158
158
|
- **Claude Code** 在后台等待任务(`dsh_send` 返回的 `wait_command`),任务结束时只被唤醒一次。
|
|
159
|
-
- **Codex** 在 `dsh_wait` 里等待。插件把 Codex 对 dsh-baton 的工具超时设为一小时(`tool_timeout_sec = 3600`),所以一次调用最多能等 59 分钟:一小时以内跑完的任务,不管跑多久,等待只花一次模型请求,结果作为普通的工具结果回到对话里。超过 59 分钟会返回 `timed_out`,只带状态,Codex 直接再调用一次,不会去总结进度。等待中按停止会立刻停下。Codex 不会收到 `wait_command
|
|
159
|
+
- **Codex** 在 `dsh_wait` 里等待。插件把 Codex 对 dsh-baton 的工具超时设为一小时(`tool_timeout_sec = 3600`),所以一次调用最多能等 59 分钟:一小时以内跑完的任务,不管跑多久,等待只花一次模型请求,结果作为普通的工具结果回到对话里。超过 59 分钟会返回 `timed_out`,只带状态,Codex 直接再调用一次,不会去总结进度。等待中按停止会立刻停下。Codex 不会收到 `wait_command`:它的终端无法在命令结束时唤醒它,只能轮询。插件设好超时之前就一直开着的对话,或者你自己写的、没有这项设置的条目,每次最多等 270 秒,刚好低于 Codex 默认 300 秒的工具超时;Codex 重启之后,这些对话也能长时间等待。实测(Codex 桌面端):28 分钟的任务只调了 1 次 `dsh_wait`;62 分钟的任务调了 2 次,第一次等满 59 分钟返回 `timed_out`,接着再等 3 分钟拿到结果;期间没有调用 `dsh_status`。
|
|
160
|
+
- **带 Harness 目标的会话**。有了目标,Harness 在一个回合结束后会自己开始下一轮。发给这种会话的任务仍然在它自己那一轮结束时返回,结果里带 `goal`(阶段、已开始的轮数和上限、Harness 会不会自己接着做),并说明工作还没完。想不发消息继续跟进,客户端用 `session_id` 调 `dsh_wait`(Claude Code 则在后台运行 `follow_command`)。`until` 为 `goal`(默认)时,目标停下才报告一次;为 `turn` 时,距上次报告满 10 分钟(`every_minutes`)后的第一个回合结束时报告,带上这期间的每一轮。每份报告带着每一轮的结尾回复和用过的工具、文件夹里自上次报告以来改动的文件、目标状态,以及下次调用要传回的 `cursor`,所以两种方式之间切换既不会重复也不会漏掉哪一轮。两种方式在这些情况下都会立刻返回:Harness 提问(用 `dsh_answer` 加 `session_id` 回答)或等待审批;目标完成、暂停、受阻或被清除;Harness 不会自己接着做(比如 Harness 重启后,要等你按“继续”)。在 Codex 里,这样的一次调用最多等 15 分钟,所以你打的新消息最晚 15 分钟内就能被看到。
|
|
160
161
|
- **等待中想改需求**。Codex:在输入框里用**引导**发出新要求,Codex 会立刻停止等待,用 `dsh_send`(`mode` 为 `steer`)把它插进同一个 Harness 对话,改等新的任务,Harness 在同一轮里把新要求一起做完;用**排队**发的话,要等这次任务做完才会被看到。也可以先按停止再发。Claude Code:随时发就行,它在后台等待,马上就能把新要求插队转给 Harness。插队的任务和原任务同时结束,共用一份结果;排队的任务单独返回。同一个文件夹里同时跑多个任务时,结果里会标出其他任务,不会删掉别人的文件。
|
|
161
162
|
|
|
162
163
|
</details>
|
|
@@ -196,8 +197,8 @@ Codex 和 Claude 轮流往同一个 Harness 对话发消息,每条都标明是
|
|
|
196
197
|
| `dsh_list_models` | 列出 Harness 能用的模型(provider、id、名称、推理强度)和它的默认模型 |
|
|
197
198
|
| `dsh_read_session` | 读某个对话最近的消息和回复(含用到的工具名);已归档的对话会带 `archived: true` |
|
|
198
199
|
| `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` 说明用的是哪种。`model` 只在用户点名模型时传(如“DeepSeek-V4 Pro”“v4-pro”;`reasoning_effort` 同理):按 Harness 自己的模型列表匹配,不分大小写,忽略空格、连字符和开头的 DeepSeek,所以 Harness 以后上线的模型(如 V4.1 Pro)一出现在列表里就能用;匹配不到或匹配到多个时,发送直接失败、什么都不创建,并列出可用模型(用 `provider/model` 指定其一)。模型在消息发出前设到该对话上,Harness 的默认模型会恢复原样(Harness 会把对话里选的模型同时存成默认)。不传时,续接的对话沿用它自己的模型,新对话用 Harness 默认模型。返回值的 `model` 和 `model_source` 写明用的是哪个 |
|
|
199
|
-
| `dsh_status` | 查看运行进度:状态、最近的文字和工具调用、最终回复;回合结束但子智能体/后台任务还在跑时给出 `waiting_on`;中途有人在 Harness 界面里发消息时给出 `human_interjections`;被 Harness 重启打断的任务状态为 `interrupted`;以及这次运行发送时用的 `model` |
|
|
200
|
-
| `dsh_wait` | 等运行结束;发消息时带了 `checks` 的,结束后会附上 `evidence`(改了哪些文件、测试结果)(或者等到它停下来问人,见 `dsh_answer`):Codex 每次最多等 3540 秒(它对 dsh-baton 的工具超时减 60 秒;没设时 270 秒),其他客户端 50 秒。没做完会带 `timed_out`,只返回状态,再调用一次即可。能在后台等待的客户端改用 `dsh_send` 返回的 `wait_command`。`dsh_status`
|
|
200
|
+
| `dsh_status` | 查看运行进度:状态、最近的文字和工具调用、最终回复;回合结束但子智能体/后台任务还在跑时给出 `waiting_on`;中途有人在 Harness 界面里发消息时给出 `human_interjections`;被 Harness 重启打断的任务状态为 `interrupted`;以及这次运行发送时用的 `model`。用 `session_id` 代替 `run_id` 时:会话正在做什么、它的 Harness 目标,以及上一回合的回复 |
|
|
201
|
+
| `dsh_wait` | 等运行结束;发消息时带了 `checks` 的,结束后会附上 `evidence`(改了哪些文件、测试结果)(或者等到它停下来问人,见 `dsh_answer`):Codex 每次最多等 3540 秒(它对 dsh-baton 的工具超时减 60 秒;没设时 270 秒),其他客户端 50 秒。没做完会带 `timed_out`,只返回状态,再调用一次即可。能在后台等待的客户端改用 `dsh_send` 返回的 `wait_command`。`dsh_status` 只用来看一眼,不用来等待。用 `session_id` 代替 `run_id` 时,不发任何消息,跟进这个会话的 Harness 目标(`until`、`cursor`、`every_minutes`;见“各客户端怎么等待”) |
|
|
201
202
|
| `dsh_answer` | 回答运行中途提出的问题(Harness 的 ask_user_question):每道题可以填选项编号或选项文字、自由填写,或者跳过;可以逐题回答,也可以一次答完。运行一提问,`dsh_wait` 和 `wait_command` 就会立刻返回,并带上题目(`needs_input`、`question`;Harness 那边模型推荐的选项单独放在 `harness_suggested` 里,不再写在选项文字中)。只有你说过让它自己决定时,助手才会按自己的判断回答,这时它会带上 `reason`,并告诉你选了什么、为什么;否则它会把题目转给你,开头先写它自己的建议和理由。你也可以直接在 Harness 窗口里回答,谁先答就算谁的 |
|
|
202
203
|
| `dsh_session_activity` | 经 `dsh_call` 调用。对话此刻在做什么:正在跑的回合、子智能体(任意层级)、后台任务;已归档的对话会带 `archived: true` |
|
|
203
204
|
| `dsh_update_queued` | 经 `dsh_call` 调用。按 `run_id` 修改还在忙碌对话队列里等待的消息:`edit` 改文字,`remove` 撤回,`steer` 立刻插进正在跑的回合。消息一旦进入对话就改不了了 |
|
|
@@ -227,6 +228,7 @@ dsh-baton send --session session-xxxx "继续:补上测试"
|
|
|
227
228
|
dsh-baton send --session session-xxxx --mode steer "插话:先别改 API"
|
|
228
229
|
dsh-baton wait hrun-xxxx # 阻塞到结束,打印最终回复;成功退出码 0
|
|
229
230
|
dsh-baton status hrun-xxxx
|
|
231
|
+
dsh-baton follow session-xxxx --until turn --every 10m # 跟进会话的 Harness 目标:阻塞到下一份报告
|
|
230
232
|
dsh-baton activity session-xxxx # 正在跑的回合、子智能体、后台任务
|
|
231
233
|
dsh-baton cancel --session session-xxxx
|
|
232
234
|
dsh-baton rename session-xxxx 新标题
|
|
@@ -275,7 +277,9 @@ Harness 插件连不上时:
|
|
|
275
277
|
|
|
276
278
|
**插件启用时做了什么**。它把 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`。
|
|
277
279
|
|
|
278
|
-
**Codex 的工具超时**。Codex 默认 300 秒就会停掉一次 MCP 工具调用,除非服务器那张表里设了 `tool_timeout_sec`。插件会在 `~/.codex/config.toml` 的 `[mcp_servers.dsh-baton]` 里写入 `tool_timeout_sec = 3600`,已有的表没有这一项时也会补上;你自己设过的值会保留。服务器读这个值,每次 `dsh_wait` 等它减 60 秒。Codex
|
|
280
|
+
**Codex 的工具超时**。Codex 默认 300 秒就会停掉一次 MCP 工具调用,除非服务器那张表里设了 `tool_timeout_sec`。插件会在 `~/.codex/config.toml` 的 `[mcp_servers.dsh-baton]` 里写入 `tool_timeout_sec = 3600`,已有的表没有这一项时也会补上;你自己设过的值会保留。服务器读这个值,每次 `dsh_wait` 等它减 60 秒。Codex 在启动或载入对话时读取这项设置,并且一直沿用:新对话不用重启 Codex 就能用上,Codex 重启后的所有对话也一样;只有从那之前一直开着的对话仍然是 Codex 的 300 秒上限。dsh-baton 按对话和 Codex 进程的开始时间区分(`~/.dsh-baton/codex-timeout.json` 记着这项设置写入的时间),这些对话每次照旧只等 270 秒。
|
|
281
|
+
|
|
282
|
+
**Codex 里 dsh-baton 的 cwd**。除非那张表里设了 `cwd`,Codex 会在对话所在的文件夹里启动 MCP 服务器;那个文件夹不在了(比如项目在没接上的移动硬盘上),服务器就启动不了,这个对话里也就没有 `dsh_*` 工具。插件会在 `[mcp_servers.dsh-baton]` 里写入 `cwd`(你的主文件夹),已有的表没有这一项时也会补上;你自己设过的 `cwd` 会保留。
|
|
279
283
|
|
|
280
284
|
**更新到 0.6.4**。不用重启,不用批准,也不用改任何设置。在 Harness 里更新插件后,新的服务器会装到 `~/.dsh-baton/app`,超时设置也会写好;Codex 里正在运行的服务器在下一次调用时就换成新版本(每次 `tools/list` 和 `tools/call` 前都会检查有没有更新的版本)。新对话每次最多等 59 分钟;已经开着的对话照常运行,每次等 270 秒。0.6.0 到 0.6.3 的插件加的 Stop 钩子会连同信任记录自动移除,不用去 Codex 设置里取消勾选。新开的对话,以及 Codex 重启后的所有对话,都完全没有钩子。
|
|
281
285
|
|
package/host/README.md
CHANGED
|
@@ -32,14 +32,16 @@ That is the whole install: switched on, the plugin registers the dsh-baton MCP s
|
|
|
32
32
|
|
|
33
33
|
装好就完成了:插件启用时会为 Claude Code 和 Codex 注册 dsh-baton MCP 服务器(`dsh-baton-mcp` 包,随插件一起安装),Harness 里会提示。新开的对话就有 `dsh_*` 工具。没能注册的话,运行 `npx -y dsh-baton-mcp setup`。详见 [dsh-baton](https://github.com/JialaoLiu/dsh-baton/blob/main/README.zh-CN.md#安装细节)。
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
Codex needs nothing extra: from 0.6.4 it waits for a run in one `dsh_wait` call of up to 59 minutes, with no hook to trust. / Codex 不用额外设置:从 0.6.4 起一次 `dsh_wait` 最长等 59 分钟,不需要信任钩子。
|
|
36
|
+
|
|
37
|
+
Update / 更新: uninstall, then add it again; the MCP server is updated with it. Right after a release, type the exact version, such as `dsh-baton-host@0.6.4`. / 先卸载再重新添加,MCP 服务器会一起更新;刚发版时请输入完整版本号,例如 `dsh-baton-host@0.6.4`。
|
|
36
38
|
|
|
37
39
|
Remove / 卸载: uninstall, then `npx -y dsh-baton-mcp setup --remove` / 卸载插件后运行 `npx -y dsh-baton-mcp setup --remove`.
|
|
38
40
|
|
|
39
41
|
## How it works / 工作原理
|
|
40
42
|
|
|
41
43
|
<p align="center">
|
|
42
|
-
<img src="https://raw.githubusercontent.com/JialaoLiu/dsh-baton/main/assets/readme/en/architecture.
|
|
44
|
+
<img src="https://raw.githubusercontent.com/JialaoLiu/dsh-baton/main/assets/readme/en/architecture.gif" width="100%" alt="MCP clients call dsh-baton-mcp over stdio; it reaches the dsh-baton-host plugin inside Harness through an owner-only local socket; the plugin drives Harness sessions live, with dsh headless as fallback">
|
|
43
45
|
</p>
|
|
44
46
|
|
|
45
47
|
## Socket protocol / 套接字协议
|
|
@@ -53,6 +55,7 @@ Newline-delimited JSON: request `{"id", "method", "params"}`, reply `{"id", "res
|
|
|
53
55
|
| `create_workspace` | `path`, `title?`, `create_dir?` (default `true`) |
|
|
54
56
|
| `list_sessions` | `cwd?`, `query?`, `limit?` |
|
|
55
57
|
| `read_session` | `session_id`, `last?` |
|
|
58
|
+
| `models` | — |
|
|
56
59
|
| `send` | `message`, and `session_id` or `cwd` or `workspace_id`; `mode?` (`followup` \| `steer`; left out, Harness's busy-send setting, else `followup`) |
|
|
57
60
|
| `status` | `run_id`, `tail?` |
|
|
58
61
|
| `wait` | `run_id`, `timeout_s?` |
|
package/host/lib/index.js
CHANGED
|
@@ -49,6 +49,8 @@ const probe = (sock) => new Promise((resolve) => {
|
|
|
49
49
|
|
|
50
50
|
// Expand `~`, resolve, and follow symlinks when the path exists, so the same
|
|
51
51
|
// folder always compares equal (on macOS /tmp is a link to /private/tmp).
|
|
52
|
+
const isDirectory = (p) => { try { return fs.statSync(p).isDirectory() } catch { return false } }
|
|
53
|
+
|
|
52
54
|
function normalizePath(p) {
|
|
53
55
|
const abs = path.resolve(p.replace(/^~(?=$|[\\/])/, os.homedir()))
|
|
54
56
|
try { return fs.realpathSync.native(abs) } catch { return abs }
|
|
@@ -149,6 +151,14 @@ export function apply(ctx) {
|
|
|
149
151
|
const runs = new Map() // runId -> run
|
|
150
152
|
let stopping = false // set when the plugin unloads: release every wait()
|
|
151
153
|
const waiters = new Map() // runId -> Set<resolve>
|
|
154
|
+
// Followers of a session's Harness goal (see follow()): woken by what may end their wait.
|
|
155
|
+
const followers = new Map() // sessionId -> Set<wake>
|
|
156
|
+
const FOLLOW_EVENTS = new Set(['turn/start', 'turn/end', 'goal/change'])
|
|
157
|
+
// A moment's delay, so the event that woke a follower is in the log it reads.
|
|
158
|
+
const wakeFollowers = (sessionId) => { if (followers.has(sessionId)) setTimeout(guard('wake followers', () => { for (const wake of [...(followers.get(sessionId) ?? [])]) wake() }), 50) }
|
|
159
|
+
// Sessions whose agent waits for a person (agent/status waiting), and how often each began to.
|
|
160
|
+
const waitingSessions = new Set()
|
|
161
|
+
const uiEpisodes = new Map() // sessionId -> count
|
|
152
162
|
|
|
153
163
|
// --- run bookkeeping ------------------------------------------------------
|
|
154
164
|
|
|
@@ -286,6 +296,7 @@ export function apply(ctx) {
|
|
|
286
296
|
|
|
287
297
|
ctx.on('session/event', guard('session/event', (session, event) => {
|
|
288
298
|
const data = event.data ?? {}
|
|
299
|
+
if (FOLLOW_EVENTS.has(event.type)) wakeFollowers(session.id)
|
|
289
300
|
// Turn state is kept only for a turn in progress and dropped at its end.
|
|
290
301
|
if (event.type === 'turn/start') turns.set(session.id, { hasOutput: false })
|
|
291
302
|
else if (event.type === 'assistant/message' && turns.has(session.id)) turns.get(session.id).hasOutput = true
|
|
@@ -321,7 +332,9 @@ export function apply(ctx) {
|
|
|
321
332
|
pushEvent(r, { type: 'human_input', text })
|
|
322
333
|
}
|
|
323
334
|
}
|
|
324
|
-
|
|
335
|
+
// A round a Harness goal starts by itself counts like a user-started turn: the run whose
|
|
336
|
+
// turn just ended keeps that turn's reply instead of running on with the goal.
|
|
337
|
+
if ((data.source?.kind === 'user' || data.source?.kind === 'goal') && opensTurn && run?.mode !== 'steer') {
|
|
325
338
|
// A new user-started turn: runs whose own turn already ended keep their
|
|
326
339
|
// result instead of absorbing this turn's output, but still wait for any
|
|
327
340
|
// sub-agents or jobs they started. Turns woken by sub-agent results carry
|
|
@@ -330,7 +343,7 @@ export function apply(ctx) {
|
|
|
330
343
|
let closed = false
|
|
331
344
|
for (const prev of active) {
|
|
332
345
|
if (prev !== run && prev.sawPrompt && prev.turnEnd && !prev.outputClosed) {
|
|
333
|
-
Object.assign(prev, { outputClosed: true, supersededBy: run?.id ?? 'user' })
|
|
346
|
+
Object.assign(prev, { outputClosed: true, supersededBy: run?.id ?? (data.source.kind === 'goal' ? 'goal' : 'user') })
|
|
334
347
|
closed = true
|
|
335
348
|
}
|
|
336
349
|
}
|
|
@@ -438,6 +451,9 @@ export function apply(ctx) {
|
|
|
438
451
|
|
|
439
452
|
ctx.on('agent/status', guard('agent/status', ({ agent, status }) => {
|
|
440
453
|
if (!agent) return
|
|
454
|
+
if (status === 'waiting') { if (!waitingSessions.has(agent.id)) uiEpisodes.set(agent.id, (uiEpisodes.get(agent.id) ?? 0) + 1); waitingSessions.add(agent.id) }
|
|
455
|
+
else waitingSessions.delete(agent.id)
|
|
456
|
+
wakeFollowers(agent.id)
|
|
441
457
|
const receivers = receiversFor(agent.id)
|
|
442
458
|
if (status === 'running' && receivers.length) lastRunningAt.set(agent.id, Date.now())
|
|
443
459
|
for (const run of outputReceiversFor(agent.id)) {
|
|
@@ -574,10 +590,12 @@ export function apply(ctx) {
|
|
|
574
590
|
function onQuestion(request, next) {
|
|
575
591
|
const sessionId = request?.agent?.id
|
|
576
592
|
const asked = sessionId && Array.isArray(request.questions) ? outputReceiversFor(sessionId) : []
|
|
577
|
-
|
|
593
|
+
// Taken up too when someone follows the session's goal: the follow wait brings it back.
|
|
594
|
+
if (!asked.length && !(followers.has(sessionId) && Array.isArray(request.questions))) return next()
|
|
578
595
|
const entry = { id: `q-${crypto.randomBytes(4).toString('hex')}`, sessionId, items: request.questions, answers: new Map(), runs: asked }
|
|
579
596
|
const ours = new Promise((resolve) => { entry.resolve = resolve })
|
|
580
597
|
questions.set(sessionId, entry)
|
|
598
|
+
wakeFollowers(sessionId)
|
|
581
599
|
for (const run of asked) {
|
|
582
600
|
run.question = publicQuestion(entry)
|
|
583
601
|
pushEvent(run, { type: 'question', question_id: entry.id, count: entry.items.length })
|
|
@@ -598,6 +616,7 @@ export function apply(ctx) {
|
|
|
598
616
|
const fromBaton = ours.then((value) => { ui.abort(new Error('answered through dsh-baton')); return value })
|
|
599
617
|
return Promise.race([theirs.then(answered('harness')), fromBaton.then(answered('dsh-baton')), aborted]).finally(() => {
|
|
600
618
|
if (questions.get(sessionId) === entry) questions.delete(sessionId)
|
|
619
|
+
wakeFollowers(sessionId)
|
|
601
620
|
for (const run of asked) {
|
|
602
621
|
if (run.question?.question_id !== entry.id) continue
|
|
603
622
|
delete run.question
|
|
@@ -607,15 +626,22 @@ export function apply(ctx) {
|
|
|
607
626
|
})
|
|
608
627
|
}
|
|
609
628
|
|
|
610
|
-
// Answer the open question of a run's turn
|
|
611
|
-
// text?, skip? }]; questions not answered yet
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
629
|
+
// Answer the open question of a run's turn, or of a session a follow wait reported it for.
|
|
630
|
+
// answers: [{ id?, choose?: [number | label], text?, skip? }]; questions not answered yet
|
|
631
|
+
// stay open until every one has an answer.
|
|
632
|
+
function answerQuestion({ run_id, session_id, answers }) {
|
|
633
|
+
if (!run_id && !session_id) throw new Error('run_id or session_id is required')
|
|
634
|
+
const run = run_id ? getRun(run_id) : null
|
|
635
|
+
const entry = questions.get(run?.sessionId ?? session_id)
|
|
636
|
+
if (run && (!isActive(run) || !entry || run.question?.question_id !== entry.id)) {
|
|
616
637
|
if (run.state === 'waiting') throw new Error(`Run ${run.id} is waiting for something in the Harness UI (an approval, for example) that cannot be answered from here; tell the user.`)
|
|
617
638
|
throw new Error(`Run ${run.id} is ${run.state} and has no open question to answer.`)
|
|
618
639
|
}
|
|
640
|
+
if (!entry) {
|
|
641
|
+
if (waitingSessions.has(session_id)) throw new Error(`Session ${session_id} is waiting for something in the Harness UI (an approval, for example) that cannot be answered from here; tell the user.`)
|
|
642
|
+
throw new Error(`Session ${session_id} has no open question to answer.`)
|
|
643
|
+
}
|
|
644
|
+
const shown = run ? publicRun(run, 0) : { session_id }
|
|
619
645
|
if (!Array.isArray(answers) || !answers.length) throw new Error('answers must list at least one answer')
|
|
620
646
|
const staged = new Map()
|
|
621
647
|
for (const a of answers) {
|
|
@@ -639,10 +665,10 @@ export function apply(ctx) {
|
|
|
639
665
|
const remaining = entry.items.filter((q) => !entry.answers.has(q.id)).map((q) => q.id)
|
|
640
666
|
if (remaining.length) {
|
|
641
667
|
for (const r of entry.runs) { if (r.question?.question_id === entry.id) { r.question = publicQuestion(entry); persist(r) } }
|
|
642
|
-
return { ...
|
|
668
|
+
return { ...shown, submitted: false, remaining }
|
|
643
669
|
}
|
|
644
670
|
entry.resolve({ answers: entry.items.map((q) => entry.answers.get(q.id)) })
|
|
645
|
-
return { ...
|
|
671
|
+
return { ...shown, submitted: true, answers: entry.items.map((q) => entry.answers.get(q.id)) }
|
|
646
672
|
}
|
|
647
673
|
|
|
648
674
|
ctx.on('user-questions/request', guardWaterfall(onQuestion), { global: true, prepend: true })
|
|
@@ -720,6 +746,190 @@ export function apply(ctx) {
|
|
|
720
746
|
return { session_id, ...(isArchived(session_id) ? { archived: true } : {}), cwd: meta?.cwd ?? null, title, total_messages: messages.length, messages: messages.slice(-last) }
|
|
721
747
|
}
|
|
722
748
|
|
|
749
|
+
// --- Harness goals --------------------------------------------------------------
|
|
750
|
+
// A session with a goal starts its next round by itself a few ms after a turn ends, until
|
|
751
|
+
// the goal is complete, paused or blocked, or its rounds run out. The goal's durable state is
|
|
752
|
+
// a session projection (values.goal). Whether this process will really go on with it
|
|
753
|
+
// (activation: armed, or disarmed, e.g. after a Harness restart until a person resumes it)
|
|
754
|
+
// is not in it: only Harness's goals service and its goal/activation-changed event say.
|
|
755
|
+
|
|
756
|
+
let goalsService = null
|
|
757
|
+
let agentsService = null
|
|
758
|
+
const activations = new Map() // sessionId -> 'armed' | 'disarmed', from goal/activation-changed
|
|
759
|
+
ctx.on('goal/activation-changed', guard('goal activation', ({ sessionId, goal } = {}) => {
|
|
760
|
+
if (!sessionId) return
|
|
761
|
+
if (goal?.activation) activations.set(sessionId, goal.activation)
|
|
762
|
+
else activations.delete(sessionId)
|
|
763
|
+
wakeFollowers(sessionId)
|
|
764
|
+
}))
|
|
765
|
+
|
|
766
|
+
// armed, disarmed, or null when this Harness does not say. A session not loaded (no live
|
|
767
|
+
// agent, as after a Harness restart until it is opened) runs nothing: disarmed.
|
|
768
|
+
function activationOf(sessionId, goalId) {
|
|
769
|
+
try {
|
|
770
|
+
if (agentsService && typeof goalsService?.get === 'function') {
|
|
771
|
+
const agent = agentsService.get(sessionId)
|
|
772
|
+
if (!agent) return 'disarmed'
|
|
773
|
+
const view = goalsService.get(agent)
|
|
774
|
+
if (view?.id === goalId && view.activation) return view.activation
|
|
775
|
+
}
|
|
776
|
+
} catch { /* not live here: the event is all there is */ }
|
|
777
|
+
return activations.get(sessionId) ?? null
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const clipText = (s, n) => (typeof s === 'string' && s.length > n ? `${s.slice(0, n)}…` : s ?? null)
|
|
781
|
+
|
|
782
|
+
// The session's current goal, or null (none, or this Harness has no goals).
|
|
783
|
+
async function goalOf(sessionId) {
|
|
784
|
+
let current
|
|
785
|
+
try {
|
|
786
|
+
const p = typeof controller.projections === 'function' ? await controller.projections({ sessionId }, signal()) : null
|
|
787
|
+
// The projection's wire view is the current goal itself (null when none); the stored
|
|
788
|
+
// state, as the projection cache keeps it, wraps it in { current, seenGoalIds, failure }.
|
|
789
|
+
const v = p?.values?.goal
|
|
790
|
+
current = v && typeof v === 'object' && 'current' in v ? v.current : v
|
|
791
|
+
} catch { return null }
|
|
792
|
+
const g = current?.goal
|
|
793
|
+
if (!g?.id) return null
|
|
794
|
+
return {
|
|
795
|
+
id: g.id, phase: g.phase, objective: clipText(g.objective, 300),
|
|
796
|
+
rounds_started: current.roundsStarted ?? 0, max_rounds: g.maxGoalRounds ?? null,
|
|
797
|
+
activation: activationOf(sessionId, g.id),
|
|
798
|
+
...(g.blockedReason ? { blocked_reason: { code: g.blockedReason.code, message: g.blockedReason.message } } : {}),
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// A goal that will go on after this point (not complete): worth telling whoever waited on a turn.
|
|
803
|
+
const openGoal = async (sessionId) => {
|
|
804
|
+
const goal = await goalOf(sessionId)
|
|
805
|
+
return goal && goal.phase !== 'complete' ? goal : null
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// A session log's turns, oldest first: when each ended, its goal round (when a goal started
|
|
809
|
+
// it), its closing text and the tools it called.
|
|
810
|
+
function turnsOf(events) {
|
|
811
|
+
const ended = []
|
|
812
|
+
let cur = null
|
|
813
|
+
for (const e of events ?? []) {
|
|
814
|
+
if (e.type === 'turn/start') cur = { turn: e.data?.turn ?? ended.length + 1, round: null, text: null, tools: {}, started_at: Date.parse(iso(e.time)) || null }
|
|
815
|
+
else if (!cur) continue
|
|
816
|
+
else if (e.type === 'user/message' && e.data?.source?.kind === 'goal') cur.round = e.data.source.round ?? null
|
|
817
|
+
else if (e.type === 'assistant/message') {
|
|
818
|
+
const content = e.data?.message?.content
|
|
819
|
+
const text = textOf(content)
|
|
820
|
+
if (text) cur.text = text
|
|
821
|
+
for (const call of toolCallsOf(content)) { const n = call.name ?? call.kind; cur.tools[n] = (cur.tools[n] ?? 0) + 1 }
|
|
822
|
+
} else if (e.type === 'turn/end') {
|
|
823
|
+
const { started_at, ...turn } = cur
|
|
824
|
+
ended.push({ ...turn, ended_at: iso(e.time) ?? new Date().toISOString(), end: e.data?.reason?.kind ?? null })
|
|
825
|
+
cur = null
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return { ended, running: cur !== null, runningSince: cur?.started_at ?? null }
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const MAX_ROUNDS_SHOWN = 10
|
|
832
|
+
const shownRound = (t) => ({
|
|
833
|
+
turn: t.turn, ...(t.round != null ? { goal_round: t.round } : {}), ended_at: t.ended_at,
|
|
834
|
+
...(t.end && t.end !== 'completed' ? { ended_as: t.end } : {}),
|
|
835
|
+
final_text: clipText(t.text, 1500), ...(Object.keys(t.tools).length ? { tools: t.tools } : {}),
|
|
836
|
+
})
|
|
837
|
+
const shownRounds = (list) => ({
|
|
838
|
+
rounds: list.slice(-MAX_ROUNDS_SHOWN).map(shownRound),
|
|
839
|
+
...(list.length > MAX_ROUNDS_SHOWN ? { earlier_rounds_not_shown: list.length - MAX_ROUNDS_SHOWN } : {}),
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
// Where a follow wait has got to: rounds already reported, and when it last reported.
|
|
843
|
+
const cursorOf = (mark) => `${mark.rounds}:${mark.at}`
|
|
844
|
+
function parseCursor(cursor) {
|
|
845
|
+
const m = /^(\d+):(\d+)$/.exec(String(cursor ?? ''))
|
|
846
|
+
return m ? { rounds: Number(m[1]), at: Number(m[2]) } : null
|
|
847
|
+
}
|
|
848
|
+
const GOAL_QUIET_MS = Number(process.env.DSH_BATON_GOAL_QUIET_MS) || 5 * 60_000
|
|
849
|
+
// The question or Harness UI wait a follower was already told about (one report per episode).
|
|
850
|
+
const noticed = new Map() // sessionId -> key
|
|
851
|
+
|
|
852
|
+
// Wait on a session's goal without sending anything. until "goal": until the goal stops
|
|
853
|
+
// (complete, paused, blocked, cleared). until "turn": at the first round end once every_s
|
|
854
|
+
// have passed since the last report, with every round since then. Both return at once when
|
|
855
|
+
// Harness needs a person, or the goal will not go on by itself (disarmed; when that cannot
|
|
856
|
+
// be read, no new round for GOAL_QUIET_MS). The cursor carries what was reported, so a
|
|
857
|
+
// next call, in either mode, neither repeats nor misses a round.
|
|
858
|
+
async function follow({ session_id, until = 'goal', cursor, every_s = 600, timeout_s = 300 }) {
|
|
859
|
+
if (!session_id) throw new Error('session_id is required')
|
|
860
|
+
if (until !== 'goal' && until !== 'turn') throw new Error('until must be goal or turn')
|
|
861
|
+
if (typeof controller.projections !== 'function') throw new Error('This DeepSeek Harness version has no session projections, so its goals cannot be followed.')
|
|
862
|
+
const every = Math.max(1, Number(every_s) || 600) * 1000
|
|
863
|
+
const seconds = Number.isFinite(Number(timeout_s)) ? Math.min(Math.max(Number(timeout_s), 0), MAX_WAIT_S) : 300
|
|
864
|
+
const deadline = Date.now() + seconds * 1000
|
|
865
|
+
let mark = parseCursor(cursor)
|
|
866
|
+
const startedAt = Date.now()
|
|
867
|
+
for (;;) {
|
|
868
|
+
const [goal, log] = await Promise.all([goalOf(session_id), controller.inspect(session_id, signal())])
|
|
869
|
+
const { ended, running, runningSince } = turnsOf(log?.events)
|
|
870
|
+
// A first call counts from the start of the round under way, so what it already wrote is reported.
|
|
871
|
+
mark ??= { rounds: ended.length, at: running && runningSince ? Math.min(runningSince, Date.now()) : Date.now() }
|
|
872
|
+
const fresh = ended.slice(Math.min(mark.rounds, ended.length))
|
|
873
|
+
const out = (state, extra = {}) => ({
|
|
874
|
+
session_id, state, until, goal, cwd: log?.meta?.cwd ?? null, since: iso(mark.at), ...shownRounds(fresh), ...extra,
|
|
875
|
+
cursor: extra.timed_out ? cursorOf(mark) : cursorOf({ rounds: ended.length, at: Date.now() }),
|
|
876
|
+
})
|
|
877
|
+
// A goal gone since a call that had one was cleared. Without one so far, the turn that
|
|
878
|
+
// sends "/goal …" creates it partway through: no_goal only once no turn is running.
|
|
879
|
+
const hadGoal = !!goal || (!!cursor && (log?.events ?? []).some((e) => e.type === 'goal/change'))
|
|
880
|
+
const endedBecause = !hadGoal ? null : !goal ? 'cleared' : goal.phase !== 'active' ? goal.phase : null
|
|
881
|
+
const lastEnd = ended.length ? Date.parse(ended.at(-1).ended_at) : 0
|
|
882
|
+
const quietSince = Math.max(lastEnd, startedAt)
|
|
883
|
+
if (!hadGoal) {
|
|
884
|
+
if (!running || stopping) return out('no_goal', { ended_because: null })
|
|
885
|
+
} else if (endedBecause) {
|
|
886
|
+
// The round that completed the goal is often still finishing: its reply goes with the report.
|
|
887
|
+
if (!running || stopping || Date.now() >= deadline) return out('goal_ended', { ended_because: endedBecause })
|
|
888
|
+
} else {
|
|
889
|
+
if (goal.max_rounds && goal.rounds_started >= goal.max_rounds && !running) return out('goal_ended', { ended_because: 'round_limit' })
|
|
890
|
+
const question = questions.get(session_id)
|
|
891
|
+
const key = question ? question.id : waitingSessions.has(session_id) ? `ui:${uiEpisodes.get(session_id)}` : null
|
|
892
|
+
if (key && noticed.get(session_id) !== key) {
|
|
893
|
+
noticed.set(session_id, key)
|
|
894
|
+
return out('needs_input', question ? { needs_input: 'answer', question: publicQuestion(question) } : { needs_input: 'harness-ui' })
|
|
895
|
+
}
|
|
896
|
+
if (goal.activation === 'disarmed') return out('needs_resume')
|
|
897
|
+
if (goal.activation == null && !running && Date.now() - quietSince >= GOAL_QUIET_MS) return out('goal_stalled')
|
|
898
|
+
if (until === 'turn' && fresh.length && (lastEnd >= mark.at + every || (Date.now() >= mark.at + every && !running))) return out('report')
|
|
899
|
+
}
|
|
900
|
+
if (stopping || Date.now() >= deadline) return out('following', { timed_out: true })
|
|
901
|
+
// Sleep until something happens, the next report is due, or the quiet runs out.
|
|
902
|
+
const due = [deadline, ...(until === 'turn' ? [mark.at + every] : []), ...(goal && goal.activation == null && !running ? [quietSince + GOAL_QUIET_MS] : [])]
|
|
903
|
+
const wakeAt = Math.max(Date.now() + 50, Math.min(...due.filter((t) => t > Date.now())))
|
|
904
|
+
await new Promise((resolve) => {
|
|
905
|
+
const set = followers.get(session_id) ?? new Set()
|
|
906
|
+
followers.set(session_id, set)
|
|
907
|
+
const done = () => {
|
|
908
|
+
clearTimeout(timer)
|
|
909
|
+
set.delete(done)
|
|
910
|
+
if (!set.size && followers.get(session_id) === set) followers.delete(session_id)
|
|
911
|
+
resolve()
|
|
912
|
+
}
|
|
913
|
+
const timer = setTimeout(done, wakeAt - Date.now())
|
|
914
|
+
set.add(done)
|
|
915
|
+
})
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// A session at a glance, for a client that holds only its id: what it is doing, its goal
|
|
920
|
+
// and the reply of its last turn.
|
|
921
|
+
async function sessionStatus({ session_id }) {
|
|
922
|
+
const [act, goal, log] = await Promise.all([activity({ session_id }), goalOf(session_id), controller.inspect(session_id, signal()).catch(() => null)])
|
|
923
|
+
const { ended, running } = turnsOf(log?.events)
|
|
924
|
+
const question = questions.get(session_id)
|
|
925
|
+
return {
|
|
926
|
+
...act, turn_running: running,
|
|
927
|
+
...(goal ? { goal } : {}),
|
|
928
|
+
...(question ? { needs_input: 'answer', question: publicQuestion(question) } : waitingSessions.has(session_id) ? { needs_input: 'harness-ui' } : {}),
|
|
929
|
+
...(ended.length ? { last_turn: shownRound(ended.at(-1)) } : {}),
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
723
933
|
// --- models -----------------------------------------------------------------
|
|
724
934
|
// Harness's own catalog decides which names exist: "V4 Pro", "v4-pro" and "DeepSeek-V4-Pro"
|
|
725
935
|
// all name the model whose id or name reads that way once case, spaces, hyphens,
|
|
@@ -885,6 +1095,15 @@ export function apply(ctx) {
|
|
|
885
1095
|
throw new Error(`Session ${sessionId} is archived. Unarchive it in Harness, or start a new session with cwd or workspace_id.`)
|
|
886
1096
|
}
|
|
887
1097
|
if (!sessionId && !cwd && !workspace_id) throw new Error('A new session needs cwd or workspace_id. Pass session_id to continue an existing one.')
|
|
1098
|
+
// A missing folder is not created: a mistyped path, or a project on a drive that is not
|
|
1099
|
+
// connected, would get an empty folder (under /Volumes, even). Checked before the registry,
|
|
1100
|
+
// which answers a bare ENOENT, or nothing at all for a folder registered while it was there.
|
|
1101
|
+
if (!sessionId) {
|
|
1102
|
+
const dir = workspace_id ? registry.get?.(workspace_id)?.path : normalizePath(cwd)
|
|
1103
|
+
if (dir && !isDirectory(dir)) {
|
|
1104
|
+
throw new Error(`The folder ${dir}${workspace_id ? ` (workspace ${workspace_id})` : ''} does not exist, so no session was started and nothing was sent. Check the path (a project on an external drive needs that drive connected), or create the folder first.`)
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
888
1107
|
const plan = await planModel(sessionId, { model, reasoning_effort })
|
|
889
1108
|
const isNew = !sessionId
|
|
890
1109
|
if (isNew) {
|
|
@@ -969,7 +1188,13 @@ export function apply(ctx) {
|
|
|
969
1188
|
run.waitNoticed = true
|
|
970
1189
|
return { ...publicRun(run), needs_input: run.question ? 'answer' : 'harness-ui' }
|
|
971
1190
|
}
|
|
972
|
-
return { ...publicRun(run), ...(done ?
|
|
1191
|
+
return { ...publicRun(run), ...(done ? await goalField(run) : { timed_out: true }) }
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// A run that completed in a session with a goal: the goal goes on after it.
|
|
1195
|
+
const goalField = async (run) => {
|
|
1196
|
+
const goal = run.state === 'completed' ? await openGoal(run.sessionId) : null
|
|
1197
|
+
return goal ? { goal } : {}
|
|
973
1198
|
}
|
|
974
1199
|
|
|
975
1200
|
// What a session is doing right now, as Harness reports it for archiving:
|
|
@@ -1045,7 +1270,12 @@ export function apply(ctx) {
|
|
|
1045
1270
|
read_session: readSession,
|
|
1046
1271
|
models,
|
|
1047
1272
|
send,
|
|
1048
|
-
status: ({ run_id, tail }) =>
|
|
1273
|
+
status: async ({ run_id, session_id, tail }) => {
|
|
1274
|
+
if (!run_id && session_id) return sessionStatus({ session_id })
|
|
1275
|
+
const run = getRun(run_id)
|
|
1276
|
+
return { ...publicRun(run, tail ?? 20), ...(isActive(run) ? {} : await goalField(run)) }
|
|
1277
|
+
},
|
|
1278
|
+
follow,
|
|
1049
1279
|
wait,
|
|
1050
1280
|
cancel,
|
|
1051
1281
|
activity,
|
|
@@ -1132,6 +1362,14 @@ export function apply(ctx) {
|
|
|
1132
1362
|
defaultModelService = child.agentDefaultModel
|
|
1133
1363
|
child.effect(() => () => { if (defaultModelService === child.agentDefaultModel) defaultModelService = null }, 'dsh-baton-host default model')
|
|
1134
1364
|
})
|
|
1365
|
+
ctx.inject(['goals'], (child) => {
|
|
1366
|
+
goalsService = child.goals
|
|
1367
|
+
child.effect(() => () => { if (goalsService === child.goals) goalsService = null }, 'dsh-baton-host goals')
|
|
1368
|
+
})
|
|
1369
|
+
ctx.inject(['agents'], (child) => {
|
|
1370
|
+
agentsService = child.agents
|
|
1371
|
+
child.effect(() => () => { if (agentsService === child.agents) agentsService = null }, 'dsh-baton-host agents')
|
|
1372
|
+
})
|
|
1135
1373
|
ctx.inject(['webServer'], (web) => {
|
|
1136
1374
|
web.effect(() => web.webServer.register({
|
|
1137
1375
|
kind: 'exact',
|
|
@@ -1168,6 +1406,7 @@ export function apply(ctx) {
|
|
|
1168
1406
|
// unloaded or reloaded plugin leaves no timers or sockets behind.
|
|
1169
1407
|
for (const set of waiters.values()) for (const done of [...set]) done()
|
|
1170
1408
|
waiters.clear()
|
|
1409
|
+
for (const set of followers.values()) for (const done of [...set]) done()
|
|
1171
1410
|
for (const conn of connections) conn.destroy()
|
|
1172
1411
|
if (!server) return
|
|
1173
1412
|
server.close()
|
package/host/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-baton-host",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5-next.1",
|
|
4
4
|
"description": "DeepSeek Harness plugin for dsh-baton: hand coding tasks from Claude Code or Codex to DeepSeek Harness on your machine. Adding it is the whole install: it registers the dsh-baton MCP server for Claude Code and Codex, runs each task live in the Harness window, wakes the main agent when the run ends and hands back the changed files, the project's own test results and a requirement-by-requirement checklist.",
|
|
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.6.
|
|
23
|
+
"dsh-baton-mcp": "0.6.5-next.1"
|
|
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.6.
|
|
3
|
+
"version": "0.6.5-next.1",
|
|
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.6.
|
|
10
|
+
"version": "0.6.5-next.1",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "dsh-baton-mcp",
|
|
15
|
-
"version": "0.6.
|
|
15
|
+
"version": "0.6.5-next.1",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|