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 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 opened 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. 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.
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 starts and keeps it: new conversations get it without restarting Codex, while conversations already open keep Codex's 300 s limit, so dsh-baton, which tells them apart by when they started (`~/.dsh-baton/codex-timeout.json` holds when the value was written), keeps their waits at 270 s.
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`:它的终端无法在命令结束时唤醒它,只能轮询。插件设好超时之前就开着的对话,或者你自己写的、没有这项设置的条目,每次最多等 270 秒,刚好低于 Codex 默认 300 秒的工具超时。实测(Codex 桌面端):28 分钟的任务只调了 1 次 `dsh_wait`;62 分钟的任务调了 2 次,第一次等满 59 分钟返回 `timed_out`,接着再等 3 分钟拿到结果;期间没有调用 `dsh_status`。
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 在新开对话时读取这项设置,并且一直沿用:新对话不用重启 Codex 就能用上,已经开着的对话仍然是 Codex 的 300 秒上限。dsh-baton 按对话的开始时间区分(`~/.dsh-baton/codex-timeout.json` 记着这项设置写入的时间),这些对话每次照旧只等 270 秒。
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
- Update / 更新: uninstall, then add it again; the MCP server is updated with it / 先卸载再重新添加,MCP 服务器会一起更新.
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.svg" 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">
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
- if (data.source?.kind === 'user' && opensTurn && run?.mode !== 'steer') {
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
- if (!asked.length) return next()
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. answers: [{ id?, choose?: [number | label],
611
- // text?, skip? }]; questions not answered yet stay open until every one has an answer.
612
- function answerQuestion({ run_id, answers }) {
613
- const run = getRun(run_id)
614
- const entry = questions.get(run.sessionId)
615
- if (!isActive(run) || !entry || run.question?.question_id !== entry.id) {
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 { ...publicRun(run, 0), submitted: false, remaining }
679
+ return { ...shown, submitted: false, remaining }
643
680
  }
644
681
  entry.resolve({ answers: entry.items.map((q) => entry.answers.get(q.id)) })
645
- return { ...publicRun(run, 0), submitted: true, answers: entry.items.map((q) => entry.answers.get(q.id)) }
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 ? {} : { timed_out: true }) }
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 }) => publicRun(getRun(run_id), tail ?? 20),
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.4",
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.4"
23
+ "dsh-baton-mcp": "0.6.5-next.2"
24
24
  },
25
25
  "license": "MIT",
26
26
  "dsh": {