dsh-baton-mcp 0.5.8 → 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 CHANGED
@@ -91,13 +91,14 @@ That is all. If Harness says it could not register, run `npx -y dsh-baton-mcp se
91
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
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
+ | `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 |
95
95
  | `dsh_session_activity` | What a session is doing now: running turn, sub-agents (any depth), background jobs; `archived: true` for an archived session |
96
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
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` |
98
98
  | `dsh_rename_session` | Rename a session |
99
99
  | `dsh_rename_workspace` | Rename a workspace (sidebar title only; the folder is not moved) |
100
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 |
101
102
 
102
103
  </details>
103
104
 
@@ -142,6 +143,8 @@ When the plugin is unreachable, the MCP server falls back to Harness's `dsh head
142
143
 
143
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.
144
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
+
145
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.
146
149
 
147
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
@@ -91,13 +91,14 @@ Codex 和 Claude 轮流往同一个 Harness 对话发消息,每条都标明是
91
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
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
+ | `dsh_answer` | 回答运行中途提出的问题(Harness 的 ask_user_question):每道题可以填选项编号或选项文字、自由填写,或者跳过;可以逐题回答,也可以一次答完。运行一提问,`dsh_wait` 和 `wait_command` 就会立刻返回,并带上题目(`needs_input`、`question`;Harness 那边模型推荐的选项单独放在 `harness_suggested` 里,不再写在选项文字中)。只有你说过让它自己决定时,助手才会按自己的判断回答,这时它会带上 `reason`,并告诉你选了什么、为什么;否则它会把题目转给你,开头先写它自己的建议和理由。你也可以直接在 Harness 窗口里回答,谁先答就算谁的 |
95
95
  | `dsh_session_activity` | 对话此刻在做什么:正在跑的回合、子智能体(任意层级)、后台任务;已归档的对话会带 `archived: true` |
96
96
  | `dsh_update_queued` | 按 `run_id` 修改还在忙碌对话队列里等待的消息:`edit` 改文字,`remove` 撤回,`steer` 立刻插进正在跑的回合。消息一旦进入对话就改不了了 |
97
97
  | `dsh_cancel` | 按 `run_id` 或 `session_id` 停掉整个对话的工作:当前回合、子智能体、后台任务。Harness 会同时清掉队列里等待的消息,这些运行记为 `cancelled` |
98
98
  | `dsh_rename_session` | 给对话改名 |
99
99
  | `dsh_rename_workspace` | 给工作区改名(只改侧边栏标题,不动文件夹) |
100
100
  | `dsh_list_runs` | 列出最近的运行,可按会话筛选 |
101
+ | `dsh_call` | 备用入口:按名字调用任意 `dsh_*` 工具,用于对话的工具列表还是更新前的旧版时;名字不存在时会列出所有可用工具 |
101
102
 
102
103
  </details>
103
104
 
@@ -144,6 +145,8 @@ dsh-baton rename-workspace ~/code/new-app 新名字
144
145
 
