dsh-baton-mcp 0.6.4 → 0.6.5-next.1

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