dsh-baton-mcp 0.6.7 → 0.6.8

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
@@ -153,6 +153,7 @@ Nothing to configure for waiting; if you want to know what each client does whil
153
153
 
154
154
  - **Claude Code** waits for a run in the background (the `wait_command` that `dsh_send` returns) and is woken once, when the run ends.
155
155
  - **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.
156
+ - **Several runs at once.** When a task is split across sessions (say docs, front end and back end, each in its own worktree), the client sends them all, then waits for them together: Claude Code runs one `wait_command` per run in the background, and Codex calls `dsh_wait` with `run_ids`. That call returns as soon as any of the runs ends or stops to ask a person, with every run that has (`ended`) and the ones still going (`still_running`); the client reviews those, sends fixes right away, and waits again with the rest plus the fixes. Before 0.6.8 Codex waited for one run at a time, so a run that ended meanwhile sat unread: measured on a three-part task, a back end that ended first was read 2 min 53 s late and its rework went out late; with `run_ids` each result was read within a second of its run ending.
156
157
  - **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.
157
158
  - **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.
158
159
 
@@ -194,7 +195,7 @@ The server's MCP instructions are a single sentence (from 0.6.2); how to hand ov
194
195
  | `dsh_read_session` | Recent prompts and replies of one session (with tool names); `archived: true` for an archived session |
195
196
  | `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` |
196
197
  | `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 |
197
- | `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) |
198
+ | `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). With `run_ids` it waits for several runs and returns as soon as any ends or asks (`ended`, `still_running`) |
198
199
  | `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 |
199
200
  | `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 |
200
201
  | `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 |
package/README.zh-CN.md CHANGED
@@ -153,6 +153,7 @@ Codex 和 Claude 轮流往同一个 Harness 对话发消息,每条都标明是
153
153
 
154
154
  - **Claude Code** 在后台等待任务(`dsh_send` 返回的 `wait_command`),任务结束时只被唤醒一次。
