dsh-baton-mcp 0.6.4 → 0.6.5-next.2
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 +327 -13
- package/host/package.json +2 -2
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/cli.mjs +48 -5
- package/src/core.mjs +114 -5
- package/src/evidence.mjs +111 -30
- package/src/folder.mjs +3 -3
- package/src/mcp.mjs +99 -21
- 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 (a message starting with `/goal` sets one) makes Harness start the next round by itself as soon as a turn ends. A run sent to such a session is waited for until the goal stops, not just its first round, so "hand this to Harness" brings back the finished work; sessions without a goal are waited for as before. When the user asks for progress reports ("report every 10 minutes"), the client passes `until` `turn`: it then reports at the end of a round once 10 minutes (`every_minutes`) have passed since the last report, with every round since. To follow a session without sending anything, the client calls `dsh_wait` with `session_id` (Claude Code runs the `follow_command` in the background instead). Each report carries every round since the last one (the latest round's closing reply (up to 1500 characters), each earlier round as a one-line digest with its tools; any round in full through `dsh_read_session`), the files changed in the folder since the last report (since the run began, for the first), and the goal's state (phase, rounds started of the most allowed, whether Harness goes on with it), 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 call follows a goal for at most 15 minutes and Codex then calls again, so a message you type reaches it within that time; such an in-between return carries only the goal's state and how many rounds wait, not their texts.
|
|
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. A run in a session with a Harness goal is waited for until the goal stops (`until` `turn` and `every_minutes` for progress reports). 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 目标的会话**。有了目标(以 `/goal` 开头的消息会设置目标),Harness 在一个回合结束后会自己开始下一轮。发给这种会话的任务会一直等到目标停下,而不只是第一轮,所以一句“交给 Harness”就能拿回做完的结果;没有目标的会话照旧等待。用户要求汇报进度时(“每 10 分钟汇报一次”),客户端传 `until` 为 `turn`:距上次报告满 10 分钟(`every_minutes`)后的第一个回合结束时报告,带上这期间的每一轮。想不发消息只跟进某个会话,客户端用 `session_id` 调 `dsh_wait`(Claude Code 则在后台运行 `follow_command`)。每份报告带着自上次报告以来的每一轮(最新一轮的结尾回复(最多 1500 字),之前每一轮一行摘要和用过的工具;任何一轮的全文可以用 `dsh_read_session` 看)、文件夹里自上次报告以来改动的文件(第一份从任务开始算起)、目标状态(阶段、已开始的轮数和上限、Harness 会不会自己接着做),以及下次调用要传回的 `cursor`,所以两种方式之间切换既不会重复也不会漏掉哪一轮。两种方式在这些情况下都会立刻返回:Harness 提问(用 `dsh_answer` 加 `session_id` 回答)或等待审批;目标完成、暂停、受阻或被清除;Harness 不会自己接着做(比如 Harness 重启后,要等你按“继续”)。在 Codex 里,一次调用最多跟进 15 分钟,然后 Codex 再接着调用,所以你打的新消息最晚 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` 只用来看一眼,不用来等待。发到带 Harness 目标的会话的任务会一直等到目标停下(要汇报进度时用 `until` 为 `turn` 和 `every_minutes`)。用 `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,24 @@ 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
|
+
// Each such event also counts up the session's generation (50 ms later, once the log has it), so
|
|
159
|
+
// a follow that was reading the log when it came reads again instead of sleeping past it.
|
|
160
|
+
const eventGen = new Map() // sessionId -> count
|
|
161
|
+
const wakeFollowers = (sessionId) => setTimeout(guard('wake followers', () => {
|
|
162
|
+
eventGen.set(sessionId, (eventGen.get(sessionId) ?? 0) + 1)
|
|
163
|
+
for (const wake of [...(followers.get(sessionId) ?? [])]) wake()
|
|
164
|
+
}), 50)
|
|
165
|
+
// Sessions whose agent waits for a person (agent/status waiting), and how often each began to.
|
|
166
|
+
const waitingSessions = new Set()
|
|
167
|
+
const uiEpisodes = new Map() // sessionId -> count
|
|
168
|
+
const waitingSince = new Map() // sessionId -> ms the current wait began
|
|
169
|
+
// Sessions a follow wait is on, until when: a question that comes between two calls of one
|
|
170
|
+
// follow (a client's chunks, its round trip) is still taken up for dsh_answer.
|
|
171
|
+
const followedUntil = new Map() // sessionId -> ms
|
|
152
172
|
|
|
153
173
|
// --- run bookkeeping ------------------------------------------------------
|
|
154
174
|
|
|
@@ -286,6 +306,7 @@ export function apply(ctx) {
|
|
|
286
306
|
|
|
287
307
|
ctx.on('session/event', guard('session/event', (session, event) => {
|
|
288
308
|
const data = event.data ?? {}
|
|
309
|
+
if (FOLLOW_EVENTS.has(event.type)) wakeFollowers(session.id)
|
|
289
310
|
// Turn state is kept only for a turn in progress and dropped at its end.
|
|
290
311
|
if (event.type === 'turn/start') turns.set(session.id, { hasOutput: false })
|
|
291
312
|
else if (event.type === 'assistant/message' && turns.has(session.id)) turns.get(session.id).hasOutput = true
|
|
@@ -321,7 +342,9 @@ export function apply(ctx) {
|
|
|
321
342
|
pushEvent(r, { type: 'human_input', text })
|
|
322
343
|
}
|
|
323
344
|
}
|
|
324
|
-
|
|
345
|
+
// A round a Harness goal starts by itself counts like a user-started turn: the run whose
|
|
346
|
+
// turn just ended keeps that turn's reply instead of running on with the goal.
|
|
347
|
+
if ((data.source?.kind === 'user' || data.source?.kind === 'goal') && opensTurn && run?.mode !== 'steer') {
|
|
325
348
|
// A new user-started turn: runs whose own turn already ended keep their
|
|
326
349
|
// result instead of absorbing this turn's output, but still wait for any
|
|
327
350
|
// sub-agents or jobs they started. Turns woken by sub-agent results carry
|
|
@@ -330,7 +353,7 @@ export function apply(ctx) {
|
|
|
330
353
|
let closed = false
|
|
331
354
|
for (const prev of active) {
|
|
332
355
|
if (prev !== run && prev.sawPrompt && prev.turnEnd && !prev.outputClosed) {
|
|
333
|
-
Object.assign(prev, { outputClosed: true, supersededBy: run?.id ?? 'user' })
|
|
356
|
+
Object.assign(prev, { outputClosed: true, supersededBy: run?.id ?? (data.source.kind === 'goal' ? 'goal' : 'user') })
|
|
334
357
|
closed = true
|
|
335
358
|
}
|
|
336
359
|
}
|
|
@@ -438,6 +461,9 @@ export function apply(ctx) {
|
|
|
438
461
|
|
|
439
462
|
ctx.on('agent/status', guard('agent/status', ({ agent, status }) => {
|
|
440
463
|
if (!agent) return
|
|
464
|
+
if (status === 'waiting') { if (!waitingSessions.has(agent.id)) { uiEpisodes.set(agent.id, (uiEpisodes.get(agent.id) ?? 0) + 1); waitingSince.set(agent.id, Date.now()) } waitingSessions.add(agent.id) }
|
|
465
|
+
else { waitingSessions.delete(agent.id); waitingSince.delete(agent.id) }
|
|
466
|
+
wakeFollowers(agent.id)
|
|
441
467
|
const receivers = receiversFor(agent.id)
|
|
442
468
|
if (status === 'running' && receivers.length) lastRunningAt.set(agent.id, Date.now())
|
|
443
469
|
for (const run of outputReceiversFor(agent.id)) {
|
|
@@ -574,10 +600,13 @@ export function apply(ctx) {
|
|
|
574
600
|
function onQuestion(request, next) {
|
|
575
601
|
const sessionId = request?.agent?.id
|
|
576
602
|
const asked = sessionId && Array.isArray(request.questions) ? outputReceiversFor(sessionId) : []
|
|
577
|
-
|
|
603
|
+
// Taken up too when someone follows the session's goal: the follow wait brings it back.
|
|
604
|
+
const followed = followers.has(sessionId) || (followedUntil.get(sessionId) ?? 0) > Date.now()
|
|
605
|
+
if (!asked.length && !(followed && Array.isArray(request.questions))) return next()
|
|
578
606
|
const entry = { id: `q-${crypto.randomBytes(4).toString('hex')}`, sessionId, items: request.questions, answers: new Map(), runs: asked }
|
|
579
607
|
const ours = new Promise((resolve) => { entry.resolve = resolve })
|
|
580
608
|
questions.set(sessionId, entry)
|
|
609
|
+
wakeFollowers(sessionId)
|
|
581
610
|
for (const run of asked) {
|
|
582
611
|
run.question = publicQuestion(entry)
|
|
583
612
|
pushEvent(run, { type: 'question', question_id: entry.id, count: entry.items.length })
|
|
@@ -598,6 +627,7 @@ export function apply(ctx) {
|
|
|
598
627
|
const fromBaton = ours.then((value) => { ui.abort(new Error('answered through dsh-baton')); return value })
|
|
599
628
|
return Promise.race([theirs.then(answered('harness')), fromBaton.then(answered('dsh-baton')), aborted]).finally(() => {
|
|
600
629
|
if (questions.get(sessionId) === entry) questions.delete(sessionId)
|
|
630
|
+
wakeFollowers(sessionId)
|
|
601
631
|
for (const run of asked) {
|
|
602
632
|
if (run.question?.question_id !== entry.id) continue
|
|
603
633
|
delete run.question
|
|
@@ -607,15 +637,22 @@ export function apply(ctx) {
|
|
|
607
637
|
})
|
|
608
638
|
}
|
|
609
639
|
|
|
610
|
-
// Answer the open question of a run's turn
|
|
611
|
-
// text?, skip? }]; questions not answered yet
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
640
|
+
// Answer the open question of a run's turn, or of a session a follow wait reported it for.
|
|
641
|
+
// answers: [{ id?, choose?: [number | label], text?, skip? }]; questions not answered yet
|
|
642
|
+
// stay open until every one has an answer.
|
|
643
|
+
function answerQuestion({ run_id, session_id, answers }) {
|
|
644
|
+
if (!run_id && !session_id) throw new Error('run_id or session_id is required')
|
|
645
|
+
const run = run_id ? getRun(run_id) : null
|
|
646
|
+
const entry = questions.get(run?.sessionId ?? session_id)
|
|
647
|
+
if (run && (!isActive(run) || !entry || run.question?.question_id !== entry.id)) {
|
|
616
648
|
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
649
|
throw new Error(`Run ${run.id} is ${run.state} and has no open question to answer.`)
|
|
618
650
|
}
|
|
651
|
+
if (!entry) {
|
|
652
|
+
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.`)
|
|
653
|
+
throw new Error(`Session ${session_id} has no open question to answer.`)
|
|
654
|
+
}
|
|
655
|
+
const shown = run ? publicRun(run, 0) : { session_id }
|
|
619
656
|
if (!Array.isArray(answers) || !answers.length) throw new Error('answers must list at least one answer')
|
|
620
657
|
const staged = new Map()
|
|
621
658
|
for (const a of answers) {
|
|
@@ -639,10 +676,10 @@ export function apply(ctx) {
|
|
|
639
676
|
const remaining = entry.items.filter((q) => !entry.answers.has(q.id)).map((q) => q.id)
|
|
640
677
|
if (remaining.length) {
|
|
641
678
|
for (const r of entry.runs) { if (r.question?.question_id === entry.id) { r.question = publicQuestion(entry); persist(r) } }
|
|
642
|
-
return { ...
|
|
679
|
+
return { ...shown, submitted: false, remaining }
|
|
643
680
|
}
|
|
644
681
|
entry.resolve({ answers: entry.items.map((q) => entry.answers.get(q.id)) })
|
|
645
|
-
return { ...
|
|
682
|
+
return { ...shown, submitted: true, answers: entry.items.map((q) => entry.answers.get(q.id)) }
|
|
646
683
|
}
|
|
647
684
|
|
|
648
685
|
ctx.on('user-questions/request', guardWaterfall(onQuestion), { global: true, prepend: true })
|
|
@@ -720,6 +757,249 @@ export function apply(ctx) {
|
|
|
720
757
|
return { session_id, ...(isArchived(session_id) ? { archived: true } : {}), cwd: meta?.cwd ?? null, title, total_messages: messages.length, messages: messages.slice(-last) }
|
|
721
758
|
}
|
|
722
759
|
|
|
760
|
+
// --- Harness goals --------------------------------------------------------------
|
|
761
|
+
// A session with a goal starts its next round by itself a few ms after a turn ends, until
|
|
762
|
+
// the goal is complete, paused or blocked, or its rounds run out. The goal's durable state is
|
|
763
|
+
// a session projection (values.goal). Whether this process will really go on with it
|
|
764
|
+
// (activation: armed, or disarmed, e.g. after a Harness restart until a person resumes it)
|
|
765
|
+
// is not in it: only Harness's goals service and its goal/activation-changed event say.
|
|
766
|
+
|
|
767
|
+
let goalsService = null
|
|
768
|
+
let agentsService = null
|
|
769
|
+
const activations = new Map() // sessionId -> 'armed' | 'disarmed', from goal/activation-changed
|
|
770
|
+
ctx.on('goal/activation-changed', guard('goal activation', ({ sessionId, goal } = {}) => {
|
|
771
|
+
if (!sessionId) return
|
|
772
|
+
if (goal?.activation) activations.set(sessionId, goal.activation)
|
|
773
|
+
else activations.delete(sessionId)
|
|
774
|
+
wakeFollowers(sessionId)
|
|
775
|
+
}))
|
|
776
|
+
|
|
777
|
+
// armed, disarmed, or null when this Harness does not say. A session not loaded (no live
|
|
778
|
+
// agent, as after a Harness restart until it is opened) runs nothing: disarmed.
|
|
779
|
+
function activationOf(sessionId, goalId) {
|
|
780
|
+
try {
|
|
781
|
+
if (agentsService && typeof goalsService?.get === 'function') {
|
|
782
|
+
const agent = agentsService.get(sessionId)
|
|
783
|
+
if (!agent) return 'disarmed'
|
|
784
|
+
const view = goalsService.get(agent)
|
|
785
|
+
if (view?.id === goalId && view.activation) return view.activation
|
|
786
|
+
}
|
|
787
|
+
} catch { /* not live here: the event is all there is */ }
|
|
788
|
+
return activations.get(sessionId) ?? null
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const clipText = (s, n) => (typeof s === 'string' && s.length > n ? `${s.slice(0, n)}…` : s ?? null)
|
|
792
|
+
|
|
793
|
+
// The session's current goal, null (none, or this Harness has no goals), or undefined when
|
|
794
|
+
// the projection could not be read just now (not the same as no goal).
|
|
795
|
+
async function goalOf(sessionId) {
|
|
796
|
+
let current
|
|
797
|
+
if (typeof controller.projections !== 'function') return null
|
|
798
|
+
try {
|
|
799
|
+
const p = await controller.projections({ sessionId }, signal())
|
|
800
|
+
// The projection's wire view is the current goal itself (null when none); the stored
|
|
801
|
+
// state, as the projection cache keeps it, wraps it in { current, seenGoalIds, failure }.
|
|
802
|
+
const v = p?.values?.goal
|
|
803
|
+
current = v && typeof v === 'object' && 'current' in v ? v.current : v
|
|
804
|
+
} catch { return undefined }
|
|
805
|
+
const g = current?.goal
|
|
806
|
+
if (!g?.id) return null
|
|
807
|
+
const view = {
|
|
808
|
+
id: g.id, phase: g.phase, objective: clipText(g.objective, 300),
|
|
809
|
+
rounds_started: current.roundsStarted ?? 0, max_rounds: g.maxGoalRounds ?? null,
|
|
810
|
+
activation: activationOf(sessionId, g.id),
|
|
811
|
+
...(g.blockedReason ? { blocked_reason: { code: g.blockedReason.code, message: g.blockedReason.message } } : {}),
|
|
812
|
+
}
|
|
813
|
+
// When it last changed, for follow() only (left out of results).
|
|
814
|
+
return Object.defineProperty(view, 'changedAt', { value: Number(current.updatedAt) || 0 })
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
// A session log's turns, oldest first: when each ended, its goal round (when a goal started
|
|
819
|
+
// it), its closing text and the tools it called.
|
|
820
|
+
function turnsOf(events) {
|
|
821
|
+
const ended = []
|
|
822
|
+
let cur = null
|
|
823
|
+
for (const e of events ?? []) {
|
|
824
|
+
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 }
|
|
825
|
+
else if (!cur) continue
|
|
826
|
+
else if (e.type === 'user/message' && e.data?.source?.kind === 'goal') cur.round = e.data.source.round ?? null
|
|
827
|
+
else if (e.type === 'assistant/message') {
|
|
828
|
+
const content = e.data?.message?.content
|
|
829
|
+
const text = textOf(content)
|
|
830
|
+
if (text) cur.text = text
|
|
831
|
+
for (const call of toolCallsOf(content)) { const n = call.name ?? call.kind; cur.tools[n] = (cur.tools[n] ?? 0) + 1 }
|
|
832
|
+
} else if (e.type === 'turn/end') {
|
|
833
|
+
ended.push({ ...cur, ended_at: iso(e.time) ?? new Date().toISOString(), end: e.data?.reason?.kind ?? null })
|
|
834
|
+
cur = null
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return { ended, running: cur !== null, runningSince: cur?.started_at ?? null }
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const MAX_ROUNDS_SHOWN = 100
|
|
841
|
+
const shownRound = (t) => ({
|
|
842
|
+
turn: t.turn, ...(t.round != null ? { goal_round: t.round } : {}), ended_at: t.ended_at,
|
|
843
|
+
...(t.end && t.end !== 'completed' ? { ended_as: t.end } : {}),
|
|
844
|
+
final_text: clipText(t.text, 1500), ...(Object.keys(t.tools).length ? { tools: t.tools } : {}),
|
|
845
|
+
})
|
|
846
|
+
// An earlier round of a report, in one line: the latest round's reply usually sums up the
|
|
847
|
+
// goal so far, and a client reads any round in full with dsh_read_session.
|
|
848
|
+
const digestRound = (t) => ({
|
|
849
|
+
turn: t.turn, ...(t.round != null ? { goal_round: t.round } : {}),
|
|
850
|
+
...(t.end && t.end !== 'completed' ? { ended_as: t.end } : {}),
|
|
851
|
+
summary: clipText(t.text?.replace(/\s+/g, ' ').trim() || null, 100), ...(Object.keys(t.tools).length ? { tools: t.tools } : {}),
|
|
852
|
+
})
|
|
853
|
+
// Every round since the last report: the latest in full, the ones before as digests (up to
|
|
854
|
+
// MAX_ROUNDS_SHOWN, then a count), so no round goes missing however long the wait was.
|
|
855
|
+
const shownRounds = (list) => {
|
|
856
|
+
const earlier = list.slice(0, -1)
|
|
857
|
+
const kept = earlier.slice(-(MAX_ROUNDS_SHOWN - 1))
|
|
858
|
+
return {
|
|
859
|
+
rounds: [...kept.map(digestRound), ...list.slice(-1).map(shownRound)],
|
|
860
|
+
...(earlier.length > kept.length ? { earlier_rounds_not_shown: earlier.length - kept.length } : {}),
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Where a follow wait has got to: rounds already reported, and when it last reported.
|
|
865
|
+
const cursorOf = (mark) => `${mark.rounds}:${mark.at}`
|
|
866
|
+
function parseCursor(cursor) {
|
|
867
|
+
const m = /^(\d+):(\d+)$/.exec(String(cursor ?? ''))
|
|
868
|
+
return m ? { rounds: Number(m[1]), at: Number(m[2]) } : null
|
|
869
|
+
}
|
|
870
|
+
const GOAL_QUIET_MS = Number(process.env.DSH_BATON_GOAL_QUIET_MS) || 5 * 60_000
|
|
871
|
+
// How long a stopped goal waits for the round that stopped it to show its end in the log.
|
|
872
|
+
const GOAL_SETTLE_MS = Number(process.env.DSH_BATON_GOAL_SETTLE_MS) || 15_000
|
|
873
|
+
// The question or Harness UI wait a follower was already told about (one report per episode).
|
|
874
|
+
const noticed = new Map() // sessionId -> { key, at }
|
|
875
|
+
// A question still open is reported again to a call that comes back after this long (the
|
|
876
|
+
// report may never have reached anyone); a Harness UI wait once per episode.
|
|
877
|
+
const RENOTICE_MS = 60_000
|
|
878
|
+
// A waiting status with no question yet: the question usually follows within this time.
|
|
879
|
+
const QUESTION_GRACE_MS = 500
|
|
880
|
+
|
|
881
|
+
// Wait on a session's goal without sending anything. until "goal": until the goal stops
|
|
882
|
+
// (complete, paused, blocked, cleared). since_ms (without a cursor): count from then, e.g.
|
|
883
|
+
// a run's start, so its own round is in the report too. until "turn": at the first round end once every_s
|
|
884
|
+
// have passed since the last report, with every round since then. Both return at once when
|
|
885
|
+
// Harness needs a person, or the goal will not go on by itself (disarmed; when that cannot
|
|
886
|
+
// be read, no new round for GOAL_QUIET_MS). The cursor carries what was reported, so a
|
|
887
|
+
// next call, in either mode, neither repeats nor misses a round.
|
|
888
|
+
async function follow({ session_id, until = 'goal', cursor, since_ms, every_s = 600, timeout_s = 300 }) {
|
|
889
|
+
if (!session_id) throw new Error('session_id is required')
|
|
890
|
+
if (until !== 'goal' && until !== 'turn') throw new Error('until must be goal or turn')
|
|
891
|
+
if (typeof controller.projections !== 'function') throw new Error('This DeepSeek Harness version has no session projections, so its goals cannot be followed.')
|
|
892
|
+
const every = Math.max(1, Number(every_s) || 600) * 1000
|
|
893
|
+
const seconds = Number.isFinite(Number(timeout_s)) ? Math.min(Math.max(Number(timeout_s), 0), MAX_WAIT_S) : 300
|
|
894
|
+
const deadline = Date.now() + seconds * 1000
|
|
895
|
+
let mark = parseCursor(cursor)
|
|
896
|
+
const since = !mark && Number(since_ms) > 0 ? Number(since_ms) : null
|
|
897
|
+
let sawGoal = false
|
|
898
|
+
for (;;) {
|
|
899
|
+
// Followed from now on (a question that comes while the log is read is taken up), and
|
|
900
|
+
// the events so far, to tell whether one came during the read.
|
|
901
|
+
followedUntil.set(session_id, Date.now() + 60_000)
|
|
902
|
+
const gen = eventGen.get(session_id) ?? 0
|
|
903
|
+
const [goal, log] = await Promise.all([goalOf(session_id), controller.inspect(session_id, signal())])
|
|
904
|
+
const { ended, running, runningSince } = turnsOf(log?.events)
|
|
905
|
+
// A first call counts from the start of the round under way, so what it already wrote is
|
|
906
|
+
// reported; one that comes after the goal stopped, from the start of the last round.
|
|
907
|
+
mark ??= since
|
|
908
|
+
? { rounds: ended.filter((t) => Date.parse(t.ended_at) < since).length, at: since }
|
|
909
|
+
: goal && goal.phase !== 'active' && !running && ended.length
|
|
910
|
+
? { rounds: ended.length - 1, at: ended.at(-1).started_at ?? Date.parse(ended.at(-1).ended_at) }
|
|
911
|
+
: { rounds: ended.length, at: running && runningSince ? Math.min(runningSince, Date.now()) : Date.now() }
|
|
912
|
+
// The goal could not be read just now: decide nothing, look again shortly.
|
|
913
|
+
if (goal === undefined) {
|
|
914
|
+
if (stopping || Date.now() >= deadline) return { session_id, state: 'following', until, cwd: log?.meta?.cwd ?? null, since: iso(mark.at), rounds_since_report: ended.length - Math.min(mark.rounds, ended.length), timed_out: true, cursor: cursorOf(mark) }
|
|
915
|
+
await new Promise((r) => setTimeout(r, 2000))
|
|
916
|
+
continue
|
|
917
|
+
}
|
|
918
|
+
if (goal) sawGoal = true
|
|
919
|
+
const fresh = ended.slice(Math.min(mark.rounds, ended.length))
|
|
920
|
+
// A call that comes back in between (timed out, or Harness needs a person) names how many
|
|
921
|
+
// rounds wait, not their texts, and keeps the cursor: the rounds come once, in the report.
|
|
922
|
+
const out = (state, extra = {}) => {
|
|
923
|
+
const between = !!extra.timed_out || state === 'needs_input'
|
|
924
|
+
followedUntil.set(session_id, Date.now() + 60_000)
|
|
925
|
+
return {
|
|
926
|
+
session_id, state, until, goal, cwd: log?.meta?.cwd ?? null, since: iso(mark.at),
|
|
927
|
+
...(between ? { rounds_since_report: fresh.length } : shownRounds(fresh)), ...extra,
|
|
928
|
+
cursor: between ? cursorOf(mark) : cursorOf({ rounds: ended.length, at: Date.now() }),
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
// A goal gone since a call that had one was cleared. Without one so far, the turn that
|
|
932
|
+
// sends "/goal …" creates it partway through: no_goal only once no turn is running.
|
|
933
|
+
const goalChanges = (log?.events ?? []).filter((e) => e.type === 'goal/change')
|
|
934
|
+
const hadGoal = sawGoal || ((!!cursor || !!since) && goalChanges.length > 0)
|
|
935
|
+
const endedBecause = !hadGoal ? null : !goal ? 'cleared' : goal.phase !== 'active' ? goal.phase : null
|
|
936
|
+
const lastEnd = ended.length ? Date.parse(ended.at(-1).ended_at) : 0
|
|
937
|
+
// From state the cursor carries, so the quiet also adds up across a client's short calls.
|
|
938
|
+
const quietSince = Math.max(lastEnd, mark.at)
|
|
939
|
+
// The round that stopped the goal (or used its last round) often still finishes its reply
|
|
940
|
+
// after that, and the log may not show that round running yet: settled once no round runs
|
|
941
|
+
// and one has ended since the goal changed, or GOAL_SETTLE_MS after the change.
|
|
942
|
+
// A cleared goal has no view left: when its last change was logged.
|
|
943
|
+
const changedAt = goal?.changedAt ?? (Date.parse(iso(goalChanges.at(-1)?.time)) || 0)
|
|
944
|
+
const settled = !running && (lastEnd >= changedAt || Date.now() - changedAt >= GOAL_SETTLE_MS)
|
|
945
|
+
const atLimit = !!(goal?.max_rounds && goal.rounds_started >= goal.max_rounds)
|
|
946
|
+
const question = questions.get(session_id)
|
|
947
|
+
const waitedFor = waitingSessions.has(session_id) ? Date.now() - (waitingSince.get(session_id) ?? 0) : null
|
|
948
|
+
if (!hadGoal) {
|
|
949
|
+
if (!running || stopping) return out('no_goal', { ended_because: null })
|
|
950
|
+
} else if (endedBecause) {
|
|
951
|
+
// Out of time before that round ends: a timed-out call, so the next one (same cursor)
|
|
952
|
+
// still waits for it; ending here would lose its reply.
|
|
953
|
+
if (settled || stopping) return out('goal_ended', { ended_because: endedBecause })
|
|
954
|
+
} else {
|
|
955
|
+
if (atLimit && settled) return out('goal_ended', { ended_because: 'round_limit' })
|
|
956
|
+
// A waiting status with no question yet is an approval in the Harness UI only once the
|
|
957
|
+
// question had time to come; an open question again to a call that comes back later.
|
|
958
|
+
const key = question ? `q:${question.id}` : waitedFor >= QUESTION_GRACE_MS ? `ui:${uiEpisodes.get(session_id)}` : null
|
|
959
|
+
const seen = noticed.get(session_id)
|
|
960
|
+
if (key && (seen?.key !== key || (question && Date.now() - seen.at >= RENOTICE_MS))) {
|
|
961
|
+
noticed.set(session_id, { key, at: Date.now() })
|
|
962
|
+
return out('needs_input', question ? { needs_input: 'answer', question: publicQuestion(question) } : { needs_input: 'harness-ui' })
|
|
963
|
+
}
|
|
964
|
+
if (goal.activation === 'disarmed') return out('needs_resume')
|
|
965
|
+
if (goal.activation == null && !running && Date.now() - quietSince >= GOAL_QUIET_MS) return out('goal_stalled')
|
|
966
|
+
if (until === 'turn' && fresh.length && (lastEnd >= mark.at + every || (Date.now() >= mark.at + every && !running))) return out('report')
|
|
967
|
+
}
|
|
968
|
+
if (stopping || Date.now() >= deadline) return out('following', { timed_out: true })
|
|
969
|
+
// Something happened while the log was read: read it again rather than sleep past it.
|
|
970
|
+
if ((eventGen.get(session_id) ?? 0) !== gen) continue
|
|
971
|
+
// Sleep until something happens, the next report is due, or the quiet runs out.
|
|
972
|
+
const due = [deadline, ...(until === 'turn' ? [mark.at + every] : []), ...(goal && goal.activation == null && !running ? [quietSince + GOAL_QUIET_MS] : []), ...((endedBecause || atLimit) && changedAt ? [changedAt + GOAL_SETTLE_MS] : []), ...(waitedFor != null && waitedFor < QUESTION_GRACE_MS ? [Date.now() - waitedFor + QUESTION_GRACE_MS] : [])]
|
|
973
|
+
const wakeAt = Math.max(Date.now() + 50, Math.min(...due.filter((t) => t > Date.now())))
|
|
974
|
+
await new Promise((resolve) => {
|
|
975
|
+
const set = followers.get(session_id) ?? new Set()
|
|
976
|
+
followers.set(session_id, set)
|
|
977
|
+
const done = () => {
|
|
978
|
+
clearTimeout(timer)
|
|
979
|
+
set.delete(done)
|
|
980
|
+
if (!set.size && followers.get(session_id) === set) followers.delete(session_id)
|
|
981
|
+
resolve()
|
|
982
|
+
}
|
|
983
|
+
const timer = setTimeout(done, wakeAt - Date.now())
|
|
984
|
+
set.add(done)
|
|
985
|
+
})
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// A session at a glance, for a client that holds only its id: what it is doing, its goal
|
|
990
|
+
// and the reply of its last turn.
|
|
991
|
+
async function sessionStatus({ session_id }) {
|
|
992
|
+
const [act, goal, log] = await Promise.all([activity({ session_id }), goalOf(session_id), controller.inspect(session_id, signal()).catch(() => null)])
|
|
993
|
+
const { ended, running } = turnsOf(log?.events)
|
|
994
|
+
const question = questions.get(session_id)
|
|
995
|
+
return {
|
|
996
|
+
...act, turn_running: running,
|
|
997
|
+
...(goal ? { goal } : {}),
|
|
998
|
+
...(question ? { needs_input: 'answer', question: publicQuestion(question) } : waitingSessions.has(session_id) ? { needs_input: 'harness-ui' } : {}),
|
|
999
|
+
...(ended.length ? { last_turn: shownRound(ended.at(-1)) } : {}),
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
723
1003
|
// --- models -----------------------------------------------------------------
|
|
724
1004
|
// Harness's own catalog decides which names exist: "V4 Pro", "v4-pro" and "DeepSeek-V4-Pro"
|
|
725
1005
|
// all name the model whose id or name reads that way once case, spaces, hyphens,
|
|
@@ -885,6 +1165,15 @@ export function apply(ctx) {
|
|
|
885
1165
|
throw new Error(`Session ${sessionId} is archived. Unarchive it in Harness, or start a new session with cwd or workspace_id.`)
|
|
886
1166
|
}
|
|
887
1167
|
if (!sessionId && !cwd && !workspace_id) throw new Error('A new session needs cwd or workspace_id. Pass session_id to continue an existing one.')
|
|
1168
|
+
// A missing folder is not created: a mistyped path, or a project on a drive that is not
|
|
1169
|
+
// connected, would get an empty folder (under /Volumes, even). Checked before the registry,
|
|
1170
|
+
// which answers a bare ENOENT, or nothing at all for a folder registered while it was there.
|
|
1171
|
+
if (!sessionId) {
|
|
1172
|
+
const dir = workspace_id ? registry.get?.(workspace_id)?.path : normalizePath(cwd)
|
|
1173
|
+
if (dir && !isDirectory(dir)) {
|
|
1174
|
+
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.`)
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
888
1177
|
const plan = await planModel(sessionId, { model, reasoning_effort })
|
|
889
1178
|
const isNew = !sessionId
|
|
890
1179
|
if (isNew) {
|
|
@@ -969,7 +1258,18 @@ export function apply(ctx) {
|
|
|
969
1258
|
run.waitNoticed = true
|
|
970
1259
|
return { ...publicRun(run), needs_input: run.question ? 'answer' : 'harness-ui' }
|
|
971
1260
|
}
|
|
972
|
-
return { ...publicRun(run), ...(done ?
|
|
1261
|
+
return { ...publicRun(run), ...(done ? await goalField(run) : { timed_out: true }) }
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// A run that completed in a session with a goal: the goal goes on after it.
|
|
1265
|
+
// The session's goal, for whoever waited on a run: one that goes on after it, or one that
|
|
1266
|
+
// stopped since the run began (its later rounds belong to the run's work: ended_since_run).
|
|
1267
|
+
// A goal complete before the run is left out.
|
|
1268
|
+
const goalField = async (run) => {
|
|
1269
|
+
const goal = run.state === 'completed' ? await goalOf(run.sessionId) : null
|
|
1270
|
+
if (!goal) return {}
|
|
1271
|
+
const since = goal.phase !== 'active' && goal.changedAt >= (Date.parse(run.startedAt) || Infinity)
|
|
1272
|
+
return goal.phase !== 'complete' || since ? { goal: since ? { ...goal, ended_since_run: true } : goal } : {}
|
|
973
1273
|
}
|
|
974
1274
|
|
|
975
1275
|
// What a session is doing right now, as Harness reports it for archiving:
|
|
@@ -1045,7 +1345,12 @@ export function apply(ctx) {
|
|
|
1045
1345
|
read_session: readSession,
|
|
1046
1346
|
models,
|
|
1047
1347
|
send,
|
|
1048
|
-
status: ({ run_id, tail }) =>
|
|
1348
|
+
status: async ({ run_id, session_id, tail }) => {
|
|
1349
|
+
if (!run_id && session_id) return sessionStatus({ session_id })
|
|
1350
|
+
const run = getRun(run_id)
|
|
1351
|
+
return { ...publicRun(run, tail ?? 20), ...(isActive(run) ? {} : await goalField(run)) }
|
|
1352
|
+
},
|
|
1353
|
+
follow,
|
|
1049
1354
|
wait,
|
|
1050
1355
|
cancel,
|
|
1051
1356
|
activity,
|
|
@@ -1132,6 +1437,14 @@ export function apply(ctx) {
|
|
|
1132
1437
|
defaultModelService = child.agentDefaultModel
|
|
1133
1438
|
child.effect(() => () => { if (defaultModelService === child.agentDefaultModel) defaultModelService = null }, 'dsh-baton-host default model')
|
|
1134
1439
|
})
|
|
1440
|
+
ctx.inject(['goals'], (child) => {
|
|
1441
|
+
goalsService = child.goals
|
|
1442
|
+
child.effect(() => () => { if (goalsService === child.goals) goalsService = null }, 'dsh-baton-host goals')
|
|
1443
|
+
})
|
|
1444
|
+
ctx.inject(['agents'], (child) => {
|
|
1445
|
+
agentsService = child.agents
|
|
1446
|
+
child.effect(() => () => { if (agentsService === child.agents) agentsService = null }, 'dsh-baton-host agents')
|
|
1447
|
+
})
|
|
1135
1448
|
ctx.inject(['webServer'], (web) => {
|
|
1136
1449
|
web.effect(() => web.webServer.register({
|
|
1137
1450
|
kind: 'exact',
|
|
@@ -1168,6 +1481,7 @@ export function apply(ctx) {
|
|
|
1168
1481
|
// unloaded or reloaded plugin leaves no timers or sockets behind.
|
|
1169
1482
|
for (const set of waiters.values()) for (const done of [...set]) done()
|
|
1170
1483
|
waiters.clear()
|
|
1484
|
+
for (const set of followers.values()) for (const done of [...set]) done()
|
|
1171
1485
|
for (const conn of connections) conn.destroy()
|
|
1172
1486
|
if (!server) return
|
|
1173
1487
|
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.2",
|
|
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.2"
|
|
24
24
|
},
|
|
25
25
|
"license": "MIT",
|
|
26
26
|
"dsh": {
|