145
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`。
146
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
+
147
150
  **`npx -y dsh-baton-mcp setup`** 在终端里做同样的注册:用于插件没能注册的情况,或者从 MCP 目录过来的用户。插件已经注册过时它什么都不改;插件没装的话,它会提示去添加。
148
151
 
149
152
  **更新。**Harness 只给官方插件提供"更新"按钮,所以要:插件 → dsh-baton-host → **卸载**,再**添加插件** → `dsh-baton-host`。新版本刚发布的一段时间里,Harness 会装上一个版本(pnpm 的 `minimumReleaseAge` 会暂缓刚发布的版本,安装弹窗里会说明);想马上用新版,输入带版本号的名字:`dsh-baton-host@0.5.0`。重新启用后,插件会把 MCP 服务器也更新到同一版本,新开的对话就会用上。万一两边版本对不上,第一次发现不一致的工具调用会提示。
package/host/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-baton-host",
3
- "version": "0.5.8",
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.8"
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.8",
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.8",
10
+ "version": "0.5.9",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "dsh-baton-mcp",
15
- "version": "0.5.8",
15
+ "version": "0.5.9",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
package/src/cli.mjs CHANGED
@@ -64,8 +64,10 @@ async function main() {
64
64
  return print(await core.api.status({ run_id: rest[0] }))
65
65
  case 'wait': {
66
66
  const st = await core.api.wait({ run_id: rest[0], timeout_s: Number(flags.timeout) || 24 * 3600 })
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.' } : {}) })
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.' } : {}) })
69
71
  process.exitCode = st.state === 'completed' ? 0 : st.needs_input ? 3 : 1
70
72
  return
71
73
  }
package/src/core.mjs CHANGED
@@ -526,6 +526,66 @@ async function useHost() {
526
526
  return up
527
527
  }
528
528
 
529
+ // The asking model marks its favourite option in the label ("(Recommended)", "(推荐)").
530
+ // Clients relaying the question tended to repeat that mark as their own advice, so the
531
+ // label is cleaned and the mark moves to harness_suggested: what the asker suggests, kept
532
+ // apart from the recommendation the client has to form itself. Harness still matches a
533
+ // cleaned label when it is chosen by label.
534
+ const MARK = /\s*(?:\((?:recommended|推荐)\)|((?:recommended|推荐)))\s*/gi
535
+ const cleanLabel = (label) => String(label).replace(MARK, ' ').trim()
536
+ export function shapeQuestion(question) {
537
+ if (!Array.isArray(question?.questions)) return question
538
+ return {
539
+ ...question,
540
+ questions: question.questions.map((q) => {
541
+ let suggested = null
542
+ const options = (q.options ?? []).map(({ recommended, ...o }) => {
543
+ const label = cleanLabel(o.label)
544
+ if (!suggested && (recommended || label !== o.label)) suggested = { number: o.number, label }
545
+ return { ...o, label }
546
+ })
547
+ return { ...q, options, ...(suggested ? { harness_suggested: suggested } : {}) }
548
+ }),
549
+ }
550
+ }
551
+ // The question laid out for the user, ready to show: the first line is left for the
552
+ // client's own recommendation, the asker's pick comes last and only as information.
553
+ // Weaker models fill in a template more reliably than they follow a paragraph of rules.
554
+ export function relayText(question) {
555
+ if (!Array.isArray(question?.questions)) return undefined
556
+ const blocks = question.questions.map((q, i) => {
557
+ const head = `${question.questions.length > 1 ? `${i + 1}. ` : ''}${q.question}${q.multi_select ? ' (several can be chosen)' : ''}`
558
+ const options = (q.options ?? []).map((o) => ` ${o.number}. ${o.label}${o.description ? ` - ${o.description}` : ''}`)
559
+ const asker = q.harness_suggested ? [` (The model in Harness that asked would pick: ${q.harness_suggested.label})`] : []
560
+ return [head, ...options, ...asker].join('\n')
561
+ })
562
+ return ['My recommendation: <your pick>, because <your own reason>', '', ...blocks].join('\n')
563
+ }
564
+
565
+ // When the client chose for the user (dsh_answer with reason), the choice and reason are
566
+ // kept until the run ends: the final wait result carries them, so the client tells the
567
+ // user then, instead of only passing on the reply. A file, because the client may wait
568
+ // from another process (wait_command).
569
+ const reasonsFile = () => path.join(config.batonHome, 'answer-reasons.json')
570
+ const readReasons = () => { try { return JSON.parse(fs.readFileSync(reasonsFile(), 'utf8')) } catch { return {} } }
571
+ function rememberReason(runId, answers, reason) {
572
+ try {
573
+ const all = readReasons()
574
+ all[runId] = { answers: (answers ?? []).map((a) => ({ id: a.id, selected: (a.selected ?? []).map(cleanLabel), ...(a.custom ? { text: a.custom } : {}) })), reason, at: Date.now() }
575
+ const kept = Object.entries(all).sort((x, y) => y[1].at - x[1].at).slice(0, 50)
576
+ ensurePrivateDir(config.batonHome)
577
+ fs.writeFileSync(reasonsFile(), JSON.stringify(Object.fromEntries(kept)), { mode: 0o600 })
578
+ } catch { /* the reason is a courtesy; answering never fails over it */ }
579
+ }
580
+ const ENDED = new Set(['completed', 'failed', 'cancelled', 'interrupted'])
581
+ const shaped = (st) => {
582
+ if (!st || typeof st !== 'object') return st
583
+ let out = st.question ? { ...st, question: shapeQuestion(st.question) } : st
584
+ const decided = ENDED.has(st.state) && st.run_id ? readReasons()[st.run_id] : null
585
+ if (decided) out = { ...out, decided_for_user: { answers: decided.answers, reason: decided.reason } }
586
+ return Array.isArray(st.answers) ? { ...out, answers: st.answers.map((a) => (Array.isArray(a?.selected) ? { ...a, selected: a.selected.map(cleanLabel) } : a)) } : out
587
+ }
588
+
529
589
  const tag = (backend, value) => (Array.isArray(value) ? value.map((v) => ({ ...v, backend })) : { ...value, backend })
530
590
 
531
591
  export const api = {
@@ -548,10 +608,10 @@ export const api = {
548
608
  return tag('headless', await waitForSessionId(startRun(a)))
549
609
  },
550
610
  async status({ run_id, tail = 20 }) {
551
- return isHostRun(run_id) ? tag('host', await hostCall('status', { run_id, tail })) : tag('headless', runStatus(run_id, { tail }))
611
+ return shaped(isHostRun(run_id) ? tag('host', await hostCall('status', { run_id, tail })) : tag('headless', runStatus(run_id, { tail })))
552
612
  },
553
613
  async wait({ run_id, timeout_s = 300 }) {
554
- if (isHostRun(run_id)) return tag('host', await hostCall('wait', { run_id, timeout_s }, { timeoutMs: (timeout_s + 15) * 1000 }))
614
+ if (isHostRun(run_id)) return shaped(tag('host', await hostCall('wait', { run_id, timeout_s }, { timeoutMs: (timeout_s + 15) * 1000 })))
555
615
  return tag('headless', await waitRun(run_id, { timeout_s }))
556
616
  },
557
617
  async cancel({ run_id, session_id }) {
@@ -579,10 +639,15 @@ export const api = {
579
639
  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
640
  }
581
641
  },
582
- async answer(a) {
642
+ // reason (why the client chose, when the user let it decide) stays here: it is for the
643
+ // user, not for Harness.
644
+ async answer({ reason, ...a }) {
583
645
  if (!(await useHost())) throw new Error('Answering a question needs the dsh-baton-host plugin running inside DeepSeek Harness.')
584
646
  try {
585
- return await hostCall('answer', a)
647
+ const result = shaped(await hostCall('answer', a))
648
+ if (!(typeof reason === 'string' && reason.trim())) return result
649
+ if (result.submitted) rememberReason(a.run_id, result.answers, reason.trim())
650
+ return { ...result, reason: reason.trim() }
586
651
  } catch (error) {
587
652
  if (!/Unknown method: answer/.test(error?.message)) throw error
588
653
  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(' ', ' '))
@@ -591,6 +656,6 @@ export const api = {
591
656
  async list_runs({ limit = 10, session_id } = {}) {
592
657
  // An older plugin ignores session_id, so the host's runs are filtered here too.
593
658
  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)
659
+ return [...host, ...tag('headless', listRuns({ limit, session_id }))].sort((x, y) => String(y.started_at).localeCompare(String(x.started_at))).slice(0, limit).map(shaped)
595
660
  },
596
661
  }
package/src/mcp.mjs ADDED
@@ -0,0 +1,327 @@
1
+ // The MCP server itself (newline-delimited JSON-RPC 2.0 over stdio, no dependencies). It
2
+ // runs as a child of server.mjs, which starts a fresh one when a newer version is installed.
3
+ import readline from 'node:readline'
4
+ import path from 'node:path'
5
+ import * as core from './core.mjs'
6
+ import { stableNodePath } from './setup.mjs'
7
+
8
+ const SERVER = { name: 'dsh-baton', version: core.VERSION }
9
+ const PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05']
10
+
11
+ const str = (description) => ({ type: 'string', description })
12
+ const int = (description, d) => ({ type: 'integer', description, default: d })
13
+
14
+ // How long one dsh_wait call waits, chosen by the server so it always ends before the
15
+ // client gives up on the tool call: Codex stops a call after 300 s, so it gets 270 s;
16
+ // other clients get 50 s (Claude Code waits with wait_command instead). DSH_BATON_WAIT_S
17
+ // overrides it for a client whose timeout was raised. A timeout_s from the model is
18
+ // ignored, so a model cannot pick a short wait and poll.
19
+ const MAX_WAIT_S = 24 * 3600
20
+ const envWait = Math.floor(Number(core.setting('WAIT_S')))
21
+ const isCodex = () => core.senderOf(clientName) === 'codex'
22
+ const waitSeconds = () => {
23
+ if (envWait >= 1) return Math.min(envWait, MAX_WAIT_S)
24
+ return isCodex() ? 270 : 50
25
+ }
26
+ const WAIT_CHUNK_S = 10
27
+ // The Codex app runs tools from a script cell that yields every 30 s and wakes the model
28
+ // to resume it; a yield longer than one dsh_wait keeps the whole wait in one turn.
29
+ const codeModeYield = () => {
30
+ const ms = (waitSeconds() + 20) * 1000
31
+ return `Required when you call tools from a script cell ("Script running with cell ID …"): the script that calls dsh_wait must start with the line // @exec: {"yield_time_ms": ${ms}}, never a shorter value; if the cell still yields, resume it with wait and yield_time_ms ${ms} (or the largest value wait accepts), never a short value such as 30000 or 55000.`
32
+ }
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
+ 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, choose by your own judgment of the question and the task, call dsh_answer with reason (one sentence on why), then tell the user what you chose and why. Otherwise relay the questions to the user and start with your own advice, on a line of its own: "My recommendation: <option>, because <reason>", the reason drawn from your own judgment of the question and the task (harness_suggested only shows what the asking model would pick; it is never a reason by itself, and your pick may differ). Then list the options, wait for the user\'s decision and submit it with dsh_answer. Never answer on the user\'s behalf without that permission.'
37
+ const DECIDED_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.'
38
+ const NEEDS_INPUT_HINT = (st) => st.question
39
+ ? `The run stopped to ask the user ${st.question.count} question(s) (in question). Unless the user let you decide, show the user relay, written in the user's language, with its first line filled in with your own pick and reason (not the asker's pick and not its option description repeated), then ask which they want. ${ASKED} After answering, wait for the run again.`
40
+ : '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.'
41
+
42
+ // A shell command that blocks until a run ends and prints its reply. Run in the
43
+ // background by a client that wakes the model when the command exits (Claude Code),
44
+ // it waits without spending any turns. Plain "<node>" "<cli>" wait <id> form: it runs
45
+ // in POSIX shells and in Git Bash, which Claude Code uses on Windows too. Registered on
46
+ // Harness's own binary (no Node.js installed), the command keeps it in Node mode.
47
+ const quote = (p) => `"${p}"`
48
+ const nodeMode = process.versions.electron ? 'ELECTRON_RUN_AS_NODE=1 ' : ''
49
+ const waitCommand = (runId) =>
50
+ /^[\w.-]+$/.test(runId ?? '') ? `${nodeMode}${quote(stableNodePath())} ${quote(path.join(core.ROOT, 'src', 'cli.mjs'))} wait ${runId}` : undefined
51
+
52
+ // Wait in short chunks, reporting progress between them (clients that support it can
53
+ // show it or reset their timer). An unfinished wait returns only the state, not the run's
54
+ // recent events, so the client has nothing to summarise and simply waits again.
55
+ async function waitInChunks({ run_id }, progress) {
56
+ const total = waitSeconds()
57
+ const started = Date.now()
58
+ const deadline = started + total * 1000
59
+ for (;;) {
60
+ const left = Math.ceil((deadline - Date.now()) / 1000)
61
+ const t0 = Date.now()
62
+ const st = await core.api.wait({ run_id, timeout_s: Math.max(1, Math.min(WAIT_CHUNK_S, left)) })
63
+ if (st.needs_input) return { ...st, ...(st.question ? { relay: core.relayText(st.question) } : {}), hint: NEEDS_INPUT_HINT(st) }
64
+ // First in the result, so a client that skims to final_text still meets it.
65
+ if (!st.timed_out) return st.decided_for_user ? { hint: DECIDED_HINT, decided_for_user: st.decided_for_user, ...st } : st
66
+ // Never spin: a chunk that came back early still takes a moment before the next one.
67
+ if (Date.now() - t0 < 500) await new Promise((r) => setTimeout(r, 500))
68
+ if (Date.now() >= deadline) {
69
+ return {
70
+ run_id, state: st.state, timed_out: true,
71
+ ...(st.waiting_on ? { waiting_on: st.waiting_on } : {}),
72
+ hint: st.state === 'waiting'
73
+ ? '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.'
74
+ : `Still ${st.state}. Call dsh_wait again with the same run_id, with no other calls in between and no progress note.`,
75
+ }
76
+ }
77
+ progress?.(Math.round((Date.now() - started) / 1000), `run ${run_id} is ${st.state}`)
78
+ }
79
+ }
80
+
81
+ // MCP tool annotations. Every tool only talks to the local Harness (no open world).
82
+ const hints = (readOnlyHint, destructiveHint, idempotentHint) => ({ readOnlyHint, destructiveHint, idempotentHint, openWorldHint: false })
83
+ const READ = hints(true, false, true)
84
+ const EDIT = hints(false, false, true) // changes something, and the same call again changes nothing more
85
+ const annotations = (title, h = READ) => ({ title, ...h })
86
+
87
+ const TOOLS = [
88
+ {
89
+ name: 'dsh_list_workspaces',
90
+ annotations: annotations('List workspaces'),
91
+ description: 'List the workspaces (project folders) in the DeepSeek Harness sidebar; dsh_list_sessions lists the sessions inside them and dsh_create_workspace adds one. Returns id, path, title and session_count for each; the id is the workspace_id for dsh_send and dsh_rename_workspace. Read-only. Needs the dsh-baton-host plugin.',
92
+ inputSchema: { type: 'object', properties: {} },
93
+ run: () => core.api.workspaces(),
94
+ },
95
+ {
96
+ name: 'dsh_create_workspace',
97
+ annotations: annotations('Create workspace', EDIT),
98
+ description: 'Add a folder to the DeepSeek Harness sidebar as a workspace without starting a session; dsh_list_workspaces only lists existing ones, and dsh_send with a cwd registers the folder by itself. Creates the folder unless create_dir is false. Idempotent: an already registered folder returns its existing workspace. Returns id, path, title and created (false when it already existed). Needs the dsh-baton-host plugin.',
99
+ inputSchema: { type: 'object', required: ['path'], properties: { path: str('Absolute folder path (~ allowed)'), title: str('Sidebar title; defaults to the folder name'), create_dir: { type: 'boolean', description: 'Create the folder if missing', default: true } } },
100
+ run: (a) => core.api.create_workspace(a),
101
+ },
102
+ {
103
+ name: 'dsh_list_sessions',
104
+ annotations: annotations('List sessions'),
105
+ description: `List saved DeepSeek Harness sessions (conversations), newest first, to find a session_id; dsh_list_runs lists only the messages sent through dsh_send. Returns session_id, title, cwd, updated_at, running and bridge_runs (active dsh-baton run ids) for each. There is no paging: the list stops at limit, so to see older sessions raise limit or narrow it with cwd or query. Read-only. ${ARCHIVED}`,
106
+ inputSchema: { type: 'object', properties: { cwd: str('Only sessions of this workspace folder'), query: str('Case-insensitive text to match in title, folder or session id'), limit: int('Max sessions returned', 20), include_subagents: { type: 'boolean', description: 'Also list sub-agent sessions', default: false } } },
107
+ run: (a) => core.api.list_sessions(a),
108
+ },
109
+ {
110
+ name: 'dsh_read_session',
111
+ annotations: annotations('Read session'),
112
+ description: 'Read what was said in one session (its past messages), to catch up before continuing it; dsh_session_activity shows what it is doing now and dsh_status follows one run. Returns title, cwd, total_messages and the last messages, each with role (user or assistant), time, text and the names of tools the assistant called (tool output is not included). Read-only.',
113
+ inputSchema: { type: 'object', required: ['session_id'], properties: { session_id: str('Session id from dsh_list_sessions, e.g. session-…'), last: int('How many of the most recent messages to return; total_messages tells how many there are', 10) } },
114
+ run: (a) => core.api.read_session(a),
115
+ },
116
+ {
117
+ name: 'dsh_send',
118
+ annotations: annotations('Send message', hints(false, false, false)),
119
+ 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).',
120
+ 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" } } },
121
+ run: async (a) => {
122
+ const st = await core.api.send({ ...a, sender: core.senderOf(core.setting('SENDER') || clientName) })
123
+ // Codex has no wake-on-exit for shell commands: offered wait_command, it runs it in a
124
+ // terminal and polls that, so it gets dsh_wait only.
125
+ 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()}` }
126
+ return {
127
+ ...st,
128
+ wait_command: waitCommand(st.run_id),
129
+ 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.',
130
+ }
131
+ },
132
+ },
133
+ {
134
+ name: 'dsh_status',
135
+ annotations: annotations('Run status'),
136
+ description: 'Look once at one run sent with dsh_send, without waiting; dsh_wait waits for it to end and dsh_session_activity covers the whole session. Returns state (queued/running/waiting/completed/failed/cancelled, or interrupted when a Harness restart cut it short), session_id, recent events (text, tool calls), final reply, waiting_on when the turn is over but sub-agents or background jobs are still working, and human_interjections when someone typed in the Harness UI during the run (its result then reflects that input too). A one-off look, never a way to wait: to wait for the reply use dsh_wait, and if dsh_wait timed out, call dsh_wait again.',
137
+ inputSchema: { type: 'object', required: ['run_id'], properties: { run_id: str('Run id from dsh_send'), tail: int('How many recent events', 20) } },
138
+ run: (a) => core.api.status(a),
139
+ },
140
+ {
141
+ name: 'dsh_wait',
142
+ annotations: annotations('Wait for run'),
143
+ 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; show the user relay with its first line filled in with your own pick and reason, harness_suggested being only the asker's pick, 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.`,
144
+ inputSchema: { type: 'object', required: ['run_id'], properties: { run_id: str('Run id from dsh_send') } },
145
+ run: (a, { progress }) => waitInChunks(a, progress),
146
+ },
147
+ {
148
+ name: 'dsh_answer',
149
+ annotations: annotations('Answer question', hints(false, false, false)),
150
+ 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. When the user let you decide, pass reason. 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}`,
151
+ inputSchema: {
152
+ type: 'object', required: ['run_id', 'answers'], properties: {
153
+ run_id: str('Run id that is waiting (needs_input: answer)'),
154
+ answers: {
155
+ type: 'array', description: 'One entry per question answered now', items: {
156
+ type: 'object', properties: {
157
+ id: str('Question id from question.questions[].id; may be left out when there is one question'),
158
+ choose: { type: 'array', items: { type: ['integer', 'string'] }, description: 'Option numbers (1 = first) or labels; one for a single-choice question' },
159
+ text: str('Free-text answer'),
160
+ skip: { type: 'boolean', description: 'Skip this question' },
161
+ },
162
+ },
163
+ },
164
+ reason: str('Required when the user let you decide: one sentence on why you chose this, from your own judgment of the question and the task; it is returned for you to tell the user and not sent to Harness'),
165
+ },
166
+ },
167
+ run: async (a) => {
168
+ const st = await core.api.answer(a)
169
+ return {
170
+ ...st,
171
+ hint: st.reason
172
+ ? 'Tell the user what you chose and this reason. Then wait for the run again.'
173
+ : 'If you chose on the user\'s behalf, tell them what you chose and why, from your own judgment of the task (that the asker suggested it is not a reason); next time pass reason. Then wait for the run again.',
174
+ }
175
+ },
176
+ },
177
+ {
178
+ name: 'dsh_rename_session',
179
+ annotations: annotations('Rename session', EDIT),
180
+ description: 'Set the title of one DeepSeek Harness session (a conversation); dsh_rename_workspace renames a workspace (project folder) instead. Only the title changes; the conversation and any running turn are untouched. Can be called again at any time to change it back or to something else. Returns session_id and the saved title.',
181
+ inputSchema: { type: 'object', required: ['session_id', 'title'], properties: { session_id: str('Session id from dsh_list_sessions'), title: str('New title; surrounding spaces are trimmed and it must not be empty') } },
182
+ run: (a) => core.api.rename_session(a),
183
+ },
184
+ {
185
+ name: 'dsh_rename_workspace',
186
+ annotations: annotations('Rename workspace', EDIT),
187
+ description: 'Set the sidebar title of a DeepSeek Harness workspace (project folder); dsh_rename_session renames a single session instead. The folder on disk is not moved or renamed. Identify it by workspace_id or path. Can be called again to change it back. Returns id, path and the saved title.',
188
+ inputSchema: { type: 'object', required: ['title'], properties: { workspace_id: str('Workspace id from dsh_list_workspaces'), path: str('Workspace folder, used when workspace_id is not given'), title: str('New title; surrounding spaces are trimmed and it must not be empty') } },
189
+ run: (a) => core.api.rename_workspace(a),
190
+ },
191
+ {
192
+ name: 'dsh_session_activity',
193
+ annotations: annotations('Session activity'),
194
+ description: 'Show what one session is doing right now, across all its work; dsh_status follows a single run and dsh_read_session shows past messages. Returns busy, activity (its running turn, sub-agents at any depth, background jobs and reminders) and bridge_runs (active dsh-baton runs). Read-only. Check it before dsh_send to a session that might be busy, or before dsh_cancel.',
195
+ inputSchema: { type: 'object', required: ['session_id'], properties: { session_id: str('Session id from dsh_list_sessions') } },
196
+ run: (a) => core.api.activity(a),
197
+ },
198
+ {
199
+ name: 'dsh_cancel',
200
+ annotations: annotations('Cancel session work', hints(false, true, true)),
201
+ 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.',
202
+ 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') } },
203
+ run: (a) => core.api.cancel(a),
204
+ },
205
+ {
206
+ name: 'dsh_update_queued',
207
+ annotations: annotations('Change queued message', hints(false, true, false)),
208
+ 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.',
209
+ 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)') } },
210
+ run: (a) => core.api.update_queued(a),
211
+ },
212
+ {
213
+ name: 'dsh_list_runs',
214
+ annotations: annotations('List runs'),
215
+ 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.',
216
+ inputSchema: { type: 'object', properties: { session_id: str('Only runs sent to this session'), limit: int('Max runs returned', 10) } },
217
+ run: (a) => core.api.list_runs(a),
218
+ },
219
+ ]
220
+
221
+ // A client keeps the tool list a conversation started with (Codex does), so a tool added by
222
+ // an update installed without a restart is missing there. dsh_call reaches it by name, along
223
+ // the same path as a direct call.
224
+ const firstSentence = (text) => text.split(/(?<=\.)\s/)[0]
225
+ const catalog = () => TOOLS.filter((t) => t.name !== 'dsh_call').map((t) => ({
226
+ name: t.name,
227
+ description: firstSentence(typeof t.description === 'function' ? t.description() : t.description),
228
+ inputSchema: t.inputSchema,
229
+ }))
230
+ TOOLS.push({
231
+ name: 'dsh_call',
232
+ annotations: annotations('Call dsh-baton tool by name', hints(false, true, false)),
233
+ description: 'Backup entry that calls any dsh-baton tool by name, for when this conversation\'s tool list is out of date: dsh-baton was updated without a restart and a tool it now has (named in the update note) is not among your tools. Normally call the dsh_* tools directly; this runs the same code with the same arguments and returns exactly what the named tool returns, so it can do whatever that tool does (including sending, cancelling or answering). With a name that does not exist it changes nothing and returns the list of available tools with their input schemas.',
234
+ inputSchema: {
235
+ type: 'object', required: ['name'], properties: {
236
+ name: str('Tool name, e.g. dsh_send or a tool added by an update'),
237
+ arguments: { type: 'object', description: 'Arguments for that tool, as its input schema describes them (see the list returned for an unknown name)', default: {} },
238
+ },
239
+ },
240
+ run: (a, ctx) => {
241
+ const tool = TOOLS.find((t) => t.name === a.name && t.name !== 'dsh_call')
242
+ if (!tool) throw new Error(`No dsh-baton tool named ${JSON.stringify(a.name ?? null)}. Available tools (call them with dsh_call {name, arguments} if this conversation does not show them):\n${JSON.stringify(catalog(), null, 2)}`)
243
+ return tool.run(a.arguments && typeof a.arguments === 'object' ? a.arguments : {}, ctx)
244
+ },
245
+ })
246
+
247
+ let clientName = null
248
+ let versionWarned = false
249
+
250
+ function send(msg) {
251
+ process.stdout.write(JSON.stringify(msg) + '\n')
252
+ }
253
+
254
+ async function handle(req) {
255
+ switch (req.method) {
256
+ case 'initialize': {
257
+ const asked = req.params?.protocolVersion
258
+ clientName = req.params?.clientInfo?.name ?? null
259
+ return {
260
+ protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
261
+ capabilities: { tools: {} },
262
+ serverInfo: SERVER,
263
+ 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} If a note says dsh-baton was updated and names a tool you do not have, call it through dsh_call {name, arguments}.`,
264
+ }
265
+ }
266
+ case 'ping':
267
+ return {}
268
+ case 'tools/list':
269
+ return { tools: TOOLS.map(({ run, ...t }) => ({ ...t, description: typeof t.description === 'function' ? t.description() : t.description })) }
270
+ case 'tools/call': {
271
+ const tool = TOOLS.find((t) => t.name === req.params?.name)
272
+ if (!tool) throw Object.assign(new Error(`Unknown tool: ${req.params?.name}`), { code: -32602 })
273
+ try {
274
+ // MCP progress: only when the client sent a progressToken with this call.
275
+ const token = req.params?._meta?.progressToken
276
+ const progress = token === undefined ? undefined : (done, message) =>
277
+ send({ jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: token, progress: done, message } })
278
+ const result = await tool.run(req.params.arguments || {}, { progress })
279
+ const content = [{ type: 'text', text: JSON.stringify(result, null, 2) }]
280
+ // Said once per server process, the first time a call finds the versions apart.
281
+ const warning = !versionWarned && core.versionWarning()
282
+ if (warning) { versionWarned = true; content.push({ type: 'text', text: `Note for the user: ${warning}` }) }
283
+ return { content }
284
+ } catch (e) {
285
+ return { content: [{ type: 'text', text: e.message }], isError: true }
286
+ }
287
+ }
288
+ default:
289
+ throw Object.assign(new Error(`Method not found: ${req.method}`), { code: -32601 })
290
+ }
291
+ }
292
+
293
+ const rl = readline.createInterface({ input: process.stdin })
294
+
295
+ // Exit when the client goes away: stdin closes, or the parent process dies
296
+ // and we get re-parented. Otherwise orphaned servers pile up.
297
+ let inFlight = 0
298
+ let stdinClosed = false
299
+ // Writes to a piped stdout are asynchronous: flush the last reply before exiting.
300
+ const exitIfDone = () => { if (stdinClosed && inFlight === 0) process.stdout.write('', () => process.exit(0)) }
301
+ rl.on('close', () => { stdinClosed = true; exitIfDone() })
302
+ const parentPid = process.ppid
303
+ // Windows does not re-parent orphans (ppid stays the same), so there the parent is checked directly.
304
+ const parentGone = () => {
305
+ if (process.platform !== 'win32') return false
306
+ try { process.kill(parentPid, 0); return false } catch (e) { return e.code !== 'EPERM' }
307
+ }
308
+ setInterval(() => { if (process.ppid !== parentPid || parentGone()) process.exit(0) }, 5000).unref()
309
+
310
+ rl.on('line', async (line) => {
311
+ if (!line.trim()) return
312
+ let req
313
+ try { req = JSON.parse(line) } catch { return send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }) }
314
+ if (req === null || typeof req !== 'object' || Array.isArray(req) || typeof req.method !== 'string') {
315
+ return send({ jsonrpc: '2.0', id: req?.id ?? null, error: { code: -32600, message: 'Invalid Request' } })
316
+ }
317
+ if (req.id === undefined) return // notification
318
+ inFlight++
319
+ try {
320
+ send({ jsonrpc: '2.0', id: req.id, result: await handle(req) })
321
+ } catch (e) {
322
+ send({ jsonrpc: '2.0', id: req.id, error: { code: e.code || -32603, message: e.message } })
323
+ } finally {
324
+ inFlight--
325
+ exitIfDone()
326
+ }
327
+ })
package/src/server.mjs CHANGED
@@ -1,290 +1,226 @@
1
1
  #!/usr/bin/env node
