dsh-baton-mcp 0.5.7 → 0.5.9
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 +9 -4
- package/README.zh-CN.md +9 -4
- package/host/README.md +31 -3
- package/host/lib/index.js +272 -5
- package/host/package.json +2 -2
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/cli.mjs +14 -5
- package/src/core.mjs +94 -8
- package/src/mcp.mjs +327 -0
- package/src/server.mjs +196 -226
package/README.md
CHANGED
|
@@ -88,14 +88,17 @@ That is all. If Harness says it could not register, run `npx -y dsh-baton-mcp se
|
|
|
88
88
|
| `dsh_create_workspace` | Register a folder as a workspace (creates the folder if missing); returns the existing one if already registered |
|
|
89
89
|
| `dsh_list_sessions` | Sessions newest first, with title, folder and running state; filter by folder or text. Archived sessions are left out |
|
|
90
90
|
| `dsh_read_session` | Recent prompts and replies of one session (with tool names); `archived: true` for an archived session |
|
|
91
|
-
| `dsh_send` | Send a message: `session_id` continues that session; `cwd` or `workspace_id` starts a new one (a `cwd` that is not a workspace yet is registered first). 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 |
|
|
91
|
+
| `dsh_send` | Send a message: `session_id` continues that session; `cwd` or `workspace_id` starts a new one (a `cwd` that is not a workspace yet is registered first). 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 |
|
|
92
92
|
| `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 |
|
|
93
|
-
| `dsh_wait` | Wait for the run to end: up to 270 s per call for Codex, 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 |
|
|
93
|
+
| `dsh_wait` | Wait for the run to end (or until it stops to ask a person, see `dsh_answer`): up to 270 s per call for Codex, 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 |
|
|
94
|
+
| `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 |
|
|
94
95
|
| `dsh_session_activity` | What a session is doing now: running turn, sub-agents (any depth), background jobs; `archived: true` for an archived session |
|
|
95
|
-
| `
|
|
96
|
+
| `dsh_update_queued` | 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 |
|
|
97
|
+
| `dsh_cancel` | Stop everything a session is doing, by `run_id` or `session_id`: turn, sub-agents, jobs. Harness also drops the messages waiting in its queue; their runs end as `cancelled` |
|
|
96
98
|
| `dsh_rename_session` | Rename a session |
|
|
97
99
|
| `dsh_rename_workspace` | Rename a workspace (sidebar title only; the folder is not moved) |
|
|
98
|
-
| `dsh_list_runs` | Recent runs |
|
|
100
|
+
| `dsh_list_runs` | Recent runs, optionally of one session |
|
|
101
|
+
| `dsh_call` | Backup entry: calls any `dsh_*` tool by name, for a conversation whose tool list predates an update; an unknown name lists the tools |
|
|
99
102
|
|
|
100
103
|
</details>
|
|
101
104
|
|
|
@@ -140,6 +143,8 @@ When the plugin is unreachable, the MCP server falls back to Harness's `dsh head
|
|
|
140
143
|
|
|
141
144
|
**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.
|
|
142
145
|
|
|
146
|
+
**Updates need no restart.** When a newer version lands in `~/.dsh-baton/app` (a plugin update, `setup`, or a copy by hand), conversations already open in Claude Code and Codex use it from their next `dsh_*` call: the server checks the folder before each call, starts the new version, and says once in that call's result that it was updated. A `dsh_wait` that is already running finishes on the old version first. If the new version cannot start, the old one keeps working and the result says why. A tool added in the new version shows up in Claude Code at once (the server tells the client its tool list changed); Codex keeps the tool list a conversation started with, so it does not show a new tool until the next conversation; from 0.5.9 on it can still use one at once through `dsh_call`, which the update note points to, and the tools it already has run the new code right away. This works from the version after 0.5.8 on; a server started from 0.5.8 or earlier needs one last restart (or `/mcp` reconnect) to pick it up.
|
|
147
|
+
|
|
143
148
|
**`npx -y dsh-baton-mcp setup`** does the same registration from a terminal: for a plugin that could not register, or when you come from an MCP directory. With the plugin already registered it changes nothing, then points you to the plugin if it is missing.
|
|
144
149
|
|
|
145
150
|
**Updating.** Harness has an update button only for its official plugins, so: Plugins → dsh-baton-host → **Uninstall**, then **Add plugin** → `dsh-baton-host`. For a while after a release, Harness installs the previous version instead (pnpm's `minimumReleaseAge` holds back versions published very recently, and the install dialog says so); to get the new one at once, type the version: `dsh-baton-host@0.5.0`. Switched on again, the plugin brings the MCP server to the same version; new conversations use it. If the two are ever out of step, the first tool call that notices it says so.
|
package/README.zh-CN.md
CHANGED
|
@@ -88,14 +88,17 @@ Codex 和 Claude 轮流往同一个 Harness 对话发消息,每条都标明是
|
|
|
88
88
|
| `dsh_create_workspace` | 把一个文件夹登记为工作区(文件夹不存在会先创建);已登记的直接返回原来那个 |
|
|
89
89
|
| `dsh_list_sessions` | 列出对话(最新在前),带标题、目录、是否在跑;可按目录或关键字过滤。已归档的对话不会列出 |
|
|
90
90
|
| `dsh_read_session` | 读某个对话最近的消息和回复(含用到的工具名);已归档的对话会带 `archived: true` |
|
|
91
|
-
| `dsh_send` | 发消息。带 `session_id` 续接那个对话;带 `cwd` 或 `workspace_id` 新开对话(`cwd` 还不是工作区时会先登记)。立即返回 `run_id`。Harness 里这条消息会标注「由 Claude / ChatGPT 从另一个聊天发送」。已归档的对话会被拒绝:先在 Harness
|
|
91
|
+
| `dsh_send` | 发消息。带 `session_id` 续接那个对话;带 `cwd` 或 `workspace_id` 新开对话(`cwd` 还不是工作区时会先登记)。立即返回 `run_id`。Harness 里这条消息会标注「由 Claude / ChatGPT 从另一个聊天发送」。已归档的对话会被拒绝:先在 Harness 里取消归档,或新开对话。对话正忙时,`mode` 为 `followup` 会排在当前回合之后,`steer` 会插进正在跑的回合;不传时跟随 Harness 自己的设置(设置 → 繁忙时的发送行为:排队发送或插话发送),读不到设置时按 `followup`。返回里的 `mode_source` 说明用的是哪种 |
|
|
92
92
|
| `dsh_status` | 查看运行进度:状态、最近的文字和工具调用、最终回复;回合结束但子智能体/后台任务还在跑时给出 `waiting_on`;中途有人在 Harness 界面里发消息时给出 `human_interjections`;被 Harness 重启打断的任务状态为 `interrupted` |
|
|
93
|
-
| `dsh_wait` |
|
|
93
|
+
| `dsh_wait` | 等运行结束(或者等到它停下来问人,见 `dsh_answer`):Codex 每次最多等 270 秒,其他客户端 50 秒。没做完会带 `timed_out`,只返回状态,再调用一次即可。能在后台等待的客户端改用 `dsh_send` 返回的 `wait_command`。`dsh_status` 只用来看一眼,不用来等待 |
|
|
94
|
+
| `dsh_answer` | 回答运行中途提出的问题(Harness 的 ask_user_question):每道题可以填选项编号或选项文字、自由填写,或者跳过;可以逐题回答,也可以一次答完。运行一提问,`dsh_wait` 和 `wait_command` 就会立刻返回,并带上题目(`needs_input`、`question`;Harness 那边模型推荐的选项单独放在 `harness_suggested` 里,不再写在选项文字中)。只有你说过让它自己决定时,助手才会按自己的判断回答,这时它会带上 `reason`,并告诉你选了什么、为什么;否则它会把题目转给你,开头先写它自己的建议和理由。你也可以直接在 Harness 窗口里回答,谁先答就算谁的 |
|
|
94
95
|
| `dsh_session_activity` | 对话此刻在做什么:正在跑的回合、子智能体(任意层级)、后台任务;已归档的对话会带 `archived: true` |
|
|
95
|
-
| `
|
|
96
|
+
| `dsh_update_queued` | 按 `run_id` 修改还在忙碌对话队列里等待的消息:`edit` 改文字,`remove` 撤回,`steer` 立刻插进正在跑的回合。消息一旦进入对话就改不了了 |
|
|
97
|
+
| `dsh_cancel` | 按 `run_id` 或 `session_id` 停掉整个对话的工作:当前回合、子智能体、后台任务。Harness 会同时清掉队列里等待的消息,这些运行记为 `cancelled` |
|
|
96
98
|
| `dsh_rename_session` | 给对话改名 |
|
|
97
99
|
| `dsh_rename_workspace` | 给工作区改名(只改侧边栏标题,不动文件夹) |
|
|
98
|
-
| `dsh_list_runs` |
|
|
100
|
+
| `dsh_list_runs` | 列出最近的运行,可按会话筛选 |
|
|
101
|
+
| `dsh_call` | 备用入口:按名字调用任意 `dsh_*` 工具,用于对话的工具列表还是更新前的旧版时;名字不存在时会列出所有可用工具 |
|
|
99
102
|
|
|
100
103
|
</details>
|
|
101
104
|
|
|
@@ -142,6 +145,8 @@ dsh-baton rename-workspace ~/code/new-app 新名字
|
|
|
142
145
|
|
|
143
146
|
**插件启用时做了什么。**它把 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`。
|
|
144
147
|
|
|
148
|
+
**更新不用重启。**新版本装进 `~/.dsh-baton/app` 后(插件更新、`setup` 或手动复制),Claude Code 和 Codex 里已经开着的对话,下一次调用 `dsh_*` 工具时就会用上:服务器每次调用前检查这个文件夹,启动新版本,并在这次调用的结果里提示一次已经更新。正在进行的 `dsh_wait` 会先在旧版本里跑完。新版本启动失败时,旧版本继续工作,结果里会说明原因。新版本新增的工具,Claude Code 里马上就能用(服务器会通知客户端工具列表变了);Codex 一直用对话开始时的工具列表,新工具要到下一个新对话才会显示;从 0.5.9 起,旧对话里也能通过 `dsh_call` 马上用上新功能(更新提示里会写明),已有的工具也会立刻用上新代码。这个功能从 0.5.8 之后的版本开始有效;由 0.5.8 或更早版本启动的服务器,还需要最后重启一次(或在 `/mcp` 里重新连接)。
|
|
149
|
+
|
|
145
150
|
**`npx -y dsh-baton-mcp setup`** 在终端里做同样的注册:用于插件没能注册的情况,或者从 MCP 目录过来的用户。插件已经注册过时它什么都不改;插件没装的话,它会提示去添加。
|
|
146
151
|
|
|
147
152
|
**更新。**Harness 只给官方插件提供"更新"按钮,所以要:插件 → dsh-baton-host → **卸载**,再**添加插件** → `dsh-baton-host`。新版本刚发布的一段时间里,Harness 会装上一个版本(pnpm 的 `minimumReleaseAge` 会暂缓刚发布的版本,安装弹窗里会说明);想马上用新版,输入带版本号的名字:`dsh-baton-host@0.5.0`。重新启用后,插件会把 MCP 服务器也更新到同一版本,新开的对话就会用上。万一两边版本对不上,第一次发现不一致的工具调用会提示。
|
package/host/README.md
CHANGED
|
@@ -1,11 +1,31 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/JialaoLiu/dsh-baton/main/assets/readme/hero.gif" width="100%" alt="dsh-baton: pass the baton from Claude Code or Codex to DeepSeek Harness, watch it run live, and get it handed back when it's done">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://github.com/JialaoLiu/dsh-baton#readme">dsh-baton</a> · <a href="https://github.com/JialaoLiu/dsh-baton/blob/main/README.zh-CN.md">中文说明</a>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
macOS · Windows (<a href="https://github.com/JialaoLiu/dsh-baton/blob/main/WINDOWS.md">guide</a>) · no Node.js needed · zero dependencies · MIT
|
|
11
|
+
</p>
|
|
12
|
+
|
|
1
13
|
# dsh-baton-host
|
|
2
14
|
|
|
3
|
-
DeepSeek Harness plugin for [dsh-baton](https://github.com/JialaoLiu/dsh-baton#readme). It runs inside the Harness host (desktop app or `dsh web`) and serves a local, owner-only Unix socket (`~/.dsh-baton/host.sock`; a per-user named pipe on Windows
|
|
15
|
+
The DeepSeek Harness plugin for [dsh-baton](https://github.com/JialaoLiu/dsh-baton#readme). It runs inside the Harness host (desktop app or `dsh web`) and serves a local, owner-only Unix socket (`~/.dsh-baton/host.sock`; a per-user named pipe on Windows). Through it, an MCP client can list and create workspaces, create sessions, continue a specific session, and wait for its turn to finish. All of this goes through Harness's own session controller, so the messages show up live in the Harness UI.
|
|
4
16
|
|
|
5
17
|
DeepSeek Harness 插件,配合 [dsh-baton](https://github.com/JialaoLiu/dsh-baton/blob/main/README.zh-CN.md) 使用。它运行在 Harness 内部,只开一个当前用户才能访问的本地套接字,让 MCP 客户端能新建工作区和对话、续接指定对话、等待任务完成。所有操作都走 Harness 官方会话服务,界面实时可见。
|
|
6
18
|
|
|
19
|
+
<p align="center">
|
|
20
|
+
<img src="https://raw.githubusercontent.com/JialaoLiu/dsh-baton/main/assets/readme/features.svg" width="100%" alt="Create workspaces and sessions, continue a session, run live in the Harness UI labelled with the sender, get woken when it is done, no extra API key, macOS and Windows with zero dependencies">
|
|
21
|
+
</p>
|
|
22
|
+
|
|
7
23
|
## Install / 安装
|
|
8
24
|
|
|
25
|
+
<p align="center">
|
|
26
|
+
<img src="https://raw.githubusercontent.com/JialaoLiu/dsh-baton/main/assets/readme/install.svg" width="100%" alt="Step 1: in Harness open Plugins, Add plugin, enter dsh-baton-host, Install, Enable now; it registers dsh-baton for Claude Code and Codex. Step 2: in a new chat, ask for dsh_list_workspaces">
|
|
27
|
+
</p>
|
|
28
|
+
|
|
9
29
|
Harness → Plugins / 插件 → Add plugin / 添加插件 → `dsh-baton-host` → Install / 安装 → Enable now / 立即启用
|
|
10
30
|
|
|
11
31
|
That is the whole install: switched on, the plugin registers the dsh-baton MCP server (the `dsh-baton-mcp` package, installed with it) for Claude Code and Codex, user-wide, and Harness says so. New conversations then have the `dsh_*` tools. If it could not register, run `npx -y dsh-baton-mcp setup`. Details: [dsh-baton](https://github.com/JialaoLiu/dsh-baton#install-details).
|
|
@@ -16,6 +36,12 @@ Update / 更新: uninstall, then add it again; the MCP server is updated with it
|
|
|
16
36
|
|
|
17
37
|
Remove / 卸载: uninstall, then `npx -y dsh-baton-mcp setup --remove` / 卸载插件后运行 `npx -y dsh-baton-mcp setup --remove`.
|
|
18
38
|
|
|
39
|
+
## How it works / 工作原理
|
|
40
|
+
|
|
41
|
+
<p align="center">
|
|
42
|
+
<img src="https://raw.githubusercontent.com/JialaoLiu/dsh-baton/main/assets/readme/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">
|
|
43
|
+
</p>
|
|
44
|
+
|
|
19
45
|
## Socket protocol / 套接字协议
|
|
20
46
|
|
|
21
47
|
Newline-delimited JSON: request `{"id", "method", "params"}`, reply `{"id", "result"}` or `{"id", "error"}`.
|
|
@@ -27,14 +53,16 @@ Newline-delimited JSON: request `{"id", "method", "params"}`, reply `{"id", "res
|
|
|
27
53
|
| `create_workspace` | `path`, `title?`, `create_dir?` (default `true`) |
|
|
28
54
|
| `list_sessions` | `cwd?`, `query?`, `limit?` |
|
|
29
55
|
| `read_session` | `session_id`, `last?` |
|
|
30
|
-
| `send` | `message`, and `session_id` or `cwd` or `workspace_id`; `mode?` (`followup` \| `steer`) |
|
|
56
|
+
| `send` | `message`, and `session_id` or `cwd` or `workspace_id`; `mode?` (`followup` \| `steer`; left out, Harness's busy-send setting, else `followup`) |
|
|
31
57
|
| `status` | `run_id`, `tail?` |
|
|
32
58
|
| `wait` | `run_id`, `timeout_s?` |
|
|
33
59
|
| `activity` | `session_id` |
|
|
34
60
|
| `cancel` | `run_id` or `session_id` |
|
|
35
61
|
| `rename_session` | `session_id`, `title` |
|
|
36
62
|
| `rename_workspace` | `workspace_id` or `path`, `title` |
|
|
37
|
-
| `list_runs` | `limit?` |
|
|
63
|
+
| `list_runs` | `session_id?`, `limit?` |
|
|
64
|
+
| `answer` | `run_id`, `answers` (`[{ id?, choose?, text?, skip? }]`) |
|
|
65
|
+
| `update_queued` | `run_id`, `action` (`edit` \| `remove` \| `steer`), `message?` (for `edit`) |
|
|
38
66
|
|
|
39
67
|
`DSH_BATON_SOCKET` / `DSH_BATON_HOME` in the Harness process environment move the socket. `DSH_BATON_AUTO_REGISTER=0` turns off the MCP registration.
|
|
40
68
|
|
package/host/lib/index.js
CHANGED
|
@@ -122,9 +122,18 @@ export function apply(ctx) {
|
|
|
122
122
|
try { ctx.logger?.error?.(`dsh-baton-host ${label}: ${error?.stack ?? error}`) } catch { /* logging must never throw */ }
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
// A waterfall listener must never break Harness's chain: on any error of its own it
|
|
126
|
+
// hands the request on untouched.
|
|
127
|
+
const guardWaterfall = (fn) => function (request, next) {
|
|
128
|
+
try { return fn(request, next) } catch (error) { logError('waterfall', error); return next() }
|
|
129
|
+
}
|
|
130
|
+
|
|
125
131
|
const controller = ctx.sessionController
|
|
126
132
|
const registry = ctx.workspaceRegistry
|
|
133
|
+
// Harness's settings service, when this Harness has one: read for its busy-send setting.
|
|
134
|
+
let settings = null
|
|
127
135
|
const runs = new Map() // runId -> run
|
|
136
|
+
let stopping = false // set when the plugin unloads: release every wait()
|
|
128
137
|
const waiters = new Map() // runId -> Set<resolve>
|
|
129
138
|
|
|
130
139
|
// --- run bookkeeping ------------------------------------------------------
|
|
@@ -136,12 +145,27 @@ export function apply(ctx) {
|
|
|
136
145
|
...(run.waitingOn ? { waiting_on: run.waitingOn } : {}),
|
|
137
146
|
...(run.supersededBy ? { superseded_by: run.supersededBy } : {}),
|
|
138
147
|
...(run.humanInputs?.length ? { human_interjections: run.humanInputs } : {}),
|
|
148
|
+
...(run.question ? { question: run.question } : {}),
|
|
139
149
|
...(tail ? { recent_events: run.events.slice(-tail) } : {}),
|
|
140
150
|
})
|
|
141
151
|
|
|
142
152
|
const ACTIVE = ['queued', 'running', 'waiting']
|
|
143
153
|
const isActive = (run) => ACTIVE.includes(run.state)
|
|
144
154
|
|
|
155
|
+
// Release callers blocked in wait() without ending the run (it needs a person now).
|
|
156
|
+
function wake(run) {
|
|
157
|
+
for (const resolve of [...(waiters.get(run.id) ?? [])]) resolve()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The run now waits for a person. The next wait() returns at once with what is asked
|
|
161
|
+
// (once per episode: a second wait() blocks again until the run moves on).
|
|
162
|
+
function needsPerson(run, delayMs = 0) {
|
|
163
|
+
Object.assign(run, { state: 'waiting', waitNoticed: false })
|
|
164
|
+
persist(run)
|
|
165
|
+
if (delayMs) setTimeout(guard('waiting', () => wake(run)), delayMs)
|
|
166
|
+
else wake(run)
|
|
167
|
+
}
|
|
168
|
+
|
|
145
169
|
function settle(run, state, extra = {}) {
|
|
146
170
|
if (!isActive(run)) return
|
|
147
171
|
Object.assign(run, extra, { state, endedAt: new Date().toISOString() })
|
|
@@ -250,9 +274,24 @@ export function apply(ctx) {
|
|
|
250
274
|
// Turn state is kept only for a turn in progress and dropped at its end.
|
|
251
275
|
if (event.type === 'turn/start') turns.set(session.id, { hasOutput: false })
|
|
252
276
|
else if (event.type === 'assistant/message' && turns.has(session.id)) turns.get(session.id).hasOutput = true
|
|
277
|
+
// A run settled as removed from the queue whose message reaches the conversation
|
|
278
|
+
// after all (the removal check guessed wrong) is taken up again.
|
|
279
|
+
if (event.type === 'user/message') {
|
|
280
|
+
const lost = [...runs.values()].find((r) => r.removedFromQueue && r.state === 'cancelled' && r.sessionId === session.id && r.requestId === data.source?.rpcId)
|
|
281
|
+
if (lost) {
|
|
282
|
+
Object.assign(lost, { state: 'queued', removedFromQueue: false, error: null, endedAt: null })
|
|
283
|
+
pushEvent(lost, { type: 'queue_delivered' })
|
|
284
|
+
}
|
|
285
|
+
}
|
|
253
286
|
const active = activeRunsFor(session.id)
|
|
254
287
|
if (event.type === 'turn/end' && !active.some((r) => r.sawPrompt)) turns.delete(session.id)
|
|
255
288
|
if (!active.length) return
|
|
289
|
+
// Something left Harness's queue: a queued run may have been removed (session stopped,
|
|
290
|
+
// or Remove in the Harness UI) rather than started. Checked once the dust settles.
|
|
291
|
+
if (event.type === 'agent/inbox/spliced' && data.removedCount > 0 && active.some((r) => !r.sawPrompt)) {
|
|
292
|
+
scheduleQueueCheck(session.id)
|
|
293
|
+
return
|
|
294
|
+
}
|
|
256
295
|
if (event.type === 'user/message') {
|
|
257
296
|
const run = active.find((r) => r.requestId === data.source?.rpcId)
|
|
258
297
|
const opensTurn = !turns.get(session.id)?.hasOutput
|
|
@@ -388,7 +427,9 @@ export function apply(ctx) {
|
|
|
388
427
|
if (status === 'running' && receivers.length) lastRunningAt.set(agent.id, Date.now())
|
|
389
428
|
for (const run of outputReceiversFor(agent.id)) {
|
|
390
429
|
if (status === 'running') run.state = 'running'
|
|
391
|
-
|
|
430
|
+
// Needs a person: a question (see user-questions/request) or an approval in the
|
|
431
|
+
// Harness UI. Waiters wake a moment later, so a question that comes with it is on the run.
|
|
432
|
+
else if (status === 'waiting') { if (run.state !== 'waiting') needsPerson(run, 500) }
|
|
392
433
|
else if (status === 'error') settle(run, 'failed')
|
|
393
434
|
else if (status === 'idle') run.idleSeen = true
|
|
394
435
|
}
|
|
@@ -399,6 +440,208 @@ export function apply(ctx) {
|
|
|
399
440
|
for (const run of agent ? outputReceiversFor(agent.id) : []) run.error = String(error?.message ?? error)
|
|
400
441
|
}))
|
|
401
442
|
|
|
443
|
+
// --- Harness's queue (inbox) ------------------------------------------------
|
|
444
|
+
// A followup sent while the session is busy waits in Harness's inbox until the turn
|
|
445
|
+
// ends. Each pending message carries the request id it was sent with (source.rpcId),
|
|
446
|
+
// so a run finds its own message there.
|
|
447
|
+
|
|
448
|
+
const QUEUE_UNSUPPORTED = 'This DeepSeek Harness version cannot change queued messages: its session controller has no projections/updateQueue.'
|
|
449
|
+
const queueTimers = new Map() // sessionId -> timeout
|
|
450
|
+
const QUEUE_RECHECK_MS = Number(process.env.DSH_BATON_QUEUE_RECHECK_MS) || 2000
|
|
451
|
+
|
|
452
|
+
// Messages still pending in a session's inbox, or null when this Harness cannot say.
|
|
453
|
+
async function pendingMessages(sessionId) {
|
|
454
|
+
if (typeof controller.projections !== 'function') return null
|
|
455
|
+
const baseline = await controller.projections({ sessionId }, signal())
|
|
456
|
+
if (baseline === null) return [] // no such session: nothing is pending
|
|
457
|
+
const inbox = baseline?.values?.inbox
|
|
458
|
+
if (!inbox) return null
|
|
459
|
+
return [...(inbox['next-turn'] ?? []), ...(inbox['next-step'] ?? [])]
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Settle the runs whose message left the inbox without reaching the conversation:
|
|
463
|
+
// Harness drops the queue when a session is stopped, and the Harness UI can remove one.
|
|
464
|
+
// A message taken into the conversation also leaves the inbox, and its user/message
|
|
465
|
+
// event can trail behind (edited messages, a busy disk), so a run counts as removed
|
|
466
|
+
// only when it is still missing from both the inbox and the log a moment later.
|
|
467
|
+
async function reconcileQueue(sessionId) {
|
|
468
|
+
let gone = await missingFromQueue(sessionId, activeRunsFor(sessionId).filter((r) => r.admitted && !r.sawPrompt))
|
|
469
|
+
if (!gone.length) return []
|
|
470
|
+
await new Promise((resolve) => setTimeout(resolve, QUEUE_RECHECK_MS))
|
|
471
|
+
gone = await missingFromQueue(sessionId, gone.filter((r) => isActive(r) && !r.sawPrompt))
|
|
472
|
+
for (const run of gone) settle(run, 'cancelled', { error: 'Removed from the Harness queue before it ran.', removedFromQueue: true })
|
|
473
|
+
return gone.map((r) => r.id)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Of these runs, the ones whose message is neither pending in the inbox nor in the
|
|
477
|
+
// session log. Nothing is missing when either cannot be read.
|
|
478
|
+
async function missingFromQueue(sessionId, waiting) {
|
|
479
|
+
if (!waiting.length) return []
|
|
480
|
+
const pending = await pendingMessages(sessionId)
|
|
481
|
+
if (pending === null) return []
|
|
482
|
+
const ids = new Set(pending.map((m) => m?.source?.rpcId))
|
|
483
|
+
const gone = waiting.filter((r) => !ids.has(r.requestId))
|
|
484
|
+
if (!gone.length) return []
|
|
485
|
+
const log = await controller.inspect(sessionId, signal()).catch(() => null)
|
|
486
|
+
if (!Array.isArray(log?.events)) return []
|
|
487
|
+
const delivered = new Set(log.events.filter((e) => e.type === 'user/message').map((e) => e.data?.source?.rpcId))
|
|
488
|
+
return gone.filter((r) => !delivered.has(r.requestId) && !r.sawPrompt)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function scheduleQueueCheck(sessionId) {
|
|
492
|
+
clearTimeout(queueTimers.get(sessionId))
|
|
493
|
+
queueTimers.set(sessionId, setTimeout(guard('queue check', () => { queueTimers.delete(sessionId); return reconcileQueue(sessionId) }), 1500))
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
ctx.effect(() => () => { for (const t of queueTimers.values()) clearTimeout(t) }, 'dsh-baton-host queue timers')
|
|
497
|
+
|
|
498
|
+
// Change a relayed message that is still waiting in Harness's queue: edit its text,
|
|
499
|
+
// remove it, or move it into the running turn as a steer.
|
|
500
|
+
async function updateQueued({ run_id, action, message }) {
|
|
501
|
+
if (!['edit', 'remove', 'steer'].includes(action)) throw new Error('action must be edit, remove or steer')
|
|
502
|
+
if (action === 'edit' && !message?.trim()) throw new Error('edit needs a non-empty message')
|
|
503
|
+
const run = getRun(run_id)
|
|
504
|
+
if (!isActive(run)) throw new Error(`Run ${run.id} is ${run.state}; only a queued run can be changed.`)
|
|
505
|
+
if (run.sawPrompt) throw new Error(`Run ${run.id} has already reached the conversation and can no longer be changed. Send a correction with dsh_send mode steer, or stop the session with dsh_cancel.`)
|
|
506
|
+
if (typeof controller.updateQueue !== 'function') throw new Error(QUEUE_UNSUPPORTED)
|
|
507
|
+
const pending = await pendingMessages(run.sessionId)
|
|
508
|
+
if (pending === null) throw new Error(QUEUE_UNSUPPORTED)
|
|
509
|
+
const item = pending.find((m) => m?.source?.rpcId === run.requestId)
|
|
510
|
+
const gone = `Run ${run.id} is no longer in the Harness queue: it has just started, or it was removed.`
|
|
511
|
+
if (!item) throw new Error(gone)
|
|
512
|
+
const change = action === 'edit' ? { kind: 'edit', content: [{ type: 'text', text: message }] } : { kind: action }
|
|
513
|
+
try {
|
|
514
|
+
await controller.updateQueue({ sessionId: run.sessionId, itemId: item.id, action: change })
|
|
515
|
+
} catch (error) {
|
|
516
|
+
if (error?.code === 'session/queue-item-not-found') throw new Error(gone)
|
|
517
|
+
if (error?.code === 'session/steer-unavailable') throw new Error(`The current turn of ${run.sessionId} no longer takes steering; the message stays queued.`)
|
|
518
|
+
throw error
|
|
519
|
+
}
|
|
520
|
+
if (action === 'edit') {
|
|
521
|
+
run.preview = message.slice(0, 200)
|
|
522
|
+
pushEvent(run, { type: 'queue_edit', text: message.slice(0, 500) })
|
|
523
|
+
} else if (action === 'steer') {
|
|
524
|
+
run.mode = 'steer'
|
|
525
|
+
pushEvent(run, { type: 'queue_steer' })
|
|
526
|
+
} else {
|
|
527
|
+
pushEvent(run, { type: 'queue_remove' })
|
|
528
|
+
settle(run, 'cancelled', { error: null })
|
|
529
|
+
}
|
|
530
|
+
return { ...publicRun(run, 0), action }
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// --- questions (ask_user_question) ----------------------------------------
|
|
534
|
+
// Harness asks the user through the user-questions/request waterfall, whose answerer
|
|
535
|
+
// is the Harness UI. Listening first, the plugin puts the question on the runs of that
|
|
536
|
+
// turn (so wait() returns with it) and lets either side answer: the Harness UI, or
|
|
537
|
+
// dsh_answer through answerQuestion(). The first answer wins.
|
|
538
|
+
|
|
539
|
+
const questions = new Map() // sessionId -> open question
|
|
540
|
+
const RECOMMENDED = /\(recommended\)|(推荐)|\(推荐\)|推荐/i
|
|
541
|
+
|
|
542
|
+
function publicQuestion(entry) {
|
|
543
|
+
return {
|
|
544
|
+
question_id: entry.id,
|
|
545
|
+
count: entry.items.length,
|
|
546
|
+
questions: entry.items.map((q) => ({
|
|
547
|
+
id: q.id,
|
|
548
|
+
...(q.header ? { header: q.header } : {}),
|
|
549
|
+
question: q.question,
|
|
550
|
+
...(q.detail ? { detail: q.detail } : {}),
|
|
551
|
+
multi_select: !!q.multiSelect,
|
|
552
|
+
options: (q.options ?? []).map((o, i) => ({ number: i + 1, label: o.label, ...(o.description ? { description: o.description } : {}), ...(RECOMMENDED.test(o.label) ? { recommended: true } : {}) })),
|
|
553
|
+
free_text: true,
|
|
554
|
+
...(entry.answers.has(q.id) ? { answered: true } : {}),
|
|
555
|
+
})),
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function onQuestion(request, next) {
|
|
560
|
+
const sessionId = request?.agent?.id
|
|
561
|
+
const asked = sessionId && Array.isArray(request.questions) ? outputReceiversFor(sessionId) : []
|
|
562
|
+
if (!asked.length) return next()
|
|
563
|
+
const entry = { id: `q-${crypto.randomBytes(4).toString('hex')}`, sessionId, items: request.questions, answers: new Map(), runs: asked }
|
|
564
|
+
const ours = new Promise((resolve) => { entry.resolve = resolve })
|
|
565
|
+
questions.set(sessionId, entry)
|
|
566
|
+
for (const run of asked) {
|
|
567
|
+
run.question = publicQuestion(entry)
|
|
568
|
+
pushEvent(run, { type: 'question', question_id: entry.id, count: entry.items.length })
|
|
569
|
+
needsPerson(run)
|
|
570
|
+
}
|
|
571
|
+
// The Harness UI (next) gets its own signal: once dsh_answer wins, aborting it tells
|
|
572
|
+
// the window to drop its question card, which it otherwise keeps open.
|
|
573
|
+
const ui = new AbortController()
|
|
574
|
+
const harnessSignal = request.signal
|
|
575
|
+
request.signal = harnessSignal ? AbortSignal.any([harnessSignal, ui.signal]) : ui.signal
|
|
576
|
+
// With no Harness window to answer, only dsh_answer can: that is not a failure here.
|
|
577
|
+
const theirs = Promise.resolve().then(next).catch((error) => (error?.code === 'NO_PROVIDER' || ui.signal.aborted ? new Promise(() => {}) : Promise.reject(error)))
|
|
578
|
+
const aborted = new Promise((_, reject) => harnessSignal?.addEventListener('abort', () => reject(harnessSignal.reason), { once: true }))
|
|
579
|
+
const answered = (by) => (value) => {
|
|
580
|
+
for (const run of asked) pushEvent(run, { type: 'question_answered', question_id: entry.id, by })
|
|
581
|
+
return value
|
|
582
|
+
}
|
|
583
|
+
const fromBaton = ours.then((value) => { ui.abort(new Error('answered through dsh-baton')); return value })
|
|
584
|
+
return Promise.race([theirs.then(answered('harness')), fromBaton.then(answered('dsh-baton')), aborted]).finally(() => {
|
|
585
|
+
if (questions.get(sessionId) === entry) questions.delete(sessionId)
|
|
586
|
+
for (const run of asked) {
|
|
587
|
+
if (run.question?.question_id !== entry.id) continue
|
|
588
|
+
delete run.question
|
|
589
|
+
if (run.state === 'waiting') run.state = 'running'
|
|
590
|
+
persist(run)
|
|
591
|
+
}
|
|
592
|
+
})
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Answer the open question of a run's turn. answers: [{ id?, choose?: [number | label],
|
|
596
|
+
// text?, skip? }]; questions not answered yet stay open until every one has an answer.
|
|
597
|
+
function answerQuestion({ run_id, answers }) {
|
|
598
|
+
const run = getRun(run_id)
|
|
599
|
+
const entry = questions.get(run.sessionId)
|
|
600
|
+
if (!isActive(run) || !entry || run.question?.question_id !== entry.id) {
|
|
601
|
+
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.`)
|
|
602
|
+
throw new Error(`Run ${run.id} is ${run.state} and has no open question to answer.`)
|
|
603
|
+
}
|
|
604
|
+
if (!Array.isArray(answers) || !answers.length) throw new Error('answers must list at least one answer')
|
|
605
|
+
const staged = new Map()
|
|
606
|
+
for (const a of answers) {
|
|
607
|
+
const q = a?.id != null ? entry.items.find((item) => item.id === a.id) : entry.items.length === 1 ? entry.items[0] : undefined
|
|
608
|
+
if (!q) throw new Error(a?.id != null ? `No question ${a.id}; the questions are ${entry.items.map((i) => i.id).join(', ')}` : 'id is required when there are several questions')
|
|
609
|
+
const options = q.options ?? []
|
|
610
|
+
const chosen = (a.choose ?? []).map((c) => {
|
|
611
|
+
const byNumber = typeof c === 'number' || /^\d+$/.test(String(c)) ? options[Number(c) - 1] : undefined
|
|
612
|
+
const option = byNumber ?? options.find((o) => o.label === c) ?? options.find((o) => o.label.toLowerCase().replace(RECOMMENDED, '').trim() === String(c).toLowerCase().replace(RECOMMENDED, '').trim())
|
|
613
|
+
if (!option) throw new Error(`Question ${q.id} has no option ${JSON.stringify(c)}; options: ${options.map((o, i) => `${i + 1}. ${o.label}`).join('; ')}`)
|
|
614
|
+
return option.label
|
|
615
|
+
})
|
|
616
|
+
const text = typeof a.text === 'string' && a.text.trim() ? a.text : undefined
|
|
617
|
+
if (a.skip && (chosen.length || text)) throw new Error(`Question ${q.id}: skip cannot come with choose or text`)
|
|
618
|
+
if (!a.skip && !chosen.length && !text) throw new Error(`Question ${q.id}: give choose, text or skip`)
|
|
619
|
+
if (!q.multiSelect && chosen.length > 1) throw new Error(`Question ${q.id} takes one option`)
|
|
620
|
+
// Single choice: typed text replaces the option (Harness's own rule).
|
|
621
|
+
staged.set(q.id, a.skip ? { id: q.id, selected: [] } : !q.multiSelect && text ? { id: q.id, selected: [], custom: text } : { id: q.id, selected: chosen, ...(text ? { custom: text } : {}) })
|
|
622
|
+
}
|
|
623
|
+
for (const [id, item] of staged) entry.answers.set(id, item)
|
|
624
|
+
const remaining = entry.items.filter((q) => !entry.answers.has(q.id)).map((q) => q.id)
|
|
625
|
+
if (remaining.length) {
|
|
626
|
+
for (const r of entry.runs) { if (r.question?.question_id === entry.id) { r.question = publicQuestion(entry); persist(r) } }
|
|
627
|
+
return { ...publicRun(run, 0), submitted: false, remaining }
|
|
628
|
+
}
|
|
629
|
+
entry.resolve({ answers: entry.items.map((q) => entry.answers.get(q.id)) })
|
|
630
|
+
return { ...publicRun(run, 0), submitted: true, answers: entry.items.map((q) => entry.answers.get(q.id)) }
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
ctx.on('user-questions/request', guardWaterfall(onQuestion), { global: true, prepend: true })
|
|
634
|
+
|
|
635
|
+
// Harness's own "busy send" setting (Settings → sending while busy: queue or steer),
|
|
636
|
+
// as a dsh-baton mode, or null when this Harness has no such setting to read.
|
|
637
|
+
function harnessBusyMode() {
|
|
638
|
+
try {
|
|
639
|
+
const form = settings?.describe?.()?.find?.((d) => d.ns === 'ui-conversation')
|
|
640
|
+
const value = form?.value?.busyEnter
|
|
641
|
+
return value === 'steer' ? 'steer' : value === 'queue' ? 'followup' : null
|
|
642
|
+
} catch { return null }
|
|
643
|
+
}
|
|
644
|
+
|
|
402
645
|
// --- operations -----------------------------------------------------------
|
|
403
646
|
|
|
404
647
|
const signal = () => new AbortController().signal
|
|
@@ -461,8 +704,13 @@ export function apply(ctx) {
|
|
|
461
704
|
return { session_id, ...(isArchived(session_id) ? { archived: true } : {}), cwd: meta?.cwd ?? null, title, total_messages: messages.length, messages: messages.slice(-last) }
|
|
462
705
|
}
|
|
463
706
|
|
|
464
|
-
async function send({ message, session_id, cwd, workspace_id, mode
|
|
707
|
+
async function send({ message, session_id, cwd, workspace_id, mode: asked, sender }) {
|
|
465
708
|
if (!message || !message.trim()) throw new Error('message is required')
|
|
709
|
+
if (asked != null && asked !== 'followup' && asked !== 'steer') throw new Error('mode must be followup or steer')
|
|
710
|
+
// Without a mode, a message to a busy session does what Harness's own busy-send setting says.
|
|
711
|
+
const fromHarness = asked == null ? harnessBusyMode() : null
|
|
712
|
+
const mode = asked ?? fromHarness ?? 'followup'
|
|
713
|
+
const modeSource = asked != null ? 'explicit' : fromHarness ? 'harness' : 'default'
|
|
466
714
|
if (sender != null && !SENDER_RE.test(sender)) throw new Error('sender must be 1-32 characters of a-z, 0-9 or -')
|
|
467
715
|
let sessionId = session_id
|
|
468
716
|
let workspace = null
|
|
@@ -486,12 +734,14 @@ export function apply(ctx) {
|
|
|
486
734
|
remember(run)
|
|
487
735
|
try {
|
|
488
736
|
await controller.prompt({ sessionId, requestId: run.requestId, content: [{ type: 'text', text: message }], mode }, signal())
|
|
737
|
+
run.admitted = true // in Harness's queue (or conversation) now
|
|
489
738
|
} catch (error) {
|
|
490
739
|
settle(run, 'failed', { error: String(error?.message ?? error) })
|
|
491
740
|
}
|
|
492
741
|
return {
|
|
493
742
|
...publicRun(run),
|
|
494
743
|
mode,
|
|
744
|
+
mode_source: modeSource,
|
|
495
745
|
...(ahead.length ? { other_active_runs: ahead } : {}),
|
|
496
746
|
...(workspace ? { workspace } : {}),
|
|
497
747
|
}
|
|
@@ -516,7 +766,10 @@ export function apply(ctx) {
|
|
|
516
766
|
async function wait({ run_id, timeout_s = 300 }) {
|
|
517
767
|
const run = getRun(run_id)
|
|
518
768
|
const seconds = Number.isFinite(Number(timeout_s)) ? Math.min(Math.max(Number(timeout_s), 0), MAX_WAIT_S) : 300
|
|
519
|
-
|
|
769
|
+
const unseenWait = () => run.state === 'waiting' && !run.waitNoticed
|
|
770
|
+
const deadline = Date.now() + seconds * 1000
|
|
771
|
+
// A wake can also come for another reason (the delayed one for a waiting status): wait on.
|
|
772
|
+
while (isActive(run) && !unseenWait() && !stopping && Date.now() < deadline) {
|
|
520
773
|
await new Promise((resolve) => {
|
|
521
774
|
const set = waiters.get(run.id) ?? new Set()
|
|
522
775
|
waiters.set(run.id, set)
|
|
@@ -525,11 +778,15 @@ export function apply(ctx) {
|
|
|
525
778
|
set.delete(done)
|
|
526
779
|
resolve()
|
|
527
780
|
}
|
|
528
|
-
const timer = setTimeout(done,
|
|
781
|
+
const timer = setTimeout(done, deadline - Date.now())
|
|
529
782
|
set.add(done)
|
|
530
783
|
})
|
|
531
784
|
}
|
|
532
785
|
const done = !isActive(run)
|
|
786
|
+
if (!done && unseenWait()) {
|
|
787
|
+
run.waitNoticed = true
|
|
788
|
+
return { ...publicRun(run), needs_input: run.question ? 'answer' : 'harness-ui' }
|
|
789
|
+
}
|
|
533
790
|
return { ...publicRun(run), ...(done ? {} : { timed_out: true }) }
|
|
534
791
|
}
|
|
535
792
|
|
|
@@ -568,6 +825,8 @@ export function apply(ctx) {
|
|
|
568
825
|
} while (still.busy && Date.now() < deadline)
|
|
569
826
|
if (!still.busy) break
|
|
570
827
|
}
|
|
828
|
+
// Stopping a session also empties Harness's queue: the runs waiting there are over.
|
|
829
|
+
const removed = await reconcileQueue(sessionId).catch((error) => { logError('queue check', error); return [] })
|
|
571
830
|
// The named run never reached the conversation (still queued, or sent to a session
|
|
572
831
|
// that will not run it): settle it too, so nothing waits on it forever.
|
|
573
832
|
if (run && isActive(run) && !run.sawPrompt) settle(run, 'cancelled')
|
|
@@ -578,6 +837,7 @@ export function apply(ctx) {
|
|
|
578
837
|
still_busy: still.busy,
|
|
579
838
|
activity: still.activity,
|
|
580
839
|
queued_runs: activeRunsFor(sessionId).filter((r) => !r.sawPrompt).map((r) => r.id),
|
|
840
|
+
...(removed.length ? { removed_from_queue: removed } : {}),
|
|
581
841
|
}
|
|
582
842
|
}
|
|
583
843
|
|
|
@@ -608,7 +868,9 @@ export function apply(ctx) {
|
|
|
608
868
|
activity,
|
|
609
869
|
rename_session: renameSession,
|
|
610
870
|
rename_workspace: renameWorkspace,
|
|
611
|
-
list_runs: ({ limit = 10 } = {}) => [...runs.values()].reverse().slice(0, limit).map((r) => publicRun(r, 0)),
|
|
871
|
+
list_runs: ({ limit = 10, session_id } = {}) => [...runs.values()].reverse().filter((r) => !session_id || r.sessionId === session_id).slice(0, limit).map((r) => publicRun(r, 0)),
|
|
872
|
+
update_queued: updateQueued,
|
|
873
|
+
answer: answerQuestion,
|
|
612
874
|
}
|
|
613
875
|
|
|
614
876
|
// --- local socket (owner-only) ---------------------------------------------
|
|
@@ -675,6 +937,10 @@ export function apply(ctx) {
|
|
|
675
937
|
registration = { state: 'off' }
|
|
676
938
|
}
|
|
677
939
|
if (typeof ctx.inject === 'function') {
|
|
940
|
+
ctx.inject(['settings'], (child) => {
|
|
941
|
+
settings = child.settings
|
|
942
|
+
child.effect(() => () => { if (settings === child.settings) settings = null }, 'dsh-baton-host settings')
|
|
943
|
+
})
|
|
678
944
|
ctx.inject(['webServer'], (web) => {
|
|
679
945
|
web.effect(() => web.webServer.register({
|
|
680
946
|
kind: 'exact',
|
|
@@ -706,6 +972,7 @@ export function apply(ctx) {
|
|
|
706
972
|
disposed = true
|
|
707
973
|
if (store.enabled) { try { flushRuns() } catch (error) { logError('persist runs', error) } }
|
|
708
974
|
store.enabled = false
|
|
975
|
+
stopping = true
|
|
709
976
|
// Release callers blocked in wait() and close open connections, so an
|
|
710
977
|
// unloaded or reloaded plugin leaves no timers or sockets behind.
|
|
711
978
|
for (const set of waiters.values()) for (const done of [...set]) done()
|
package/host/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-baton-host",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.9",
|
|
4
4
|
"description": "DeepSeek Harness plugin for dsh-baton: adding it is the whole install. Switched on, it registers the dsh-baton MCP server for Claude Code and Codex, then takes the baton over a local, owner-only control socket, runs it in a specific session through the host's own session controller, and hands the result back when the turn is done.",
|
|
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.5.
|
|
23
|
+
"dsh-baton-mcp": "0.5.9"
|
|
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.5.
|
|
3
|
+
"version": "0.5.9",
|
|
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.5.
|
|
10
|
+
"version": "0.5.9",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "dsh-baton-mcp",
|
|
15
|
-
"version": "0.5.
|
|
15
|
+
"version": "0.5.9",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -14,12 +14,14 @@ const USAGE = `Usage: dsh-baton <command> [args]
|
|
|
14
14
|
read <session_id> [--last <n>] show recent messages
|
|
15
15
|
send [--session <id> | --cwd <dir> | --workspace <id>] [--mode followup|steer] <message...> start a run (message may be "-" for stdin)
|
|
16
16
|
status <run_id> show a run's progress
|
|
17
|
-
wait <run_id> [--timeout <seconds>] block until the run ends, print
|
|
17
|
+
wait <run_id> [--timeout <seconds>] block until the run ends (or asks a person), print it
|
|
18
18
|
rename <session_id> <title> rename a session
|
|
19
19
|
rename-workspace <workspace_id|dir> <title> rename a workspace (sidebar title)
|
|
20
20
|
activity <session_id> what the session is doing now (turn, sub-agents, jobs)
|
|
21
21
|
cancel <run_id> | --session <id> stop the session's turn, sub-agents and jobs
|
|
22
|
-
|
|
22
|
+
queued <run_id> edit <message...> | remove | steer change a message still waiting in Harness's queue
|
|
23
|
+
answer <run_id> '<answers JSON>' answer the questions a run is waiting on
|
|
24
|
+
runs [--session <id>] [--limit <n>] list recent runs`
|
|
23
25
|
|
|
24
26
|
function parse(argv) {
|
|
25
27
|
const flags = {}, rest = []
|
|
@@ -62,12 +64,19 @@ async function main() {
|
|
|
62
64
|
return print(await core.api.status({ run_id: rest[0] }))
|
|
63
65
|
case 'wait': {
|
|
64
66
|
const st = await core.api.wait({ run_id: rest[0], timeout_s: Number(flags.timeout) || 24 * 3600 })
|
|
65
|
-
print({
|
|
66
|
-
|
|
67
|
+
print({
|
|
68
|
+
...(st.decided_for_user ? { hint: 'Required in your reply: you chose an answer for the user during this run, so start your reply by telling the user, in their language, what you chose and why (decided_for_user: answers and reason), then give the result. Passing on only final_text is not enough.', decided_for_user: st.decided_for_user } : {}),
|
|
69
|
+
run_id: st.run_id, state: st.state, session_id: st.session_id, timed_out: !!st.timed_out, error: st.error, final_text: st.final_text,
|
|
70
|
+
...(st.needs_input ? { needs_input: st.needs_input, question: st.question, ...(st.question ? { relay: core.relayText(st.question) } : {}), hint: st.question ? 'The run asks the user something. If the user allowed you to decide, choose by your own judgment and answer with dsh_answer (or dsh-baton answer), passing reason, and tell the user what you chose and why. Otherwise show the user relay, written in the user\'s language, with its first line filled in with your own pick and a reason from your own judgment of the question and the task (harness_suggested is only the asker\'s pick, never a reason by itself), and ask the user first. Then wait again.' : 'The run waits for a person in the Harness UI. Tell the user, then wait again.' } : {}) })
|
|
71
|
+
process.exitCode = st.state === 'completed' ? 0 : st.needs_input ? 3 : 1
|
|
67
72
|
return
|
|
68
73
|
}
|
|
69
74
|
case 'cancel':
|
|
70
75
|
return print(await core.api.cancel(rest[0]?.startsWith('session') || flags.session ? { session_id: flags.session ?? rest[0] } : { run_id: rest[0] }))
|
|
76
|
+
case 'queued':
|
|
77
|
+
return print(await core.api.update_queued({ run_id: rest[0], action: rest[1], ...(rest[1] === 'edit' ? { message: rest.slice(2).join(' ') } : {}) }))
|
|
78
|
+
case 'answer':
|
|
79
|
+
return print(await core.api.answer({ run_id: rest[0], answers: JSON.parse(rest.slice(1).join(' ')) }))
|
|
71
80
|
case 'rename':
|
|
72
81
|
return print(await core.api.rename_session({ session_id: rest[0], title: rest.slice(1).join(' ') }))
|
|
73
82
|
case 'rename-workspace':
|
|
@@ -76,7 +85,7 @@ async function main() {
|
|
|
76
85
|
case 'activity':
|
|
77
86
|
return print(await core.api.activity({ session_id: rest[0] }))
|
|
78
87
|
case 'runs':
|
|
79
|
-
return print(await core.api.list_runs({ limit: Number(flags.limit) || 10 }))
|
|
88
|
+
return print(await core.api.list_runs({ limit: Number(flags.limit) || 10, session_id: flags.session }))
|
|
80
89
|
default:
|
|
81
90
|
console.error(USAGE)
|
|
82
91
|
process.exitCode = cmd ? 2 : 0
|