dsh-baton-mcp 0.5.7 → 0.5.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 +6 -4
- package/README.zh-CN.md +6 -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 +12 -5
- package/src/core.mjs +27 -6
- package/src/server.mjs +44 -10
package/README.md
CHANGED
|
@@ -88,14 +88,16 @@ 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 recommended option marked). The assistant answers by its own judgment only when you told it to decide; otherwise it brings you the questions with its recommendation. 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 |
|
|
99
101
|
|
|
100
102
|
</details>
|
|
101
103
|
|
package/README.zh-CN.md
CHANGED
|
@@ -88,14 +88,16 @@ 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 窗口里回答,谁先答就算谁的 |
|
|
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` | 列出最近的运行,可按会话筛选 |
|
|
99
101
|
|
|
100
102
|
</details>
|
|
101
103
|
|
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.8",
|
|
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.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.5.
|
|
3
|
+
"version": "0.5.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.5.
|
|
10
|
+
"version": "0.5.8",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "dsh-baton-mcp",
|
|
15
|
-
"version": "0.5.
|
|
15
|
+
"version": "0.5.8",
|
|
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,17 @@ 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({ 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
|
|
66
|
-
|
|
67
|
+
print({ 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,
|
|
68
|
+
...(st.needs_input ? { needs_input: st.needs_input, question: st.question, hint: st.question ? 'The run asks the user something: answer with dsh_answer (or dsh-baton answer), only by your own judgment if the user allowed that; otherwise show the user the questions with your own recommendation and a reason from your own judgment of the question and the task ("it is marked recommended" is not a reason) and ask first. Then wait again.' : 'The run waits for a person in the Harness UI. Tell the user, then wait again.' } : {}) })
|
|
69
|
+
process.exitCode = st.state === 'completed' ? 0 : st.needs_input ? 3 : 1
|
|
67
70
|
return
|
|
68
71
|
}
|
|
69
72
|
case 'cancel':
|
|
70
73
|
return print(await core.api.cancel(rest[0]?.startsWith('session') || flags.session ? { session_id: flags.session ?? rest[0] } : { run_id: rest[0] }))
|
|
74
|
+
case 'queued':
|
|
75
|
+
return print(await core.api.update_queued({ run_id: rest[0], action: rest[1], ...(rest[1] === 'edit' ? { message: rest.slice(2).join(' ') } : {}) }))
|
|
76
|
+
case 'answer':
|
|
77
|
+
return print(await core.api.answer({ run_id: rest[0], answers: JSON.parse(rest.slice(1).join(' ')) }))
|
|
71
78
|
case 'rename':
|
|
72
79
|
return print(await core.api.rename_session({ session_id: rest[0], title: rest.slice(1).join(' ') }))
|
|
73
80
|
case 'rename-workspace':
|
|
@@ -76,7 +83,7 @@ async function main() {
|
|
|
76
83
|
case 'activity':
|
|
77
84
|
return print(await core.api.activity({ session_id: rest[0] }))
|
|
78
85
|
case 'runs':
|
|
79
|
-
return print(await core.api.list_runs({ limit: Number(flags.limit) || 10 }))
|
|
86
|
+
return print(await core.api.list_runs({ limit: Number(flags.limit) || 10, session_id: flags.session }))
|
|
80
87
|
default:
|
|
81
88
|
console.error(USAGE)
|
|
82
89
|
process.exitCode = cmd ? 2 : 0
|
package/src/core.mjs
CHANGED
|
@@ -506,10 +506,12 @@ export function cancelRun(runId) {
|
|
|
506
506
|
return { ...st, state: 'cancelling' }
|
|
507
507
|
}
|
|
508
508
|
|
|
509
|
-
export function listRuns({ limit = 10 } = {}) {
|
|
509
|
+
export function listRuns({ limit = 10, session_id } = {}) {
|
|
510
510
|
if (!fs.existsSync(runsDir())) return []
|
|
511
|
-
|
|
512
|
-
|
|
511
|
+
const ids = fs.readdirSync(runsDir()).filter((d) => d.startsWith('run-')).sort().reverse()
|
|
512
|
+
return (session_id ? ids : ids.slice(0, limit))
|
|
513
|
+
.map((id) => { try { return runStatus(id, { tail: 0 }) } catch { return null } })
|
|
514
|
+
.filter((r) => r && (!session_id || r.session_id === session_id)).slice(0, limit)
|
|
513
515
|
}
|
|
514
516
|
|
|
515
517
|
// ---------------------------------------------------------------------------
|
|
@@ -568,8 +570,27 @@ export const api = {
|
|
|
568
570
|
if (!(await useHost())) throw new Error('Session activity needs the dsh-baton-host plugin running inside DeepSeek Harness.')
|
|
569
571
|
return hostCall('activity', { session_id })
|
|
570
572
|
},
|
|
571
|
-
async
|
|
572
|
-
|
|
573
|
-
|
|
573
|
+
async update_queued(a) {
|
|
574
|
+
if (!(await useHost())) throw new Error('Changing a queued message needs the dsh-baton-host plugin running inside DeepSeek Harness.')
|
|
575
|
+
try {
|
|
576
|
+
return await hostCall('update_queued', a)
|
|
577
|
+
} catch (error) {
|
|
578
|
+
if (!/Unknown method: update_queued/.test(error?.message)) throw error
|
|
579
|
+
throw new Error(`The Harness plugin dsh-baton-host ${hostVersion ?? ''} is too old to change queued messages; this MCP server is ${VERSION}. Update the plugin in Harness: Plugins → dsh-baton-host → Uninstall, then Add plugin → dsh-baton-host@${VERSION}.`.replace(' ', ' '))
|
|
580
|
+
}
|
|
581
|
+
},
|
|
582
|
+
async answer(a) {
|
|
583
|
+
if (!(await useHost())) throw new Error('Answering a question needs the dsh-baton-host plugin running inside DeepSeek Harness.')
|
|
584
|
+
try {
|
|
585
|
+
return await hostCall('answer', a)
|
|
586
|
+
} catch (error) {
|
|
587
|
+
if (!/Unknown method: answer/.test(error?.message)) throw error
|
|
588
|
+
throw new Error(`The Harness plugin dsh-baton-host ${hostVersion ?? ''} is too old to answer questions; this MCP server is ${VERSION}. Answer in the Harness window, or update the plugin: Plugins → dsh-baton-host → Uninstall, then Add plugin → dsh-baton-host@${VERSION}.`.replace(' ', ' '))
|
|
589
|
+
}
|
|
590
|
+
},
|
|
591
|
+
async list_runs({ limit = 10, session_id } = {}) {
|
|
592
|
+
// An older plugin ignores session_id, so the host's runs are filtered here too.
|
|
593
|
+
const host = (await hostAvailable()) ? tag('host', await hostCall('list_runs', { limit: session_id ? 100 : limit, session_id })).filter((r) => !session_id || r.session_id === session_id) : []
|
|
594
|
+
return [...host, ...tag('headless', listRuns({ limit, session_id }))].sort((x, y) => String(y.started_at).localeCompare(String(x.started_at))).slice(0, limit)
|
|
574
595
|
},
|
|
575
596
|
}
|
package/src/server.mjs
CHANGED
|
@@ -32,6 +32,11 @@ const codeModeYield = () => {
|
|
|
32
32
|
}
|
|
33
33
|
const NO_POLL = 'While waiting, make no other calls: no dsh_status, dsh_session_activity or dsh_read_session, and do not list or read the target folder or files to check progress. The final dsh_wait result carries the reply of the run; check files only after the run ends, if needed.'
|
|
34
34
|
const ARCHIVED = 'Sessions not in dsh_list_sessions are archived or gone: do not look for them on disk.'
|
|
35
|
+
// What to do when a run stops to ask a person (dsh_wait, wait_command and the instructions say it).
|
|
36
|
+
const ASKED = 'If the user told you to decide such questions yourself, answer with dsh_answer by your own judgment (the recommended option is only a suggestion). Otherwise show the user the questions and options, add your own recommendation with a short reason drawn from your own judgment of the question and the task (it may differ from the option marked recommended; "it is marked recommended" is not a reason), wait for their decision, then submit it with dsh_answer. Never answer on the user\'s behalf without that permission.'
|
|
37
|
+
const NEEDS_INPUT_HINT = (st) => st.question
|
|
38
|
+
? `The run stopped to ask the user ${st.question.count} question(s) (in question). ${ASKED} After answering, wait for the run again.`
|
|
39
|
+
: 'The run waits for something only a person can do in the Harness UI (an approval, for example). Tell the user, then wait for the run again.'
|
|
35
40
|
|
|
36
41
|
// A shell command that blocks until a run ends and prints its reply. Run in the
|
|
37
42
|
// background by a client that wakes the model when the command exits (Claude Code),
|
|
@@ -54,6 +59,7 @@ async function waitInChunks({ run_id }, progress) {
|
|
|
54
59
|
const left = Math.ceil((deadline - Date.now()) / 1000)
|
|
55
60
|
const t0 = Date.now()
|
|
56
61
|
const st = await core.api.wait({ run_id, timeout_s: Math.max(1, Math.min(WAIT_CHUNK_S, left)) })
|
|
62
|
+
if (st.needs_input) return { ...st, hint: NEEDS_INPUT_HINT(st) }
|
|
57
63
|
if (!st.timed_out) return st
|
|
58
64
|
// Never spin: a chunk that came back early still takes a moment before the next one.
|
|
59
65
|
if (Date.now() - t0 < 500) await new Promise((r) => setTimeout(r, 500))
|
|
@@ -62,7 +68,7 @@ async function waitInChunks({ run_id }, progress) {
|
|
|
62
68
|
run_id, state: st.state, timed_out: true,
|
|
63
69
|
...(st.waiting_on ? { waiting_on: st.waiting_on } : {}),
|
|
64
70
|
hint: st.state === 'waiting'
|
|
65
|
-
? 'The run is waiting for a person
|
|
71
|
+
? 'The run is still waiting for a person (see dsh_status for its question). Answer it with dsh_answer if you may, or tell the user; then call dsh_wait again.'
|
|
66
72
|
: `Still ${st.state}. Call dsh_wait again with the same run_id, with no other calls in between and no progress note.`,
|
|
67
73
|
}
|
|
68
74
|
}
|
|
@@ -108,17 +114,17 @@ const TOOLS = [
|
|
|
108
114
|
{
|
|
109
115
|
name: 'dsh_send',
|
|
110
116
|
annotations: annotations('Send message', hints(false, false, false)),
|
|
111
|
-
description: 'Send a message to a DeepSeek Harness session and return at once with a run_id, without waiting for the reply; dsh_wait then waits for it. With session_id it continues that existing session (same context, no new session); with cwd or workspace_id it starts a new session there (a cwd that is not a workspace yet is registered in the sidebar first). The turn runs in the background. If the session is already working, mode followup
|
|
112
|
-
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'), workspace_id: str('Harness workspace for a new session (from dsh_list_workspaces)'), mode: { type: 'string', enum: ['followup', 'steer'], description:
|
|
117
|
+
description: 'Send a message to a DeepSeek Harness session and return at once with a run_id, without waiting for the reply; dsh_wait then waits for it. With session_id it continues that existing session (same context, no new session); with cwd or workspace_id it starts a new session there (a cwd that is not a workspace yet is registered in the sidebar first). The turn runs in the background. If the session is already working, mode followup queues the message after the current turn and steer adds it to the running turn; each message gets its own run_id. Leave mode out to do what the user set in Harness (Settings, sending while busy: queue or steer); followup when that setting cannot be read. The result says which mode was used and why (mode_source: explicit, harness or default). A queued message can still be edited, removed or turned into a steer with dsh_update_queued. The message is delivered verbatim as a user message, with no prefix or label added: write it in the language the user works in, self-contained (Harness does not see this conversation), and say who is asking only if it matters. Then wait for the reply with dsh_wait (or the wait_command it returns, where offered).',
|
|
118
|
+
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'), 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" } } },
|
|
113
119
|
run: async (a) => {
|
|
114
120
|
const st = await core.api.send({ ...a, sender: core.senderOf(core.setting('SENDER') || clientName) })
|
|
115
121
|
// Codex has no wake-on-exit for shell commands: offered wait_command, it runs it in a
|
|
116
122
|
// terminal and polls that, so it gets dsh_wait only.
|
|
117
|
-
if (isCodex()) return { ...st, hint: `Run started. Call dsh_wait with this run_id; it returns when the run ends. ${NO_POLL} ${codeModeYield()}` }
|
|
123
|
+
if (isCodex()) 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. ${NO_POLL} ${codeModeYield()}` }
|
|
118
124
|
return {
|
|
119
125
|
...st,
|
|
120
126
|
wait_command: waitCommand(st.run_id),
|
|
121
|
-
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 and prints the reply, with no polling. Otherwise call dsh_wait with this run_id.',
|
|
127
|
+
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.',
|
|
122
128
|
}
|
|
123
129
|
},
|
|
124
130
|
},
|
|
@@ -132,10 +138,31 @@ const TOOLS = [
|
|
|
132
138
|
{
|
|
133
139
|
name: 'dsh_wait',
|
|
134
140
|
annotations: annotations('Wait for run'),
|
|
135
|
-
description: () => `Wait for a run sent with dsh_send to end and return its final status and reply; dsh_status only looks once.${isCodex() ? '' : ' Prefer the background wait_command from dsh_send when your client can run background shell commands and wake you when they exit: it costs no turns while waiting.'} Each call waits up to ${waitSeconds()} s (set by the server to stay under this client's tool timeout); if the run is still going it returns timed_out: true with just the state: then call dsh_wait again with the same run_id, without summarising progress.${isCodex() ? ` ${NO_POLL} ${codeModeYield()}` : ''} Sends progress notifications while waiting when the client asks for them.`,
|
|
141
|
+
description: () => `Wait for a run sent with dsh_send to end and return its final status and reply; dsh_status only looks once.${isCodex() ? '' : ' Prefer the background wait_command from dsh_send when your client can run background shell commands and wake you when they exit: it costs no turns while waiting.'} It also returns at once when the run stops to ask a person: needs_input is answer (the questions are in question; relay them with your own recommendation and a reason from your own judgment of the task, not the recommended mark, then reply with dsh_answer) or harness-ui (an approval only a person can give in Harness). Each call waits up to ${waitSeconds()} s (set by the server to stay under this client's tool timeout); if the run is still going it returns timed_out: true with just the state: then call dsh_wait again with the same run_id, without summarising progress.${isCodex() ? ` ${NO_POLL} ${codeModeYield()}` : ''} Sends progress notifications while waiting when the client asks for them.`,
|
|
136
142
|
inputSchema: { type: 'object', required: ['run_id'], properties: { run_id: str('Run id from dsh_send') } },
|
|
137
143
|
run: (a, { progress }) => waitInChunks(a, progress),
|
|
138
144
|
},
|
|
145
|
+
{
|
|
146
|
+
name: 'dsh_answer',
|
|
147
|
+
annotations: annotations('Answer question', hints(false, false, false)),
|
|
148
|
+
description: `Answer the questions a run sent with dsh_send is waiting on (Harness's ask_user_question, shown in dsh_wait / dsh_status as question); dsh_send sends a new message instead, and dsh_update_queued changes one still in the queue. Per question: choose option numbers or labels, text for a free-text answer (with a single choice it replaces the option), or skip. Several questions can be answered in one call or one at a time: the answer reaches Harness once every question has one (submitted: true; until then remaining lists the rest). The run then continues; wait for it again. Whoever answers first wins: if the user answers in the Harness window, this call reports there is no open question. ${ASKED}`,
|
|
149
|
+
inputSchema: {
|
|
150
|
+
type: 'object', required: ['run_id', 'answers'], properties: {
|
|
151
|
+
run_id: str('Run id that is waiting (needs_input: answer)'),
|
|
152
|
+
answers: {
|
|
153
|
+
type: 'array', description: 'One entry per question answered now', items: {
|
|
154
|
+
type: 'object', properties: {
|
|
155
|
+
id: str('Question id from question.questions[].id; may be left out when there is one question'),
|
|
156
|
+
choose: { type: 'array', items: { type: ['integer', 'string'] }, description: 'Option numbers (1 = first) or labels; one for a single-choice question' },
|
|
157
|
+
text: str('Free-text answer'),
|
|
158
|
+
skip: { type: 'boolean', description: 'Skip this question' },
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
run: (a) => core.api.answer(a),
|
|
165
|
+
},
|
|
139
166
|
{
|
|
140
167
|
name: 'dsh_rename_session',
|
|
141
168
|
annotations: annotations('Rename session', EDIT),
|
|
@@ -160,15 +187,22 @@ const TOOLS = [
|
|
|
160
187
|
{
|
|
161
188
|
name: 'dsh_cancel',
|
|
162
189
|
annotations: annotations('Cancel session work', hints(false, true, true)),
|
|
163
|
-
description: 'Stop everything a session is doing (running turn, sub-agents, background jobs, reminders), by run_id or session_id; the session itself is kept, and this is the only tool that stops work. It retries a few times until the session is quiet, then returns session_id, run (when run_id was given), attempts, still_busy, activity (whatever is still going) and
|
|
190
|
+
description: 'Stop everything a session is doing (running turn, sub-agents, background jobs, reminders), by run_id or session_id; the session itself is kept, and this is the only tool that stops work. It retries a few times until the session is quiet, then returns session_id, run (when run_id was given), attempts, still_busy, activity (whatever is still going), queued_runs and removed_from_queue. Harness also drops the messages still waiting in the session queue: their runs end as cancelled and are listed in removed_from_queue; to drop just one queued message, use dsh_update_queued instead. Safe to repeat: on an idle session it does nothing and returns still_busy: false. Use dsh_session_activity first to see what would be stopped.',
|
|
164
191
|
inputSchema: { type: 'object', properties: { run_id: str('Run id from dsh_send; stops the session that run belongs to'), session_id: str('Session id, to stop work dsh-baton did not start') } },
|
|
165
192
|
run: (a) => core.api.cancel(a),
|
|
166
193
|
},
|
|
194
|
+
{
|
|
195
|
+
name: 'dsh_update_queued',
|
|
196
|
+
annotations: annotations('Change queued message', hints(false, true, false)),
|
|
197
|
+
description: 'Change one message sent with dsh_send that is still waiting in Harness\'s queue behind a running turn; dsh_send sends a new one and dsh_cancel stops the whole session. action edit replaces its text with message, remove deletes it (its run ends as cancelled), steer moves it into the running turn now. Only for a run whose state is queued: once its message has reached the conversation it can no longer be changed, and the error says so. Returns the run (with message_preview after an edit) and the action taken.',
|
|
198
|
+
inputSchema: { type: 'object', required: ['run_id', 'action'], properties: { run_id: str('Run id from dsh_send of the queued message'), action: { type: 'string', enum: ['edit', 'remove', 'steer'], description: 'edit replaces the text, remove deletes the message, steer sends it into the running turn now' }, message: str('New text for action edit (plain text, not empty)') } },
|
|
199
|
+
run: (a) => core.api.update_queued(a),
|
|
200
|
+
},
|
|
167
201
|
{
|
|
168
202
|
name: 'dsh_list_runs',
|
|
169
203
|
annotations: annotations('List runs'),
|
|
170
|
-
description: 'List the runs sent through dsh_send (the latest 100 are kept), newest first, to recover a lost run_id or review past hand-overs; dsh_list_sessions lists Harness sessions instead. Returns run_id, session_id, state, started_at, ended_at, message_preview, final_text and error for each; dsh_status shows one run in detail. Read-only.',
|
|
171
|
-
inputSchema: { type: 'object', properties: { limit: int('Max runs returned', 10) } },
|
|
204
|
+
description: 'List the runs sent through dsh_send (the latest 100 are kept), newest first, to recover a lost run_id or review past hand-overs (session_id narrows it to one session); dsh_list_sessions lists Harness sessions instead. Returns run_id, session_id, state, started_at, ended_at, message_preview, final_text and error for each; dsh_status shows one run in detail. Read-only.',
|
|
205
|
+
inputSchema: { type: 'object', properties: { session_id: str('Only runs sent to this session'), limit: int('Max runs returned', 10) } },
|
|
172
206
|
run: (a) => core.api.list_runs(a),
|
|
173
207
|
},
|
|
174
208
|
]
|
|
@@ -189,7 +223,7 @@ async function handle(req) {
|
|
|
189
223
|
protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
|
|
190
224
|
capabilities: { tools: {} },
|
|
191
225
|
serverInfo: SERVER,
|
|
192
|
-
instructions: `Hands tasks to a local DeepSeek Harness and passes the results back. Typical flow: dsh_list_workspaces (or dsh_create_workspace) to pick a folder, dsh_list_sessions to find a session, dsh_send {session_id, message} to continue it (or {cwd, message} to start one), then wait for the reply: ${isCodex() ? 'call dsh_wait with the run_id until it returns the final state (not dsh_status)' : 'run the wait_command it returns as a background shell command if your client wakes you when one exits, otherwise call dsh_wait'}. Then dsh_read_session to read the conversation, dsh_session_activity to see what a session is doing, and dsh_cancel to stop it; dsh_list_runs recovers a lost run_id. A run
|
|
226
|
+
instructions: `Hands tasks to a local DeepSeek Harness and passes the results back. Typical flow: dsh_list_workspaces (or dsh_create_workspace) to pick a folder, dsh_list_sessions to find a session, dsh_send {session_id, message} to continue it (or {cwd, message} to start one), then wait for the reply: ${isCodex() ? 'call dsh_wait with the run_id until it returns the final state (not dsh_status)' : 'run the wait_command it returns as a background shell command if your client wakes you when one exits, otherwise call dsh_wait'}. Then dsh_read_session to read the conversation, dsh_session_activity to see what a session is doing, dsh_update_queued to edit or withdraw a message still waiting in its queue, and dsh_cancel to stop it; dsh_list_runs recovers a lost run_id. A run that stops to ask the user something ends the wait at once with needs_input and the questions; answer with dsh_answer. ${ASKED} ${ARCHIVED}`,
|
|
193
227
|
}
|
|
194
228
|
}
|
|
195
229
|
case 'ping':
|