2
- // MCP server over stdio (newline-delimited JSON-RPC 2.0), no dependencies.
3
- import readline from 'node:readline'
2
+ // What MCP clients start: a thin shell that holds the stdio connection and passes every
3
+ // message to the server proper (mcp.mjs), run as a child process. Before each tools/list
4
+ // and tools/call it checks package.json; when a newer copy was installed over this folder
5
+ // (by the Harness plugin, setup or by hand) it starts a fresh child from the new files and
6
+ // sends the call there, so an open conversation uses the new version without a restart or
7
+ // a reconnect. Calls still running in the old child (a long dsh_wait) finish there first.
8
+ // If the new child cannot start, the old one keeps serving and the reply says why.
9
+ import fs from 'node:fs'
4
10
  import path from 'node:path'
5
- import * as core from './core.mjs'
6
- import { stableNodePath } from './setup.mjs'
7
-
8
- const SERVER = { name: 'dsh-baton', version: core.VERSION }
9
- const PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05']
11
+ import readline from 'node:readline'
12
+ import { spawn } from 'node:child_process'
13
+ import { fileURLToPath } from 'node:url'
10
14
 
11
- const str = (description) => ({ type: 'string', description })
12
- const int = (description, d) => ({ type: 'integer', description, default: d })
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
16
+ const START_TIMEOUT_MS = Number(process.env.DSH_BATON_START_TIMEOUT_MS) || 15000
13
17
 