155
155
  - **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`。
156
+ - **同时等多个任务**。一个任务拆给几个会话做时(比如接口文档、前端、后端,各在自己的 worktree 里),客户端先全部发出去,再一起等:Claude Code 给每个任务在后台各开一个 `wait_command`,Codex 调一次带 `run_ids` 的 `dsh_wait`。只要其中任何一个做完或停下来问人,这次调用就立刻返回,带上所有已经做完的(`ended`)和还在跑的(`still_running`);客户端检查这些结果、马上发返修,再带着剩下的和返修的一起接着等。0.6.8 之前 Codex 一次只能等一个任务,别的任务做完了也只能放着:实测一个分三部分的任务,最先做完的后端晚了 2 分 53 秒才被看到,返修也跟着晚发;用 `run_ids` 后,每个结果都在做完 1 秒内拿到。
156
157
  - **带 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 分钟内就能被看到;这种中途返回只带目标状态和还有几轮没报告,不带各轮全文。
157
158
  - **等待中想改需求**。Codex:在输入框里用**引导**发出新要求,Codex 会立刻停止等待,用 `dsh_send`(`mode` 为 `steer`)把它插进同一个 Harness 对话,改等新的任务,Harness 在同一轮里把新要求一起做完;用**排队**发的话,要等这次任务做完才会被看到。也可以先按停止再发。Claude Code:随时发就行,它在后台等待,马上就能把新要求插队转给 Harness。插队的任务和原任务同时结束,共用一份结果;排队的任务单独返回。同一个文件夹里同时跑多个任务时,结果里会标出其他任务,不会删掉别人的文件。
158
159
 
@@ -194,7 +195,7 @@ Codex 和 Claude 轮流往同一个 Harness 对话发消息,每条都标明是
194
195
  | `dsh_read_session` | 读某个对话最近的消息和回复(含用到的工具名);已归档的对话会带 `archived: true` |
195
196
  | `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` 写明用的是哪个 |
196
197
  | `dsh_status` | 查看运行进度:状态、最近的文字和工具调用、最终回复;回合结束但子智能体/后台任务还在跑时给出 `waiting_on`;中途有人在 Harness 界面里发消息时给出 `human_interjections`;被 Harness 重启打断的任务状态为 `interrupted`;以及这次运行发送时用的 `model`。用 `session_id` 代替 `run_id` 时:会话正在做什么、它的 Harness 目标,以及上一回合的回复 |
197
- | `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`;见“各客户端怎么等待”) |
198
+ | `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`;见“各客户端怎么等待”)。用 `run_ids` 时同时等多个任务,任何一个做完或提问就返回(`ended`、`still_running`) |
198
199
  | `dsh_answer` | 回答运行中途提出的问题(Harness 的 ask_user_question):每道题可以填选项编号或选项文字、自由填写,或者跳过;可以逐题回答,也可以一次答完。运行一提问,`dsh_wait` 和 `wait_command` 就会立刻返回,并带上题目(`needs_input`、`question`;Harness 那边模型推荐的选项单独放在 `harness_suggested` 里,不再写在选项文字中)。只有你说过让它自己决定时,助手才会按自己的判断回答,这时它会带上 `reason`,并告诉你选了什么、为什么;否则它会把题目转给你,开头先写它自己的建议和理由。你也可以直接在 Harness 窗口里回答,谁先答就算谁的 |
199
200
  | `dsh_session_activity` | 经 `dsh_call` 调用。对话此刻在做什么:正在跑的回合、子智能体(任意层级)、后台任务;已归档的对话会带 `archived: true` |
200
201
  | `dsh_update_queued` | 经 `dsh_call` 调用。按 `run_id` 修改还在忙碌对话队列里等待的消息:`edit` 改文字,`remove` 撤回,`steer` 立刻插进正在跑的回合。消息一旦进入对话就改不了了 |
package/host/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-baton-host",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
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.7"
23
+ "dsh-baton-mcp": "0.6.8"
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.7",
3
+ "version": "0.6.8",
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.7",
10
+ "version": "0.6.8",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "dsh-baton-mcp",
15
- "version": "0.6.7",
15
+ "version": "0.6.8",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
package/src/mcp.mjs CHANGED
@@ -97,12 +97,18 @@ const followCommand = (sid, until = 'goal', cursor, everyMin) => (/^[\w.-]+$/.te
97
97
  // A run in a session with a Harness goal is waited for until the goal stops.
98
98
  const GOAL_WAIT_HINT = (how) => `In a session with a Harness goal (a message starting with /goal sets one) the wait goes on until the goal stops; add ${how} only when the user asked for progress reports.`
99
99
  const FOLLOW_COMMAND_HINT = ' Your client wakes you when a background command exits, so run follow_command that way instead of calling dsh_wait with session_id: it ends at the next report, with no polling.'
100
+ // Several runs in parallel: one dsh_wait for all of them, so whichever ends first is dealt with
101
+ // first. Waiting on one run at a time left a run that ended meanwhile unseen until the other
102
+ // wait returned (measured 2026-11-08: a result read 3 minutes late, its rework sent late).
103
+ const PARALLEL_HINT = 'If you are sending several parts in parallel, send them all first, then wait for them together: one dsh_wait with run_ids set to all their run_ids, never one run after another.'
100
104
 
101
105
  // Wait in short chunks, reporting progress between them (clients that support it can
102
106
  // show it or reset their timer). An unfinished wait returns only the state, not the run's
103
107
  // recent events, so the client has nothing to summarise and simply waits again.
104
108
  // A call the client cancelled (the user stopped the turn) stops at the next chunk.
105
109
  async function waitInChunks({ run_id }, progress, signal, meta) {
110
+ // An answer a multi-run wait got after it had returned (a question is reported only once).
111
+ if (late.has(run_id)) return core.waitResult(takeLate(run_id))
106
112
  if (isCodex()) await lookUpCodexStart()
107
113
  const total = waitSeconds(meta ?? null)
108
114
  const started = Date.now()
@@ -128,6 +134,81 @@ async function waitInChunks({ run_id }, progress, signal, meta) {
128
134
  }
129
135
  }
130
136
 
137
+ // Several runs (dsh_wait run_ids): returns as soon as any of them ends or stops to ask a person,
138
+ // with every one that has by then and the ones still running. Each round asks about all of them
139
+ // together for a short chunk, so an ending is seen within MULTI_CHUNK_S. Every answer is kept:
140
+ // a wait that sees a question marks it as seen, so dropping one would lose the question.
141
+ const MULTI_CHUNK_S = 5
142
+ // Runs that end within this of the first come back with it.
143
+ const TOGETHER_MS = 200
144
+ const isDone = (st) => !!st.needs_input || !st.timed_out
145
+ // A chunk's wait for another run can answer after the multi-run wait has returned. The host
146
+ // reports a question only once, so such an answer is kept here and given to the next wait.
147
+ const late = new Map()
148
+ const takeLate = (run_id) => { const st = late.get(run_id); late.delete(run_id); return st }
149
+ async function waitAnyInChunks(run_ids, progress, signal, meta) {
150
+ if (isCodex()) await lookUpCodexStart()
151
+ const total = waitSeconds(meta ?? null)
152
+ const started = Date.now()
153
+ const deadline = started + total * 1000
154
+ for (;;) {
155
+ if (signal?.aborted) return { run_ids, cancelled: true }
156
+ const left = Math.ceil((deadline - Date.now()) / 1000)
157
+ const t0 = Date.now()
158
+ const timeout_s = Math.max(1, Math.min(MULTI_CHUNK_S, left))
159
+ const answers = new Map()
160
+ const asks = run_ids.map((run_id) => (late.has(run_id)
161
+ ? Promise.resolve(takeLate(run_id))
162
+ : core.api.wait({ run_id, timeout_s }).then((st) => ({ run_id, ...st }), (error) => ({ run_id, state: 'unknown', error: error?.message ?? String(error) })))
163
+ .then((st) => { answers.set(run_id, st); return st }))
164
+ // On until one is done or every one has answered, then a moment for any ending with it.
165
+ await new Promise((resolve) => {
166
+ let n = 0
167
+ for (const ask of asks) ask.then((st) => { n += 1; if (isDone(st) || n === asks.length) resolve() })
168
+ })
169
+ if (answers.size < asks.length) await new Promise((r) => setTimeout(r, TOGETHER_MS))
170
+ const done = [...answers.values()].filter(isDone)
171
+ if (done.length) {
172
+ run_ids.forEach((run_id, i) => {
173
+ if (!answers.has(run_id)) asks[i].then((st) => { if (isDone(st)) late.set(run_id, st) })
174
+ })
175
+ const running = run_ids.filter((run_id) => !done.some((st) => st.run_id === run_id)).map((run_id) => {
176
+ const st = answers.get(run_id) ?? { run_id, state: 'running' }
177
+ return { run_id, state: st.state, ...(st.session_id ? { session_id: st.session_id } : {}) }
178
+ })
179
+ return { done, running }
180
+ }
181
+ const running = [...answers.values()].map((st) => ({ run_id: st.run_id, state: st.state, ...(st.session_id ? { session_id: st.session_id } : {}) }))
182
+ if (Date.now() - t0 < 500) await new Promise((r) => setTimeout(r, 500))
183
+ if (Date.now() >= deadline) {
184
+ const asking = running.filter((r) => r.state === 'waiting').map((r) => r.run_id)
185
+ return {
186
+ run_ids, timed_out: true, still_running: running,
187
+ hint: `None of these runs has ended yet. Call dsh_wait again with the same run_ids, with no progress check or progress note in between (work the user asked you to do meanwhile is fine).${asking.length ? ` ${asking.join(', ')} still wait${asking.length === 1 ? 's' : ''} for a person (see dsh_status for the question): answer with dsh_answer if you may, or tell the user.` : ''}`,
188
+ }
189
+ }
190
+ progress?.(Math.round((Date.now() - started) / 1000), `${running.length} runs still going`)
191
+ }
192
+ }
193
+ // The result of a multi-run wait: each ended run as dsh_wait returns it for one run, then what next.
194
+ function anyResult({ done, running }) {
195
+ const ended = done.map((st) => {
196
+ const res = st.state === 'unknown' ? { ...st, hint: 'This run_id is not known here (only the latest 100 runs are kept); leave it out of the next dsh_wait.' } : core.waitResult(st)
197
+ if (res.state !== 'unknown') delivered(st.run_id, res)
198
+ return core.goalGoesOn(res)
199
+ ? { ...res, hint: `${res.hint} Its Harness goal goes on after this round: once the other runs are dealt with, follow it on its own with dsh_wait session_id ${res.session_id}.` }
200
+ : res
201
+ })
202
+ const ids = running.map((r) => r.run_id)
203
+ const next = ids.length
204
+ ? `Then call dsh_wait again with run_ids: the ones still running (${ids.join(', ')}) plus the run_id of every fix you sent, so whichever ends next comes back first. Do not wait for one run on its own while others are going.`
205
+ : 'None of these runs is still going.'
206
+ return {
207
+ ended, still_running: running,
208
+ hint: `${ended.length} of the runs ${ended.length === 1 ? 'has' : 'have'} ended or stopped to ask (ended; each has its own hint). Deal with ${ended.length === 1 ? 'it' : 'them'} now: review against what you asked, and send any fixes with dsh_send to the same session_id right away, or answer a question. ${next}`,
209
+ }
210
+ }
211
+
131
212
  // Following a session's goal (dsh_wait with session_id). Codex sees a message the user types
132
213
  // only once the tool call returns, so one call waits at most 15 minutes; it then returns
133
214
  // timed_out with the same cursor and Codex simply calls again.
@@ -197,7 +278,7 @@ const TOOLS = [
197
278
  {
198
279
  name: 'dsh_send',
199
280
  annotations: annotations('Send message', hints(false, false, false)),
200
- description: 'Hand a task to DeepSeek Harness, or continue a session: returns at once with a run_id and a hint saying how to wait for the reply; follow it. Call it directly, no need to list anything first. A new session starts in the folder this conversation works in, or in cwd or workspace_id (a new cwd is registered first); session_id continues that session. message arrives verbatim as a user message, and Harness does not see this conversation: make it self-contained, in the user\'s language, the full, explicit spec, asking for tests that cover each requirement. Keep a leading /goal (it sets a Harness goal) and the user\'s goal text as written. For a large task, write a concrete spec (goal, modules and the files each owns, level of detail, acceptance criteria) rather than passing the user\'s words on, and send independent parts to separate sessions in parallel, each owning its own files; review once all are back, then send specific fixes. With checks, a short request for a sub-agent review table is appended. To a busy session, mode followup queues the message and steer adds it to the running turn; left out, the Harness setting decides.',
281
+ description: 'Hand a task to DeepSeek Harness, or continue a session: returns at once with a run_id and a hint saying how to wait for the reply; follow it. Call it directly, no need to list anything first. A new session starts in the folder this conversation works in, or in cwd or workspace_id (a new cwd is registered first); session_id continues that session. message arrives verbatim as a user message, and Harness does not see this conversation: make it self-contained, in the user\'s language, the full, explicit spec, asking for tests that cover each requirement. Keep a leading /goal (it sets a Harness goal) and the user\'s goal text as written. For a large task, write a concrete spec (goal, modules and the files each owns, level of detail, acceptance criteria) rather than passing the user\'s words on, and send independent parts to separate sessions in parallel, each owning its own files; wait for them together (dsh_wait run_ids), review each as it returns and send its fixes at once. With checks, a short request for a sub-agent review table is appended. To a busy session, mode followup queues the message and steer adds it to the running turn; left out, the Harness setting decides.',
201
282
  inputSchema: { type: 'object', required: ['message'], properties: { message: str('The task or reply to send'), session_id: str('Existing session to continue'), cwd: str('Workspace folder for a new session; leave out to use the folder this conversation works in'), workspace_id: str('Harness workspace for a new session (from dsh_list_workspaces)'), mode: { type: 'string', enum: ['followup', 'steer'], description: "followup queues after the current turn; steer injects into a running turn. Omit to follow Harness's busy-send setting" }, model: str('Only when the user names a model for this run (e.g. "DeepSeek-V4 Pro", "v4-pro"); leave out otherwise and the session uses its own model, or Harness\'s default model for a new session. Matched against Harness\'s model list ignoring case, spaces, hyphens and a leading DeepSeek; a name that matches no model or several fails the send with the list (provider/model picks one). Sets this session only; Harness\'s default model is left as it was'), reasoning_effort: str('Only when the user names a reasoning effort, e.g. high; one of the efforts dsh_list_models shows for the model'), checks: { type: 'array', items: { type: 'string' }, description: 'The project\'s own test commands, e.g. npm test, or python -m pytest (a bare pytest is often not on PATH, on Windows especially); never check scripts you write yourself. dsh-baton runs them in the folder when the run ends and returns exit codes and the last output lines as evidence.' } } },
202
283
  run: async (a, { meta } = {}) => {
203
284
  // A new session goes to this conversation's folder unless the call names another one;
@@ -216,36 +297,43 @@ const TOOLS = [
216
297
  // Codex has no wake-on-exit for shell commands: offered wait_command, it runs it in a
217
298
  // terminal and polls that, so it gets dsh_wait only.
218
299
  if (isCodex()) {
219
- return { ...st, hint: `Run started. Call dsh_wait with this run_id; it returns when the run ends or stops to ask the user something. ${GOAL_WAIT_HINT('until turn and every_minutes')} ${NO_POLL} ${codeModeYield()}` }
300
+ return { ...st, hint: `Run started. Call dsh_wait with this run_id; it returns when the run ends or stops to ask the user something. ${PARALLEL_HINT} ${GOAL_WAIT_HINT('until turn and every_minutes')} ${NO_POLL} ${codeModeYield()}` }
220
301
  }
221
302
  return {
222
303
  ...st,
223
304
  wait_command: waitCommand(st.run_id),
224
- hint: `Run started. If you can run a shell command in the background and are woken when it exits (Claude Code: Bash with run_in_background), run wait_command that way: it ends when the run ends, or stops to ask the user something, and prints the reply or the questions, with no polling. Otherwise call dsh_wait with this run_id. ${GOAL_WAIT_HINT('--until turn --every 10m on wait_command')}`,
305
+ hint: `Run started. If you can run a shell command in the background and are woken when it exits (Claude Code: Bash with run_in_background), run wait_command that way: it ends when the run ends, or stops to ask the user something, and prints the reply or the questions, with no polling (several runs: one background wait_command each, so whichever ends first wakes you). Otherwise call dsh_wait with this run_id (several runs: one dsh_wait with run_ids). ${GOAL_WAIT_HINT('--until turn --every 10m on wait_command')}`,
225
306
  }
226
307
  },
227
308
  },
228
309
  {
229
310
  name: 'dsh_status',
230
311
  annotations: annotations('Run status'),
231
- description: 'Look once at a run or a session without waiting. A run (run_id): state (queued/running/waiting/completed/failed/cancelled/interrupted), recent events, final reply, waiting_on (sub-agents or jobs still working) and human_interjections. A session (session_id): busy, activity, its Harness goal (phase, rounds, whether Harness goes on with it), needs_input, and its last turn\'s reply. Never a way to wait: use dsh_wait.',
312
+ description: 'Look once at a run or a session without waiting. A run (run_id): state, recent events, final reply, waiting_on (sub-agents or jobs still working) and human_interjections. A session (session_id): busy, activity, its Harness goal (phase, rounds, whether Harness goes on with it), needs_input, and its last turn\'s reply. Never a way to wait: use dsh_wait.',
232
313
  inputSchema: { type: 'object', properties: { run_id: str('Run id from dsh_send'), session_id: str('Session id, when you have no run_id (for example to look at a session whose goal you follow)'), tail: int('How many recent events of a run', 5) } },
233
314
  run: async (a) => clip(await core.api.status({ tail: 5, ...a })),
234
315
  },
235
316
  {
236
317
  name: 'dsh_wait',
237
318
  annotations: annotations('Wait for run'),
238
- description: () => `Wait for a run sent with dsh_send to end and return its state and final reply.${!isCodex() ? ' Prefer the background wait_command from dsh_send if your client wakes you when a background command exits: it costs no turns.' : ''} It also returns at once when the run stops to ask a person: needs_input answer (question and relay; reply with dsh_answer) or harness-ui (an approval only a person can give in Harness). Each call waits up to ${waitSeconds()} s${isCodex() && waitSeconds() > 270 ? ' (270 s in a conversation that was already open when dsh-baton set Codex\'s tool timeout)' : ''}; if it returns timed_out: true, call dsh_wait again with the same run_id, without a progress note. A run in a session with a Harness goal is followed on until the goal stops, with every round since the run began. With session_id instead of run_id it follows that session's Harness goal without sending anything: until "goal" (default) returns once when the goal stops, until "turn" at the end of a round once every_minutes have passed since the last report; it also returns when Harness needs a person or will not go on by itself. Pass back the cursor each result gives.${isCodex() ? ` ${NO_POLL} ${codeModeYield()}` : ''}`,
319
+ description: () => `Wait for a run sent with dsh_send to end and return its state and final reply.${!isCodex() ? ' Prefer the background wait_command from dsh_send if your client wakes you when a background command exits: it costs no turns.' : ''} It also returns at once when the run stops to ask a person: needs_input answer (reply with dsh_answer) or harness-ui (an approval in the Harness window). Each call waits up to ${waitSeconds()} s${isCodex() && waitSeconds() > 270 ? ' (270 s in a conversation that was already open when dsh-baton set Codex\'s tool timeout)' : ''}; if it returns timed_out: true, call dsh_wait again with the same run_id, without a progress note. Parallel runs: all ids in run_ids, never one by one; returns once any ends or asks (ended; the rest in still_running); then wait again with the rest plus your fixes. A run in a session with a Harness goal is followed on until the goal stops, with every round since the run began. With session_id instead of run_id it follows that session's Harness goal (see until) without sending anything; it also returns when Harness needs a person or will not go on by itself. Pass back the cursor each result gives.${isCodex() ? ` ${NO_POLL} ${codeModeYield()}` : ''}`,
239
320
  inputSchema: {
240
321
  type: 'object', properties: {
241
322
  run_id: str('Run id from dsh_send'),
323
+ run_ids: { type: 'array', items: { type: 'string' }, description: 'Several run ids, to wait for whichever ends first' },
242
324
  session_id: str('Follow this session\'s Harness goal instead of waiting for a run (no message is sent)'),
243
325
  until: { type: 'string', enum: ['goal', 'turn'], description: 'Harness goal: goal (default) reports once when it stops; turn (only if the user asked for progress reports) at a round end once every_minutes passed since the last report, with every round since' },
244
- cursor: str('The cursor from the previous result, so nothing is reported twice or missed (also when switching until)'),
326
+ cursor: str('The cursor from the previous result (nothing is reported twice or missed)'),
245
327
  every_minutes: int('With until turn: least minutes between reports', 10),
246
328
  },
247
329
  },
248
330
  run: async (a, { progress, signal, meta }) => {
331
+ const ids = [...new Set([...(Array.isArray(a.run_ids) ? a.run_ids : []), ...(a.run_id ? [a.run_id] : [])].filter((id) => typeof id === 'string' && id))]
332
+ if (ids.length > 1) {
333
+ const r = await waitAnyInChunks(ids, progress, signal, meta)
334
+ return r.cancelled || r.timed_out ? r : anyResult(r)
335
+ }
336
+ if (ids.length === 1) a = { ...a, run_id: ids[0] }
249
337
  if (!a.run_id && a.session_id) {
250
338
  const st = await followInChunks(a, progress, signal, meta)
251
339
  if (isCodex() || st.cancelled || !['report', 'following', 'needs_input'].includes(st.state)) return st