14
- // How long one dsh_wait call waits, chosen by the server so it always ends before the
15
- // client gives up on the tool call: Codex stops a call after 300 s, so it gets 270 s;
16
- // other clients get 50 s (Claude Code waits with wait_command instead). DSH_BATON_WAIT_S
17
- // overrides it for a client whose timeout was raised. A timeout_s from the model is
18
- // ignored, so a model cannot pick a short wait and poll.
19
- const MAX_WAIT_S = 24 * 3600
20
- const envWait = Math.floor(Number(core.setting('WAIT_S')))
21
- const isCodex = () => core.senderOf(clientName) === 'codex'
22
- const waitSeconds = () => {
23
- if (envWait >= 1) return Math.min(envWait, MAX_WAIT_S)
24
- return isCodex() ? 270 : 50
18
+ function send(msg) {
19
+ process.stdout.write(JSON.stringify(msg) + '\n')
25
20
  }
26
- const WAIT_CHUNK_S = 10
27
- // The Codex app runs tools from a script cell that yields every 30 s and wakes the model
28
- // to resume it; a yield longer than one dsh_wait keeps the whole wait in one turn.
29
- const codeModeYield = () => {
30
- const ms = (waitSeconds() + 20) * 1000
31
- return `Required when you call tools from a script cell ("Script running with cell ID …"): the script that calls dsh_wait must start with the line // @exec: {"yield_time_ms": ${ms}}, never a shorter value; if the cell still yields, resume it with wait and yield_time_ms ${ms} (or the largest value wait accepts), never a short value such as 30000 or 55000.`
21
+
22
+ // What identifies an installed copy: its version, and the time package.json was written
23
+ // (a copy with the same version, such as a test build, still counts as new). The installer
24
+ // writes package.json after src, so a new stamp means the new files are in place.
25
+ function stamp() {
26
+ try {
27
+ const file = path.join(ROOT, 'package.json')
28
+ return `${JSON.parse(fs.readFileSync(file, 'utf8')).version}@${fs.statSync(file).mtimeMs}`
29
+ } catch {
30
+ return null
31
+ }
32
32
  }
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
- 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.'
40
33
 
41
- // A shell command that blocks until a run ends and prints its reply. Run in the
42
- // background by a client that wakes the model when the command exits (Claude Code),
43
- // it waits without spending any turns. Plain "<node>" "<cli>" wait <id> form: it runs
44
- // in POSIX shells and in Git Bash, which Claude Code uses on Windows too. Registered on
45
- // Harness's own binary (no Node.js installed), the command keeps it in Node mode.
46
- const quote = (p) => `"${p}"`
47
- const nodeMode = process.versions.electron ? 'ELECTRON_RUN_AS_NODE=1 ' : ''
48
- const waitCommand = (runId) =>
49
- /^[\w.-]+$/.test(runId ?? '') ? `${nodeMode}${quote(stableNodePath())} ${quote(path.join(core.ROOT, 'src', 'cli.mjs'))} wait ${runId}` : undefined
34
+ let nextInternal = 0
35
+ const children = new Set()
50
36
 
51
- // Wait in short chunks, reporting progress between them (clients that support it can
52
- // show it or reset their timer). An unfinished wait returns only the state, not the run's
53
- // recent events, so the client has nothing to summarise and simply waits again.
54
- async function waitInChunks({ run_id }, progress) {
55
- const total = waitSeconds()
56
- const started = Date.now()
57
- const deadline = started + total * 1000
58
- for (;;) {
59
- const left = Math.ceil((deadline - Date.now()) / 1000)
60
- const t0 = Date.now()
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) }
63
- if (!st.timed_out) return st
64
- // Never spin: a chunk that came back early still takes a moment before the next one.
65
- if (Date.now() - t0 < 500) await new Promise((r) => setTimeout(r, 500))
66
- if (Date.now() >= deadline) {
67
- return {
68
- run_id, state: st.state, timed_out: true,
69
- ...(st.waiting_on ? { waiting_on: st.waiting_on } : {}),
70
- hint: st.state === 'waiting'
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.'
72
- : `Still ${st.state}. Call dsh_wait again with the same run_id, with no other calls in between and no progress note.`,
73
- }
74
- }
75
- progress?.(Math.round((Date.now() - started) / 1000), `run ${run_id} is ${st.state}`)
37
+ function startChild() {
38
+ const proc = spawn(process.execPath, [path.join(ROOT, 'src', 'mcp.mjs')], {
39
+ stdio: ['pipe', 'pipe', 'inherit'],
40
+ windowsHide: true,
41
+ env: process.versions.electron ? { ...process.env, ELECTRON_RUN_AS_NODE: '1' } : process.env,
42
+ })
43
+ const child = {
44
+ proc,
45
+ clientCalls: new Map(), // client request id -> optional rewrite of the result
46
+ internal: new Map(), // the shell's own requests: id -> { resolve, reject }
47
+ retired: false,
48
+ exited: false,
49
+ write(msg) { if (!this.exited && proc.stdin.writable) proc.stdin.write(JSON.stringify(msg) + '\n') },
50
+ request(method, params) {
51
+ const id = `dsh-baton-shell-${++nextInternal}`
52
+ return new Promise((resolve, reject) => {
53
+ this.internal.set(id, { resolve, reject })
54
+ this.write({ jsonrpc: '2.0', id, method, params })
55
+ })
56
+ },
57
+ // Stop taking calls; it exits on its own once the last one it holds has answered.
58
+ retire() { this.retired = true; this.closeIfIdle() },
59
+ closeIfIdle() { if (this.retired && this.clientCalls.size === 0 && this.internal.size === 0) proc.stdin.end() },
76
60
  }
61
+ children.add(child)
62
+ proc.stdin.on('error', () => {})
63
+ readline.createInterface({ input: proc.stdout }).on('line', (line) => {
64
+ let msg
65
+ try { msg = JSON.parse(line) } catch { return }
66
+ const own = child.internal.get(msg.id)
67
+ if (own) {
68
+ child.internal.delete(msg.id)
69
+ msg.error ? own.reject(new Error(msg.error.message)) : own.resolve(msg.result)
70
+ } else if (msg.id !== undefined && child.clientCalls.has(msg.id)) {
71
+ const rewrite = child.clientCalls.get(msg.id)
72
+ child.clientCalls.delete(msg.id)
73
+ send(rewrite && msg.result ? { ...msg, result: rewrite(msg.result) } : msg)
74
+ } else {
75
+ send(msg) // notifications (progress) and replies to malformed requests
76
+ }
77
+ child.closeIfIdle()
78
+ })
79
+ proc.on('error', () => {})
80
+ proc.on('exit', (code) => {
81
+ child.exited = true
82
+ children.delete(child)
83
+ for (const [id] of child.clientCalls) send({ jsonrpc: '2.0', id, error: { code: -32603, message: `The dsh-baton server process stopped (exit code ${code}).` } })
84
+ for (const [, own] of child.internal) own.reject(new Error(`exited with code ${code}`))
85
+ child.clientCalls.clear()
86
+ child.internal.clear()
87
+ if (current === child) current = null
88
+ exitIfDone()
89
+ })
90
+ return child
77
91
  }
78
92
 
79
- // MCP tool annotations. Every tool only talks to the local Harness (no open world).
80
- const hints = (readOnlyHint, destructiveHint, idempotentHint) => ({ readOnlyHint, destructiveHint, idempotentHint, openWorldHint: false })
81
- const READ = hints(true, false, true)
82
- const EDIT = hints(false, false, true) // changes something, and the same call again changes nothing more
83
- const annotations = (title, h = READ) => ({ title, ...h })
93
+ let current = null
94
+ let currentStamp = null
95
+ let currentVersion = null
96
+ let failedStamp = null
97
+ let initParams = null
98
+ let lastTools = null // the tool list the client last saw, to tell it when that changes
99
+ let note = null // said once, with the next tools/call result
84
100
 
85
- const TOOLS = [
86
- {
87
- name: 'dsh_list_workspaces',
88
- annotations: annotations('List workspaces'),
89
- description: 'List the workspaces (project folders) in the DeepSeek Harness sidebar; dsh_list_sessions lists the sessions inside them and dsh_create_workspace adds one. Returns id, path, title and session_count for each; the id is the workspace_id for dsh_send and dsh_rename_workspace. Read-only. Needs the dsh-baton-host plugin.',
90
- inputSchema: { type: 'object', properties: {} },
91
- run: () => core.api.workspaces(),
92
- },
93
- {
94
- name: 'dsh_create_workspace',
95
- annotations: annotations('Create workspace', EDIT),
96
- description: 'Add a folder to the DeepSeek Harness sidebar as a workspace without starting a session; dsh_list_workspaces only lists existing ones, and dsh_send with a cwd registers the folder by itself. Creates the folder unless create_dir is false. Idempotent: an already registered folder returns its existing workspace. Returns id, path, title and created (false when it already existed). Needs the dsh-baton-host plugin.',
97
- inputSchema: { type: 'object', required: ['path'], properties: { path: str('Absolute folder path (~ allowed)'), title: str('Sidebar title; defaults to the folder name'), create_dir: { type: 'boolean', description: 'Create the folder if missing', default: true } } },
98
- run: (a) => core.api.create_workspace(a),
99
- },
100
- {
101
- name: 'dsh_list_sessions',
102
- annotations: annotations('List sessions'),
103
- description: `List saved DeepSeek Harness sessions (conversations), newest first, to find a session_id; dsh_list_runs lists only the messages sent through dsh_send. Returns session_id, title, cwd, updated_at, running and bridge_runs (active dsh-baton run ids) for each. There is no paging: the list stops at limit, so to see older sessions raise limit or narrow it with cwd or query. Read-only. ${ARCHIVED}`,
104
- inputSchema: { type: 'object', properties: { cwd: str('Only sessions of this workspace folder'), query: str('Case-insensitive text to match in title, folder or session id'), limit: int('Max sessions returned', 20), include_subagents: { type: 'boolean', description: 'Also list sub-agent sessions', default: false } } },
105
- run: (a) => core.api.list_sessions(a),
106
- },
107
- {
108
- name: 'dsh_read_session',
109
- annotations: annotations('Read session'),
110
- description: 'Read what was said in one session (its past messages), to catch up before continuing it; dsh_session_activity shows what it is doing now and dsh_status follows one run. Returns title, cwd, total_messages and the last messages, each with role (user or assistant), time, text and the names of tools the assistant called (tool output is not included). Read-only.',
111
- inputSchema: { type: 'object', required: ['session_id'], properties: { session_id: str('Session id from dsh_list_sessions, e.g. session-…'), last: int('How many of the most recent messages to return; total_messages tells how many there are', 10) } },
112
- run: (a) => core.api.read_session(a),
113
- },
114
- {
115
- name: 'dsh_send',
116
- annotations: annotations('Send message', hints(false, false, false)),
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" } } },
119
- run: async (a) => {
120
- const st = await core.api.send({ ...a, sender: core.senderOf(core.setting('SENDER') || clientName) })
121
- // Codex has no wake-on-exit for shell commands: offered wait_command, it runs it in a
122
- // terminal and polls that, so it gets dsh_wait only.
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()}` }
124
- return {
125
- ...st,
126
- wait_command: waitCommand(st.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.',
128
- }
129
- },
130
- },
131
- {
132
- name: 'dsh_status',
133
- annotations: annotations('Run status'),
134
- description: 'Look once at one run sent with dsh_send, without waiting; dsh_wait waits for it to end and dsh_session_activity covers the whole session. Returns state (queued/running/waiting/completed/failed/cancelled, or interrupted when a Harness restart cut it short), session_id, recent events (text, tool calls), final reply, waiting_on when the turn is over but sub-agents or background jobs are still working, and human_interjections when someone typed in the Harness UI during the run (its result then reflects that input too). A one-off look, never a way to wait: to wait for the reply use dsh_wait, and if dsh_wait timed out, call dsh_wait again.',
135
- inputSchema: { type: 'object', required: ['run_id'], properties: { run_id: str('Run id from dsh_send'), tail: int('How many recent events', 20) } },
136
- run: (a) => core.api.status(a),
137
- },
138
- {
139
- name: 'dsh_wait',
140
- annotations: annotations('Wait for run'),
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.`,
142
- inputSchema: { type: 'object', required: ['run_id'], properties: { run_id: str('Run id from dsh_send') } },
143
- run: (a, { progress }) => waitInChunks(a, progress),
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
- },
166
- {
167
- name: 'dsh_rename_session',
168
- annotations: annotations('Rename session', EDIT),
169
- description: 'Set the title of one DeepSeek Harness session (a conversation); dsh_rename_workspace renames a workspace (project folder) instead. Only the title changes; the conversation and any running turn are untouched. Can be called again at any time to change it back or to something else. Returns session_id and the saved title.',
170
- inputSchema: { type: 'object', required: ['session_id', 'title'], properties: { session_id: str('Session id from dsh_list_sessions'), title: str('New title; surrounding spaces are trimmed and it must not be empty') } },
171
- run: (a) => core.api.rename_session(a),
172
- },
173
- {
174
- name: 'dsh_rename_workspace',
175
- annotations: annotations('Rename workspace', EDIT),
176
- description: 'Set the sidebar title of a DeepSeek Harness workspace (project folder); dsh_rename_session renames a single session instead. The folder on disk is not moved or renamed. Identify it by workspace_id or path. Can be called again to change it back. Returns id, path and the saved title.',
177
- inputSchema: { type: 'object', required: ['title'], properties: { workspace_id: str('Workspace id from dsh_list_workspaces'), path: str('Workspace folder, used when workspace_id is not given'), title: str('New title; surrounding spaces are trimmed and it must not be empty') } },
178
- run: (a) => core.api.rename_workspace(a),
179
- },
180
- {
181
- name: 'dsh_session_activity',
182
- annotations: annotations('Session activity'),
183
- description: 'Show what one session is doing right now, across all its work; dsh_status follows a single run and dsh_read_session shows past messages. Returns busy, activity (its running turn, sub-agents at any depth, background jobs and reminders) and bridge_runs (active dsh-baton runs). Read-only. Check it before dsh_send to a session that might be busy, or before dsh_cancel.',
184
- inputSchema: { type: 'object', required: ['session_id'], properties: { session_id: str('Session id from dsh_list_sessions') } },
185
- run: (a) => core.api.activity(a),
186
- },
187
- {
188
- name: 'dsh_cancel',
189
- annotations: annotations('Cancel session work', hints(false, true, true)),
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.',
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') } },
192
- run: (a) => core.api.cancel(a),
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
- },
201
- {
202
- name: 'dsh_list_runs',
203
- annotations: annotations('List runs'),
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) } },
206
- run: (a) => core.api.list_runs(a),
207
- },
208
- ]
101
+ const withTimeout = (promise, ms, what) => Promise.race([
102
+ promise,
103
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`${what} did not answer within ${ms / 1000} s`)), ms).unref()),
104
+ ])
209
105
 
210
- let clientName = null
211
- let versionWarned = false
106
+ // A child that stopped is replaced on the next message; once the client has initialized,
107
+ // the replacement is initialized the same way before it takes calls.
108
+ async function ensureChild() {
109
+ if (current) return current
110
+ currentStamp = stamp()
111
+ const child = current = startChild()
112
+ if (initParams) {
113
+ try {
114
+ const init = await withTimeout(child.request('initialize', initParams), START_TIMEOUT_MS, 'The restarted server')
115
+ currentVersion = init?.serverInfo?.version ?? currentVersion
116
+ child.write({ jsonrpc: '2.0', method: 'notifications/initialized' })
117
+ } catch { /* its calls then fail with the reason */ }
118
+ }
119
+ return child
120
+ }
212
121
 
213
- function send(msg) {
214
- process.stdout.write(JSON.stringify(msg) + '\n')
122
+ // Start a child from the newly installed files and make it the one that takes calls.
123
+ async function reloadIfInstalled() {
124
+ if (!initParams || !current) return
125
+ const now = stamp()
126
+ if (!now || now === currentStamp || now === failedStamp) return
127
+ const next = startChild()
128
+ try {
129
+ const init = await withTimeout(next.request('initialize', initParams), START_TIMEOUT_MS, 'The new version')
130
+ next.write({ jsonrpc: '2.0', method: 'notifications/initialized' })
131
+ const { tools } = await withTimeout(next.request('tools/list', {}), START_TIMEOUT_MS, 'The new version')
132
+ // What the old version had, to name what is new (the client may not show new tools).
133
+ const had = await withTimeout(current.request('tools/list', {}), 5000, 'The old version').then((r) => r.tools.map((t) => t.name), () => null)
134
+ const added = had ? tools.map((t) => t.name).filter((n) => !had.includes(n)) : []
135
+ const before = currentVersion
136
+ current.retire()
137
+ current = next
138
+ currentStamp = now
139
+ currentVersion = init?.serverInfo?.version ?? null
140
+ note = `dsh-baton was updated from ${before} to ${currentVersion} without a restart; this call already used ${currentVersion}.` +
141
+ (added.length ? ` New in this version: ${added.join(', ')}. If this conversation does not show them, call them through dsh_call {name, arguments}.` : '')
142
+ if (lastTools !== null && JSON.stringify(tools) !== lastTools) {
143
+ lastTools = JSON.stringify(tools)
144
+ send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' })
145
+ }
146
+ } catch (e) {
147
+ next.proc.kill()
148
+ failedStamp = now
149
+ const version = now.split('@')[0]
150
+ note = `A new dsh-baton (${version}) is installed but could not be started (${e.message}); this call still used ${currentVersion}. Restart this client to load it, or reinstall.`
151
+ }
215
152
  }
216
153
 
217
- async function handle(req) {
218
- switch (req.method) {
219
- case 'initialize': {
220
- const asked = req.params?.protocolVersion
221
- clientName = req.params?.clientInfo?.name ?? null
222
- return {
223
- protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
224
- capabilities: { tools: {} },
225
- serverInfo: SERVER,
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}`,
227
- }
154
+ const appendNote = (result) => {
155
+ if (!note || !Array.isArray(result.content)) return result
156
+ const text = note
157
+ note = null
158
+ return { ...result, content: [...result.content, { type: 'text', text: `Note for the user: ${text}` }] }
159
+ }
160
+
161
+ async function route(line) {
162
+ let msg
163
+ try { msg = JSON.parse(line) } catch { msg = null }
164
+ const ok = msg && typeof msg === 'object' && !Array.isArray(msg) && typeof msg.method === 'string'
165
+ if (!ok) return (await ensureChild()).proc.stdin.write(line + '\n') // the child sends the error reply
166
+
167
+ if (msg.id === undefined) {
168
+ // A cancellation goes to the child running that call; other notifications to the current one.
169
+ if (msg.method === 'notifications/cancelled') {
170
+ for (const c of children) if (c.clientCalls.has(msg.params?.requestId)) return c.write(msg)
171
+ return
228
172
  }
229
- case 'ping':
230
- return {}
231
- case 'tools/list':
232
- return { tools: TOOLS.map(({ run, ...t }) => ({ ...t, description: typeof t.description === 'function' ? t.description() : t.description })) }
233
- case 'tools/call': {
234
- const tool = TOOLS.find((t) => t.name === req.params?.name)
235
- if (!tool) throw Object.assign(new Error(`Unknown tool: ${req.params?.name}`), { code: -32602 })
236
- try {
237
- // MCP progress: only when the client sent a progressToken with this call.
238
- const token = req.params?._meta?.progressToken
239
- const progress = token === undefined ? undefined : (done, message) =>
240
- send({ jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: token, progress: done, message } })
241
- const result = await tool.run(req.params.arguments || {}, { progress })
242
- const content = [{ type: 'text', text: JSON.stringify(result, null, 2) }]
243
- // Said once per server process, the first time a call finds the versions apart.
244
- const warning = !versionWarned && core.versionWarning()
245
- if (warning) { versionWarned = true; content.push({ type: 'text', text: `Note for the user: ${warning}` }) }
246
- return { content }
247
- } catch (e) {
248
- return { content: [{ type: 'text', text: e.message }], isError: true }
249
- }
173
+ return current?.write(msg)
174
+ }
175
+
176
+ let rewrite = null
177
+ if (msg.method === 'initialize') {
178
+ await ensureChild()
179
+ initParams = msg.params ?? {}
180
+ rewrite = (result) => {
181
+ currentVersion = result.serverInfo?.version ?? null
182
+ return { ...result, capabilities: { ...result.capabilities, tools: { ...result.capabilities?.tools, listChanged: true } } }
250
183
  }
251
- default:
252
- throw Object.assign(new Error(`Method not found: ${req.method}`), { code: -32601 })
184
+ } else if (msg.method === 'tools/list' || msg.method === 'tools/call') {
185
+ await reloadIfInstalled()
186
+ rewrite = msg.method === 'tools/list'
187
+ ? (result) => { lastTools = JSON.stringify(result.tools); return result }
188
+ : appendNote
253
189
  }
190
+ const child = await ensureChild()
191
+ child.clientCalls.set(msg.id, rewrite)
192
+ child.write(msg)
254
193
  }
255
194
 
195
+ // Messages are passed on in the order they came, even while a new child starts.
196
+ let queue = Promise.resolve()
256
197
  const rl = readline.createInterface({ input: process.stdin })
198
+ rl.on('line', (line) => {
199
+ if (!line.trim()) return
200
+ queue = queue.then(() => route(line)).catch((e) => process.stderr.write(`dsh-baton: ${e.stack ?? e}\n`))
201
+ })
257
202
 
258
- // Exit when the client goes away: stdin closes, or the parent process dies
259
- // and we get re-parented. Otherwise orphaned servers pile up.
260
- let inFlight = 0
203
+ // Exit once the client is gone and every child has answered and stopped.
261
204
  let stdinClosed = false
262
- // Writes to a piped stdout are asynchronous: flush the last reply before exiting.
263
- const exitIfDone = () => { if (stdinClosed && inFlight === 0) process.stdout.write('', () => process.exit(0)) }
264
- rl.on('close', () => { stdinClosed = true; exitIfDone() })
205
+ function exitIfDone() {
206
+ if (stdinClosed && children.size === 0) process.stdout.write('', () => process.exit(0))
207
+ }
208
+ rl.on('close', () => {
209
+ stdinClosed = true
210
+ queue.then(() => {
211
+ for (const c of children) c.retire()
212
+ exitIfDone()
213
+ })
214
+ })
215
+ // The parent process died and we were re-parented (or, on Windows, the parent is gone).
265
216
  const parentPid = process.ppid
266
- // Windows does not re-parent orphans (ppid stays the same), so there the parent is checked directly.
267
217
  const parentGone = () => {
268
218
  if (process.platform !== 'win32') return false
269
219
  try { process.kill(parentPid, 0); return false } catch (e) { return e.code !== 'EPERM' }
270
220
  }
271
- setInterval(() => { if (process.ppid !== parentPid || parentGone()) process.exit(0) }, 5000).unref()
272
-
273
- rl.on('line', async (line) => {
274
- if (!line.trim()) return
275
- let req
276
- try { req = JSON.parse(line) } catch { return send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }) }
277
- if (req === null || typeof req !== 'object' || Array.isArray(req) || typeof req.method !== 'string') {
278
- return send({ jsonrpc: '2.0', id: req?.id ?? null, error: { code: -32600, message: 'Invalid Request' } })
221
+ setInterval(() => {
222
+ if (process.ppid !== parentPid || parentGone()) {
223
+ for (const c of children) c.proc.kill()
224
+ process.exit(0)
279
225
  }
280
- if (req.id === undefined) return // notification
281
- inFlight++
282
- try {
283
- send({ jsonrpc: '2.0', id: req.id, result: await handle(req) })
284
- } catch (e) {
285
- send({ jsonrpc: '2.0', id: req.id, error: { code: e.code || -32603, message: e.message } })
286
- } finally {
287
- inFlight--
288
- exitIfDone()
289
- }
290
- })
226
+ }, 5000).unref()