dsh-baton-mcp 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- English | <a href="README.zh-CN.md">中文</a> · Unofficial; not affiliated with DeepSeek.
6
+ English | <a href="README.zh-CN.md">中文</a>
7
7
  </p>
8
8
 
9
9
  <p align="center">
@@ -37,64 +37,18 @@ Codex and Claude take turns in the same Harness session, and every message is la
37
37
  ## Quick start
38
38
 
39
39
  <p align="center">
40
- <img src="./assets/readme/install.svg" width="100%" alt="Step 1: run npx -y dsh-baton-mcp setup. Step 2: in Harness open Plugins, Add plugin, enter dsh-baton-host from the npm registry, Install, Enable now. Step 3: in a new chat, ask for dsh_list_workspaces">
40
+ <img src="./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">
41
41
  </p>
42
42
 
43
- Requires DeepSeek Harness ≥ 0.2.1-rc.2 (signed in), Node.js ≥ 22.15. On Windows, see [WINDOWS.md](WINDOWS.md).
43
+ Requires DeepSeek Harness ≥ 0.2.1-rc.2 (signed in), Node.js ≥ 22.15, and Claude Code or Codex. The steps are the same on macOS and Windows.
44
44
 
45
- **1. Run setup** in a terminal:
45
+ **1. Add the plugin in Harness:** **Plugins** (插件) → **Add plugin** (添加插件) → type `dsh-baton-host` → **Install** (安装) → **Enable now** (立即启用).
46
46
 
47
- ```bash
48
- npx -y dsh-baton-mcp setup
49
- ```
50
-
51
- dsh-baton has no dependencies. `setup` copies itself to `~/.dsh-baton/app`, a folder that stays put (npx's cache does not), and registers the MCP server once, user-wide, for Claude Code and Codex: every new conversation, in any folder, has the `dsh_*` tools. It writes `~/.claude.json` and `~/.codex/config.toml` directly (a backup of each is kept as `*.bak-dsh-baton`), so it also works when only the desktop apps are installed and `claude` / `codex` are not on your `PATH`. Running it again is safe.
52
-
53
- **2. Add the plugin in Harness**, by its npm package name (`setup` copies it to your clipboard):
54
-
55
- **Plugins** (插件) → **Add plugin** (添加插件) → registry **Official npm registry** → `dsh-baton-host` → **Install** (安装) → **Enable now** (立即启用)
56
-
57
- **3. Check.** Start a **new** conversation in Claude Code or Codex and ask it to call `dsh_list_workspaces`. It lists your Harness workspaces.
58
-
59
- **Or let Claude Code or Codex do it:** paste *"Run `npx -y dsh-baton-mcp setup`, then tell me the Harness step it prints."*
60
-
61
- **How each client waits** (nothing to configure):
62
- - **Claude Code** waits for a run in the background (the `wait_command` that `dsh_send` returns) and is woken once, when the run ends.
63
- - **Codex** waits inside `dsh_wait`, up to 270 s per call, just under its 300 s tool timeout. An unfinished wait returns only the state, so Codex calls again without summarising. Codex gets no `wait_command`: its terminal cannot wake it, so it would poll.
64
- - Codex asks you to approve MCP tool calls: approve the `dsh-baton` tools. For unattended `codex exec` runs, add `--approve-for-me`.
65
-
66
- **Updating:** the MCP server and the plugin update separately, and both carry the same version.
67
- - MCP server: `npx -y dsh-baton-mcp@latest setup`. New conversations use it.
68
- - Plugin: Harness offers an update button only for its own official plugins, so for this one: Plugins → dsh-baton-host → **Uninstall**, then **Add plugin** → `dsh-baton-host` again.
69
- - `setup` says when the two are out of step, and so does the first tool call that notices it.
70
-
71
- **Removing:** Harness → Plugins → dsh-baton-host → **Uninstall**, and `npx -y dsh-baton-mcp setup --remove`. That removes the MCP entries and `~/.dsh-baton/app`; run records stay in `~/.dsh-baton` until you delete it.
72
-
73
- **Upgrading from dsh-bridge**: run `setup` as above. It replaces the old `dsh-bridge` MCP entries; in Harness, uninstall `dsh-bridge-host`, then add `dsh-baton-host` as in step 2. `DSH_BRIDGE_*` variables and an existing `~/.dsh-bridge` folder keep working for now. Tools get a new prefix (`mcp__dsh-baton__…`), so update any permission allowlists.
74
-
75
- **Installed from a local folder before 0.4?** Earlier versions had you paste `~/.dsh-baton/app/host`. That still works, but it does not update with npm: uninstall it and add `dsh-baton-host` by name. `setup` points this out.
76
-
77
- **From source** (to follow `main` or to change the code): `git clone https://github.com/JialaoLiu/dsh-baton.git` and `node dsh-baton/src/cli.mjs setup`. setup then registers the clone itself and has you add the plugin from the clone's `host` folder (Add plugin → paste the path it copies), so plugin changes run before they are published; update with `git pull`.
47
+ Switched on, the plugin registers dsh-baton for Claude Code and Codex, for every folder, and Harness says so.
78
48
 
79
- <details>
80
- <summary>Manual registration and the Claude desktop app</summary>
81
-
82
- `setup` writes the same entries these commands would (with the absolute path of `node`, since desktop apps start MCP servers without your shell's `PATH`):
83
-
84
- ```bash
85
- claude mcp add dsh-baton -s user -- "$(command -v node)" "$PWD/dsh-baton/src/server.mjs"
86
- codex mcp add dsh-baton -- "$(command -v node)" "$PWD/dsh-baton/src/server.mjs"
87
- ```
88
-
89
- `-s user` matters: without it, Claude Code registers the server for the current project folder only.
90
-
91
- **Claude desktop app (optional)**: for the chat app's connectors (separate from Claude Code), add this to `~/Library/Application Support/Claude/claude_desktop_config.json` and restart the app. It shows as a "Local dev" connector.
92
-
93
- ```json
94
- { "mcpServers": { "dsh-baton": { "command": "/absolute/path/to/node", "args": ["/absolute/path/to/dsh-baton/src/server.mjs"] } } }
95
- ```
49
+ **2. Check:** in a **new** Claude Code or Codex conversation, ask it to call `dsh_list_workspaces`. It lists your Harness workspaces.
96
50
 
97
- </details>
51
+ That is all. If Harness says it could not register (no Node.js found, say), run `npx -y dsh-baton-mcp setup` in a terminal; it does the same registration. Updating, removing and Windows details are [further down](#install-details).
98
52
 
99
53
  ## How it works
100
54
 
@@ -106,6 +60,11 @@ codex mcp add dsh-baton -- "$(command -v node)" "$PWD/dsh-baton/src/server.mjs"
106
60
  <img src="./assets/readme/comparison.svg" width="100%" alt="Plugin vs headless: only the plugin shows runs live in the Harness UI, continues desktop sessions and uses desktop agent presets; both reuse the desktop sign-in">
107
61
  </p>
108
62
 
63
+ **How each client waits** (nothing to configure):
64
+ - **Claude Code** waits for a run in the background (the `wait_command` that `dsh_send` returns) and is woken once, when the run ends.
65
+ - **Codex** waits inside `dsh_wait`, up to 270 s per call, just under its 300 s tool timeout. An unfinished wait returns only the state, so Codex calls again without summarising. Codex gets no `wait_command`: its terminal cannot wake it, so it would poll.
66
+ - Codex asks you to approve MCP tool calls: approve the `dsh-baton` tools. For unattended `codex exec` runs, add `--approve-for-me`.
67
+
109
68
  ## Tools
110
69
 
111
70
  <p align="center">
@@ -173,6 +132,42 @@ When the plugin is unreachable, the MCP server falls back to Harness's `dsh head
173
132
 
174
133
  </details>
175
134
 
135
+ ## Install details
136
+
137
+ **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 registers the `node` it finds on `PATH` (never the one inside Harness), so 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.
138
+
139
+ **`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.
140
+
141
+ **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.
142
+
143
+ **Removing.** Harness → Plugins → dsh-baton-host → **Uninstall**, then `npx -y dsh-baton-mcp setup --remove`. The plugin cannot tell an uninstall from Harness quitting, so it leaves the MCP entries to that command, which also deletes `~/.dsh-baton/app`; run records stay in `~/.dsh-baton` until you delete it.
144
+
145
+ **Upgrading from dsh-bridge.** In Harness, uninstall `dsh-bridge-host`, then add `dsh-baton-host`; switched on, it replaces the old `dsh-bridge` MCP entries. `DSH_BRIDGE_*` variables and an existing `~/.dsh-bridge` folder keep working for now. Tools get a new prefix (`mcp__dsh-baton__…`), so update any permission allowlists. A plugin added before 0.4 by pasting `~/.dsh-baton/app/host` still works but does not update with npm: uninstall it and add `dsh-baton-host` by name.
146
+
147
+ **From source** (to follow `main` or change the code): `git clone https://github.com/JialaoLiu/dsh-baton.git`, then in Harness add the plugin from the clone's `host` folder (Add plugin → paste the path). Switched on, it registers the clone itself, so changes run before they are published. Update with `git pull`. `node dsh-baton/src/cli.mjs setup` registers the clone from a terminal.
148
+
149
+ **Windows.** Same steps; [WINDOWS.md](WINDOWS.md) covers what differs (a named pipe instead of a socket file) and fixes for a Plugins page that cannot install or uninstall.
150
+
151
+ <details>
152
+ <summary>Manual registration and the Claude desktop app</summary>
153
+
154
+ The plugin and `setup` write the same entries these commands would (with the absolute path of `node`, since desktop apps start MCP servers without your shell's `PATH`):
155
+
156
+ ```bash
157
+ claude mcp add dsh-baton -s user -- "$(command -v node)" "$PWD/dsh-baton/src/server.mjs"
158
+ codex mcp add dsh-baton -- "$(command -v node)" "$PWD/dsh-baton/src/server.mjs"
159
+ ```
160
+
161
+ `-s user` matters: without it, Claude Code registers the server for the current project folder only.
162
+
163
+ **Claude desktop app (optional)**: for the chat app's connectors (separate from Claude Code), add this to `~/Library/Application Support/Claude/claude_desktop_config.json` and restart the app. It shows as a "Local dev" connector.
164
+
165
+ ```json
166
+ { "mcpServers": { "dsh-baton": { "command": "/absolute/path/to/node", "args": ["/absolute/path/to/dsh-baton/src/server.mjs"] } } }
167
+ ```
168
+
169
+ </details>
170
+
176
171
  ## Security and limits
177
172
 
178
173
  <p align="center">
package/README.zh-CN.md CHANGED
@@ -3,7 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <a href="README.md">English</a> | 中文 · 非官方项目,与 DeepSeek 无关。
6
+ <a href="README.md">English</a> | 中文
7
7
  </p>
8
8
 
9
9
  <p align="center">
@@ -37,64 +37,18 @@ Codex 和 Claude 轮流往同一个 Harness 对话发消息,每条都标明是
37
37
  ## 快速上手
38
38
 
39
39
  <p align="center">
40
- <img src="./assets/readme/zh/install.svg" width="100%" alt="第一步:运行 npx -y dsh-baton-mcp setup。第二步:Harness → 插件 → 添加插件 → 从 npm 输入 dsh-baton-host → 安装 → 立即启用。第三步:新开对话,让它调用 dsh_list_workspaces">
40
+ <img src="./assets/readme/zh/install.svg" width="100%" alt="第一步:Harness → 插件 → 添加插件 → 输入 dsh-baton-host → 安装 → 立即启用,它会为 Claude Code 和 Codex 注册 dsh-baton。第二步:新开对话,让它调用 dsh_list_workspaces">
41
41
  </p>
42
42
 
43
- 需要:DeepSeek Harness ≥ 0.2.1-rc.2 并已登录;Node.js ≥ 22.15。Windows 见 [WINDOWS.md](WINDOWS.md)。
43
+ 需要:DeepSeek Harness ≥ 0.2.1-rc.2 并已登录;Node.js ≥ 22.15;装了 Claude Code 或 Codex。macOS 和 Windows 步骤完全相同。
44
44
 
45
- **1. 运行 setup**,在终端里:
45
+ **1. 在 Harness 里添加插件:** 插件 → 添加插件 → 输入 `dsh-baton-host` → 安装 → 立即启用。
46
46
 
47
- ```bash
48
- npx -y dsh-baton-mcp setup
49
- ```
50
-
51
- dsh-baton 没有任何依赖。`setup` 会把自己复制到 `~/.dsh-baton/app` 这个固定的文件夹(npx 的缓存目录会变),然后在用户级别给 Claude Code 和 Codex 注册一次 MCP 服务器:之后在任何文件夹新开的对话都有 `dsh_*` 工具。它直接写 `~/.claude.json` 和 `~/.codex/config.toml`(各留一份 `*.bak-dsh-baton` 备份),所以只装了桌面应用、`claude` / `codex` 不在 `PATH` 里也能用。重复运行也没问题。
52
-
53
- **2. 在 Harness 里按包名添加插件**(`setup` 已经把包名复制到剪贴板):
54
-
55
- **插件** → **添加插件** → 来源选 **Official npm registry** → 输入 `dsh-baton-host` → **安装** → **立即启用**
56
-
57
- **3. 检查。**在 Claude Code 或 Codex 里**新开**一个对话,让它调用 `dsh_list_workspaces`,应该会列出你的 Harness 工作区。
58
-
59
- **也可以让 Claude Code 或 Codex 帮你装:** 发给它 *"运行 `npx -y dsh-baton-mcp setup`,然后告诉我它提示的 Harness 那一步。"*
60
-
61
- **各客户端怎么等待**(不需要配置):
62
- - **Claude Code** 在后台等待任务(`dsh_send` 返回的 `wait_command`),任务结束时只被唤醒一次。
63
- - **Codex** 在 `dsh_wait` 里等待,每次最多 270 秒,刚好低于它 300 秒的工具超时。没做完时只返回状态,Codex 会直接再调用,不会去总结进度。Codex 不会收到 `wait_command`:它的终端无法在命令结束时唤醒它,只能轮询。
64
- - Codex 调用 MCP 工具时会请你批准,批准 `dsh-baton` 的工具即可;无人值守的 `codex exec` 加上 `--approve-for-me`。
65
-
66
- **更新:** MCP 服务器和插件分开更新,两者版本号相同。
67
- - MCP 服务器:`npx -y dsh-baton-mcp@latest setup`,新开的对话就会用上。
68
- - 插件:Harness 只给官方插件提供"更新"按钮,所以这个插件要:插件 → dsh-baton-host → **卸载**,再**添加插件** → `dsh-baton-host`。
69
- - 两边版本对不上时,`setup` 会提示,第一次发现不一致的工具调用也会提示。
70
-
71
- **卸载:** Harness → 插件 → dsh-baton-host → **卸载**,再运行 `npx -y dsh-baton-mcp setup --remove`。它会删掉 MCP 注册和 `~/.dsh-baton/app`;运行记录留在 `~/.dsh-baton`,不需要的话自己删掉。
72
-
73
- **从 dsh-bridge 升级:**照上面运行 `setup`。它会替换掉旧的 `dsh-bridge` MCP 注册;在 Harness 里先卸载 `dsh-bridge-host`,再按第 2 步添加 `dsh-baton-host`。`DSH_BRIDGE_*` 环境变量和已有的 `~/.dsh-bridge` 文件夹暂时继续可用。工具前缀变成 `mcp__dsh-baton__…`,权限白名单里写了旧前缀的要改一下。
74
-
75
- **0.4 之前从本地文件夹装的插件:**旧版本让你粘贴 `~/.dsh-baton/app/host`。这样装的仍然能用,但不会跟着 npm 更新:卸载它,再按包名添加 `dsh-baton-host`。`setup` 会提示这一点。
76
-
77
- **从源码安装**(想跟进 `main` 或改代码):`git clone https://github.com/JialaoLiu/dsh-baton.git`,再运行 `node dsh-baton/src/cli.mjs setup`。这时 setup 注册的就是克隆目录本身,插件也从克隆目录的 `host` 文件夹添加(添加插件 → 粘贴它复制的路径),这样插件改动在发布前就能跑起来;更新用 `git pull`。
47
+ 插件启用后会为 Claude Code 和 Codex 注册 dsh-baton(对所有文件夹生效),Harness 里会弹出提示。
78
48
 
79
- <details>
80
- <summary>手动注册与 Claude 桌面应用</summary>
81
-
82
- `setup` 写入的内容和下面两条命令等价(用 `node` 的绝对路径,因为桌面应用启动 MCP 服务器时没有你 shell 的 `PATH`):
83
-
84
- ```bash
85
- claude mcp add dsh-baton -s user -- "$(command -v node)" "$PWD/dsh-baton/src/server.mjs"
86
- codex mcp add dsh-baton -- "$(command -v node)" "$PWD/dsh-baton/src/server.mjs"
87
- ```
88
-
89
- `-s user` 很关键:不加的话,Claude Code 只给当前项目文件夹注册。
90
-
91
- **Claude 桌面应用(可选)**:聊天应用的连接器(和 Claude Code 是两回事),把下面这段加到 `~/Library/Application Support/Claude/claude_desktop_config.json`,然后重启应用,会显示为 "Local dev" 连接器。
92
-
93
- ```json
94
- { "mcpServers": { "dsh-baton": { "command": "/node 的绝对路径", "args": ["/dsh-baton 的绝对路径/src/server.mjs"] } } }
95
- ```
49
+ **2. 检查:**在 Claude Code 或 Codex 里**新开**一个对话,让它调用 `dsh_list_workspaces`,会列出你的 Harness 工作区。
96
50
 
97
- </details>
51
+ 就这些。如果 Harness 提示没能注册(比如找不到 Node.js),在终端运行 `npx -y dsh-baton-mcp setup`,它做的是同样的注册。更新、卸载和 Windows 说明在[后面](#安装细节)。
98
52
 
99
53
  ## 工作原理
100
54
 
@@ -106,6 +60,11 @@ codex mcp add dsh-baton -- "$(command -v node)" "$PWD/dsh-baton/src/server.mjs"
106
60
  <img src="./assets/readme/zh/comparison.svg" width="100%" alt="插件和 headless 对比:只有插件能在 Harness 界面实时可见、续接桌面版的对话、使用桌面版智能体预设;两者都沿用桌面版登录">
107
61
  </p>
108
62
 
63
+ **各客户端怎么等待**(不需要配置):
64
+ - **Claude Code** 在后台等待任务(`dsh_send` 返回的 `wait_command`),任务结束时只被唤醒一次。
65
+ - **Codex** 在 `dsh_wait` 里等待,每次最多 270 秒,刚好低于它 300 秒的工具超时。没做完时只返回状态,Codex 会直接再调用,不会去总结进度。Codex 不会收到 `wait_command`:它的终端无法在命令结束时唤醒它,只能轮询。
66
+ - Codex 调用 MCP 工具时会请你批准,批准 `dsh-baton` 的工具即可;无人值守的 `codex exec` 加上 `--approve-for-me`。
67
+
109
68
  ## 工具
110
69
 
111
70
  <p align="center">
@@ -175,6 +134,42 @@ dsh-baton rename-workspace ~/code/new-app 新名字
175
134
 
176
135
  </details>
177
136
 
137
+ ## 安装细节
138
+
139
+ **插件启用时做了什么。**它把 MCP 服务器复制到 `~/.dsh-baton/app` 这个固定的文件夹,再直接把 MCP 注册写进 `~/.claude.json` 和 `~/.codex/config.toml`,各留一份 `*.bak-dsh-baton` 备份。注册的是 `PATH` 里找到的 `node`(不会用 Harness 自带的那个),所以只装了桌面应用、`claude` / `codex` 不在 `PATH` 里也能用。每次 Harness 启动都会再检查一遍:已经正确的注册、或者指向另一份可用 dsh-baton 的注册,都不会改动。在 Harness 里输入包名后,安装源会自动切到「npm 官方源」(英文界面是 "Official npm registry"),不用另外选择,也不用重启。不想让插件自动注册,给 Harness 设置 `DSH_BATON_AUTO_REGISTER=0`。
140
+
141
+ **`npx -y dsh-baton-mcp setup`** 在终端里做同样的注册:用于插件没能注册的情况,或者从 MCP 目录过来的用户。插件已经注册过时它什么都不改;插件没装的话,它会提示去添加。
142
+
143
+ **更新。**Harness 只给官方插件提供"更新"按钮,所以要:插件 → dsh-baton-host → **卸载**,再**添加插件** → `dsh-baton-host`。新版本刚发布的一段时间里,Harness 会装上一个版本(pnpm 的 `minimumReleaseAge` 会暂缓刚发布的版本,安装弹窗里会说明);想马上用新版,输入带版本号的名字:`dsh-baton-host@0.5.0`。重新启用后,插件会把 MCP 服务器也更新到同一版本,新开的对话就会用上。万一两边版本对不上,第一次发现不一致的工具调用会提示。
144
+
145
+ **卸载。**Harness → 插件 → dsh-baton-host → **卸载**,再运行 `npx -y dsh-baton-mcp setup --remove`。插件分不清"被卸载"和"Harness 退出",所以 MCP 注册交给这条命令删,它也会删掉 `~/.dsh-baton/app`;运行记录留在 `~/.dsh-baton`,不需要的话自己删掉。
146
+
147
+ **从 dsh-bridge 升级。**在 Harness 里先卸载 `dsh-bridge-host`,再添加 `dsh-baton-host`;启用后它会替换掉旧的 `dsh-bridge` MCP 注册。`DSH_BRIDGE_*` 环境变量和已有的 `~/.dsh-bridge` 文件夹暂时继续可用。工具前缀变成 `mcp__dsh-baton__…`,权限白名单里写了旧前缀的要改一下。0.4 之前通过粘贴 `~/.dsh-baton/app/host` 装的插件仍然能用,但不会跟着 npm 更新:卸载它,再按包名添加 `dsh-baton-host`。
148
+
149
+ **从源码安装**(想跟进 `main` 或改代码):`git clone https://github.com/JialaoLiu/dsh-baton.git`,然后在 Harness 里从克隆目录的 `host` 文件夹添加插件(添加插件 → 粘贴路径)。启用后注册的就是克隆目录本身,改动在发布前就能跑起来;更新用 `git pull`。在终端里运行 `node dsh-baton/src/cli.mjs setup` 也能注册克隆目录。
150
+
151
+ **Windows。**步骤相同;[WINDOWS.md](WINDOWS.md) 说明了不同之处(用命名管道代替套接字文件),以及插件页装不上或卸不掉时的修法。
152
+
153
+ <details>
154
+ <summary>手动注册与 Claude 桌面应用</summary>
155
+
156
+ 插件和 `setup` 写入的内容和下面两条命令等价(用 `node` 的绝对路径,因为桌面应用启动 MCP 服务器时没有你 shell 的 `PATH`):
157
+
158
+ ```bash
159
+ claude mcp add dsh-baton -s user -- "$(command -v node)" "$PWD/dsh-baton/src/server.mjs"
160
+ codex mcp add dsh-baton -- "$(command -v node)" "$PWD/dsh-baton/src/server.mjs"
161
+ ```
162
+
163
+ `-s user` 很关键:不加的话,Claude Code 只给当前项目文件夹注册。
164
+
165
+ **Claude 桌面应用(可选)**:聊天应用的连接器(和 Claude Code 是两回事),把下面这段加到 `~/Library/Application Support/Claude/claude_desktop_config.json`,然后重启应用,会显示为 "Local dev" 连接器。
166
+
167
+ ```json
168
+ { "mcpServers": { "dsh-baton": { "command": "/node 的绝对路径", "args": ["/dsh-baton 的绝对路径/src/server.mjs"] } } }
169
+ ```
170
+
171
+ </details>
172
+
178
173
  ## 安全与限制
179
174
 
180
175
  <p align="center">
package/WINDOWS.md CHANGED
@@ -20,25 +20,11 @@ Verified on Windows 11 with DeepSeek Harness 0.2.1-rc.2 (desktop app), Node.js 2
20
20
 
21
21
  ## Install
22
22
 
23
- Requires DeepSeek Harness ≥ 0.2.1-rc.2 and Node.js ≥ 22.15. In PowerShell:
23
+ Exactly as in the [README](README.md#quick-start): in Harness add the plugin `dsh-baton-host` and switch it on. Updating and removing are the same too ([Install details](README.md#install-details)). On Windows:
24
24
 
25
- ```powershell
26
- npx -y dsh-baton-mcp setup
27
- ```
28
-
29
- `setup` copies itself to `%USERPROFILE%\.dsh-baton\app` (npx's cache is not a stable place) and registers the MCP server once, user-wide, for Claude Code (`%USERPROFILE%\.claude.json`) and Codex (`%USERPROFILE%\.codex\config.toml`), with the absolute path of `node`. It edits the files directly, so it works with the desktop apps alone: their `claude.exe` / `codex.exe` are usually not on `PATH`. It also copies the plugin's package name to the clipboard.
30
-
31
- Then, in Harness: **Plugins / 插件** → **Add plugin / 添加插件** → registry **Official npm registry** → `dsh-baton-host` → **Install / 安装** → **Enable now / 立即启用**. The pipe appears right away (see [Verify](#verify)); if it does not, restart Harness.
32
-
33
- Nothing else to configure: Claude Code waits for a run in the background (Git Bash runs the `wait_command` that `dsh_send` returns), and Codex waits up to 270 s per `dsh_wait` call, under its 300 s tool timeout. Approve the `dsh-baton` tools when Codex asks; for unattended `codex exec`, add `--approve-for-me`.
34
-
35
- Update: `npx -y dsh-baton-mcp@latest setup` for the MCP server; for the plugin, Harness → Plugins → dsh-baton-host → Uninstall, then Add plugin → `dsh-baton-host` again (Harness has no update button for non-official plugins).
36
-
37
- Remove: Harness → Plugins → dsh-baton-host → Uninstall, and `npx -y dsh-baton-mcp setup --remove`.
38
-
39
- Upgrading from dsh-bridge: `setup` replaces the old MCP entries and reuses an existing `%USERPROFILE%\.dsh-bridge` (the app then goes to `.dsh-bridge\app`). Uninstall `dsh-bridge-host` in Harness, then add `dsh-baton-host` by name. A plugin added earlier from `.dsh-baton\app\host` (0.3) also works, but `setup` suggests swapping it for the npm package, which is the one that gets updates. With the hoisted profile layout, uninstalling may leave a `node_modules\dsh-bridge-host` link behind in `%USERPROFILE%\.dsh\profiles\desktop`; `setup --remove` deletes such a leftover link (old or new name) once the profile no longer lists the plugin.
40
-
41
- From source instead: `git clone https://github.com/JialaoLiu/dsh-baton.git C:\dev\dsh-baton` and `node C:\dev\dsh-baton\src\cli.mjs setup`; setup then has you add the plugin from `C:\dev\dsh-baton\host` (Add plugin → paste the path it copies).
25
+ - Switched on, the plugin copies the MCP server to `%USERPROFILE%\.dsh-baton\app` and writes `%USERPROFILE%\.claude.json` and `%USERPROFILE%\.codex\config.toml` with the absolute path of `node.exe` (from `PATH`, `%ProgramFiles%\nodejs` or nvm-windows' `NVM_SYMLINK`; never Harness's own runtime), so the desktop apps work even though their `claude.exe` / `codex.exe` are usually not on `PATH`. If it reports that it could not register, run `npx -y dsh-baton-mcp setup` in PowerShell.
26
+ - Claude Code runs the background `wait_command` in Git Bash.
27
+ - With the hoisted profile layout, uninstalling a plugin can leave a `node_modules\dsh-baton-host` (or old `dsh-bridge-host`) link in `%USERPROFILE%\.dsh\profiles\desktop`; `setup --remove` deletes it once the profile no longer lists the plugin.
42
28
 
43
29
  ### If the Plugins page cannot uninstall or install it
44
30
 
@@ -109,7 +95,7 @@ The suite runs on Windows with named pipes. Two POSIX-only checks are skipped: f
109
95
 
110
96
  - **套接字改为命名管道。** Windows 上 Node 不能监听 Unix 套接字文件,所以同一路径被映射成按用户区分的命名管道 `\\.\pipe\dsh-baton-<哈希>`。插件和 MCP 两边算法一致,`DSH_BATON_SOCKET` 仍然可用,Harness 退出后管道自动消失。
111
97
  - **headless 用 Harness 自带的 CLI。** Harness ≥ 0.2.1 自带 `resources\runtime\cli\bin\dsh.cmd`;更早的版本只有打包在 `app.asar` 里的 CLI,由 Harness 自己的 exe 以 Node 模式运行。会按顺序自动查找:`DSH_BIN`、`PATH` 里的 `dsh`、默认安装位置,以及注册表里的自定义安装目录。
112
- - **插件用 Harness 插件页按包名安装。** 插件 → 添加插件 → 来源选 Official npm registry → 输入 `dsh-baton-host` → 安装 → 立即启用(`setup` 已把包名复制到剪贴板)。更新插件要先卸载再重新添加,Harness 只给官方插件提供更新按钮。
113
- - **一条命令注册 MCP。** `npx -y dsh-baton-mcp setup` 把自己复制到 `%USERPROFILE%\.dsh-baton\app`,再直接写 Claude Code 和 Codex 的用户级配置(用 `node` 的绝对路径),桌面版的 `claude` / `codex` 不在 `PATH` 里也没关系。之后新开对话就有 `dsh_*` 工具;Codex 每次最多等 270 秒,不用再配超时。
98
+ - **安装和 macOS 完全相同。** 在 Harness 插件页添加 `dsh-baton-host` 并启用,详见 [README](README.zh-CN.md#快速上手)。
99
+ - **插件启用时自动注册 MCP。** 它把 MCP 服务器复制到 `%USERPROFILE%\.dsh-baton\app`,再直接写 Claude Code 和 Codex 的用户级配置(用 `PATH`、`%ProgramFiles%\nodejs` 或 nvm-windows 里找到的 `node.exe` 绝对路径,不用 Harness 自带的),桌面版的 `claude` / `codex` 不在 `PATH` 里也没关系。之后新开对话就有 `dsh_*` 工具;Codex 每次最多等 270 秒,不用再配超时。没能注册时,在 PowerShell 里运行 `npx -y dsh-baton-mcp setup`。
114
100
  - **插件页卸不掉或装不上时**(`ERR_PNPM_UNEXPECTED_STORE`,或提示"该插件已安装"):是旧版 Harness 装 profile 时用了另一个 pnpm store。一次性修好:从菜单退出 Harness,把 `%USERPROFILE%\.dsh\profiles\desktop\node_modules` 改名备份,用 Harness 自带的 pnpm 运行 `install --frozen-lockfile`(不加 `--store-dir`;`$app` 改成你的安装目录,比如 `E:\Software\DeepSeek Harness`),再启动 Harness,插件页的卸载和添加就正常了;如果插件被关掉了,记得重新打开。
115
101
  - **headless 需要独立凭据。** 要么登录账户,要么设置 `DEEPSEEK_API_KEY`。桌面版里保存的 Key,headless CLI 读不到。
package/host/README.md CHANGED
@@ -6,11 +6,15 @@ DeepSeek Harness 插件,配合 [dsh-baton](https://github.com/JialaoLiu/dsh-ba
6
6
 
7
7
  ## Install / 安装
8
8
 
9
- Harness → Plugins / 插件 → Add plugin / 添加插件 → registry Official npm registry → `dsh-baton-host` → Install / 安装 → Enable now / 立即启用
9
+ Harness → Plugins / 插件 → Add plugin / 添加插件 → `dsh-baton-host` → Install / 安装 → Enable now / 立即启用
10
10
 
11
- Update / 更新: uninstall, then add it again / 先卸载再重新添加. Keep it on the same version as `dsh-baton-mcp` / 版本与 `dsh-baton-mcp` 保持一致.
11
+ 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).
12
12
 
13
- Then connect the MCP server / 然后接入 MCP 服务器: see [dsh-baton](https://github.com/JialaoLiu/dsh-baton#readme).
13
+ 装好就完成了:插件启用时会为 Claude Code 和 Codex 注册 dsh-baton MCP 服务器(`dsh-baton-mcp` 包,随插件一起安装),Harness 里会提示。新开的对话就有 `dsh_*` 工具。没能注册的话,运行 `npx -y dsh-baton-mcp setup`。详见 [dsh-baton](https://github.com/JialaoLiu/dsh-baton/blob/main/README.zh-CN.md#安装细节)。
14
+
15
+ Update / 更新: uninstall, then add it again; the MCP server is updated with it / 先卸载再重新添加,MCP 服务器会一起更新.
16
+
17
+ Remove / 卸载: uninstall, then `npx -y dsh-baton-mcp setup --remove` / 卸载插件后运行 `npx -y dsh-baton-mcp setup --remove`.
14
18
 
15
19
  ## Socket protocol / 套接字协议
16
20
 
@@ -32,7 +36,9 @@ Newline-delimited JSON: request `{"id", "method", "params"}`, reply `{"id", "res
32
36
  | `rename_workspace` | `workspace_id` or `path`, `title` |
33
37
  | `list_runs` | `limit?` |
34
38
 
35
- `DSH_BATON_SOCKET` / `DSH_BATON_HOME` in the Harness process environment move the socket.
39
+ `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
+
41
+ The Plugins page reads the registration outcome from `GET /dsh-baton/status` on the Harness web server.
36
42
 
37
43
  ## License
38
44
 
@@ -1,5 +1,9 @@
1
- // dsh-baton-host, browser half: labels chat messages sent through dsh-baton with
2
- // their sender ("Sent by Claude from another chat"). The host half puts the sender
1
+ // dsh-baton-host, browser half. Two jobs:
2
+ // - On the Plugins page: right after the plugin is switched on, and on its detail page,
3
+ // say whether the host half registered the MCP server for Claude Code and Codex
4
+ // (it serves the outcome at /dsh-baton/status).
5
+ // - In the chat: label messages sent through dsh-baton with their sender ("Sent by
6
+ // Claude from another chat"). The host half puts the sender
3
7
  // into the prompt's request id ("dsh-baton.<sender>.<uuid>"); the chat records it as
4
8
  // the user message's source.rpcId, so the label survives reloads with no extra state.
5
9
  //
@@ -14,7 +18,10 @@ window.__ModuleLoader__.load({
14
18
  id: 'dsh-baton-host',
15
19
  factory: (require) => {
16
20
  const module = { exports: {} }
17
- const { createElement: h } = require('react')
21
+ const { createElement: h, useState, useEffect } = require('react')
22
+ // Harness's dialog and button; without them (an older or newer Harness), plain elements.
23
+ let ui = null
24
+ try { ui = require('@deepseek-ai/dsh-client-ui-primitives') } catch { /* fall back */ }
18
25
 
19
26
  const SLOT = 'conversation.chat.node'
20
27
  const KEYS = ['user', 'steering']
@@ -25,8 +32,124 @@ window.__ModuleLoader__.load({
25
32
  // Codex shows as ChatGPT, the way the ChatGPT app labels its own cross-chat messages.
26
33
  const NAMES = { claude: 'Claude', codex: 'ChatGPT' }
27
34
 
28
- const zh = { sentFrom: '由 {name} 从另一个聊天发送' }
29
- const en = { sentFrom: 'Sent by {name} from another chat' }
35
+ const PACKAGE = 'dsh-baton-host'
36
+ const SETUP = 'npx -y dsh-baton-mcp setup'
37
+
38
+ const zh = {
39
+ sentFrom: '由 {name} 从另一个聊天发送',
40
+ readyTitle: 'dsh-baton 已就绪',
41
+ failTitle: 'dsh-baton 还差一步',
42
+ pending: '正在为 Claude Code 和 Codex 注册 dsh-baton…',
43
+ ready: '已为 {clients} 注册 dsh-baton。在 Claude Code 或 Codex 里新开一个对话,就能用 dsh_* 工具。',
44
+ partial: '{clients} 没能注册:',
45
+ noNode: '没找到 Node.js 22.15 或更新版本。装好 Node.js 后,',
46
+ noClients: '没找到 Claude Code 或 Codex。装好之后,',
47
+ failed: '没能自动注册 MCP。',
48
+ runSetup: '请在终端运行 {cmd}。',
49
+ unknown: '如果新对话里没有 dsh_* 工具,请在终端运行 {cmd}。',
50
+ off: '已关闭自动注册(DSH_BATON_AUTO_REGISTER=0)。',
51
+ ok: '知道了',
52
+ status: 'MCP 注册',
53
+ registered: '已注册',
54
+ notInstalled: '未安装',
55
+ notRegistered: '未注册',
56
+ and: '和',
57
+ }
58
+ const en = {
59
+ sentFrom: 'Sent by {name} from another chat',
60
+ readyTitle: 'dsh-baton is ready',
61
+ failTitle: 'dsh-baton needs one more step',
62
+ pending: 'Registering dsh-baton for Claude Code and Codex…',
63
+ ready: 'dsh-baton is registered for {clients}. Start a new conversation in Claude Code or Codex to use the dsh_* tools.',
64
+ partial: 'Could not register for {clients}: ',
65
+ noNode: 'Node.js 22.15 or later was not found. Once it is installed, ',
66
+ noClients: 'Neither Claude Code nor Codex was found. Once one is installed, ',
67
+ failed: 'The MCP server could not be registered automatically. ',
68
+ runSetup: 'run {cmd} in a terminal.',
69
+ unknown: 'If new conversations have no dsh_* tools, run {cmd} in a terminal.',
70
+ off: 'Automatic registration is off (DSH_BATON_AUTO_REGISTER=0).',
71
+ ok: 'OK',
72
+ status: 'MCP registration',
73
+ registered: 'registered',
74
+ notInstalled: 'not installed',
75
+ notRegistered: 'not registered',
76
+ and: 'and',
77
+ }
78
+
79
+ // --- Plugins page ------------------------------------------------------------
80
+
81
+ const statusUrl = () => new URL('dsh-baton/status', document.baseURI).pathname
82
+ const REGISTERED = ['registered', 'unchanged', 'kept']
83
+
84
+ // The host half registers right after it starts, so poll until it says it is done.
85
+ function useRegistration() {
86
+ const [reg, setReg] = useState(null)
87
+ useEffect(() => {
88
+ let stopped = false
89
+ let tries = 0
90
+ const poll = async () => {
91
+ let next = null
92
+ try {
93
+ const res = await fetch(statusUrl(), { cache: 'no-store' })
94
+ if (res.ok) next = (await res.json()).registration
95
+ } catch { /* not served yet */ }
96
+ if (stopped) return
97
+ if (next && next.state !== 'pending') return setReg(next)
98
+ if (++tries >= 30) return setReg({ state: 'unknown' })
99
+ setTimeout(poll, 700)
100
+ }
101
+ poll()
102
+ return () => { stopped = true }
103
+ }, [])
104
+ return reg
105
+ }
106
+
107
+ // What to tell the user: [title or null, text]. null while the host is still working.
108
+ function describe(reg, t) {
109
+ if (!reg || reg.state === 'pending') return [null, t('pending')]
110
+ if (reg.state === 'off') return [t('readyTitle'), t('off')]
111
+ if (reg.state === 'unknown') return [t('readyTitle'), t('unknown', { cmd: SETUP })]
112
+ const clients = reg.clients ?? []
113
+ const done = clients.filter((c) => REGISTERED.includes(c.state)).map((c) => c.label)
114
+ const failed = clients.filter((c) => c.state === 'failed').map((c) => c.label)
115
+ const list = (names) => names.join(` ${t('and')} `)
116
+ const fix = (reg.error === 'no-node' ? t('noNode') : reg.error === 'no-clients' ? t('noClients') : '') + t('runSetup', { cmd: SETUP })
117
+ if (reg.ok) return [t('readyTitle'), t('ready', { clients: list(done) })]
118
+ if (done.length) return [t('failTitle'), `${t('ready', { clients: list(done) })} ${t('partial', { clients: list(failed) })}${fix}`]
119
+ return [t('failTitle'), (reg.error === 'no-node' || reg.error === 'no-clients' ? '' : t('failed')) + fix]
120
+ }
121
+
122
+ // Shown once, right after "Enable now".
123
+ function wrapActivation(t) {
124
+ return function DshBatonActivation({ onDismiss }) {
125
+ const [title, text] = describe(useRegistration(), t)
126
+ if (ui?.Modal && ui?.Button) {
127
+ return h(ui.Modal, {
128
+ open: true, title: title ?? t('readyTitle'), closeLabel: t('ok'), onClose: onDismiss,
129
+ footer: h(ui.Button, { variant: 'primary', 'data-modal-autofocus': true, onClick: onDismiss }, t('ok')),
130
+ }, h('p', null, text))
131
+ }
132
+ return h('div', { role: 'status', style: { margin: '12px 0', padding: 12, borderRadius: 8, border: '1px solid var(--dsw-alias-border-l3)', color: 'var(--dsw-alias-label-primary)' } },
133
+ title ? h('strong', null, title) : null,
134
+ h('p', { style: { margin: '6px 0' } }, text),
135
+ h('button', { type: 'button', onClick: onDismiss }, t('ok')))
136
+ }
137
+ }
138
+
139
+ // The plugin's detail page: one line per client.
140
+ function wrapDetails(t) {
141
+ return function DshBatonStatus({ view }) {
142
+ const reg = useRegistration()
143
+ if (view === 'summary') return null
144
+ const [, text] = describe(reg, t)
145
+ const word = (s) => (REGISTERED.includes(s) ? t('registered') : s === 'not-installed' ? t('notInstalled') : t('notRegistered'))
146
+ const rows = (reg?.clients ?? []).map((c) => `${c.label}: ${word(c.state)}`)
147
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 4, color: 'var(--dsw-alias-label-secondary)' } },
148
+ h('strong', { style: { color: 'var(--dsw-alias-label-primary)' } }, t('status')),
149
+ rows.length ? h('span', null, rows.join(' · ')) : null,
150
+ h('span', null, text))
151
+ }
152
+ }
30
153
 
31
154
  function senderOf(node) {
32
155
  const rpcId = node?.data?.source?.rpcId
@@ -92,6 +215,8 @@ window.__ModuleLoader__.load({
92
215
  // locale 'chat': the shipped renderer reads its strings through the `t` it is handed.
93
216
  for (const key of KEYS) yield ctx.slots.register({ name: SLOT, key, priority: PRIORITY, locale: 'chat' }, wrapperFor(ctx, key, t))
94
217
  })
218
+ ctx.slots.inject('plugins.bundle.activation', () => ctx.slots.register({ name: 'plugins.bundle.activation', key: PACKAGE, locale: NS }, wrapActivation(t)))
219
+ ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({ name: 'plugins.bundle.config', key: PACKAGE, locale: NS }, wrapDetails(t)))
95
220
  }
96
221
  return module.exports
97
222
  },
package/host/lib/index.js CHANGED
@@ -10,7 +10,9 @@ import net from 'node:net'
10
10
  import os from 'node:os'
11
11
  import path from 'node:path'
12
12
  import crypto from 'node:crypto'
13
- import { socketEndpoint, batonHome, socketPath as socketPathSetting } from './socket.js'
13
+ import { createRequire } from 'node:module'
14
+ import { fileURLToPath, pathToFileURL } from 'node:url'
15
+ import { socketEndpoint, batonHome, setting, socketPath as socketPathSetting } from './socket.js'
14
16
 
15
17
  export const name = 'dsh-baton-host'
16
18
  export const inject = ['sessionController', 'workspaceRegistry']
@@ -85,6 +87,26 @@ function toolCallsOf(content) {
85
87
 
86
88
  const iso = (t) => (typeof t === 'number' ? new Date(t).toISOString() : t ?? null)
87
89
 
90
+ // The MCP server package this plugin registers: the checkout or ~/.dsh-baton/app folder
91
+ // this host folder sits in, else the dsh-baton-mcp package installed with the plugin.
92
+ function serverRoot() {
93
+ const up = fileURLToPath(new URL('../../', import.meta.url))
94
+ if (fs.existsSync(path.join(up, 'src', 'register.mjs'))) return path.resolve(up)
95
+ try { return path.dirname(createRequire(import.meta.url).resolve('dsh-baton-mcp/package.json')) } catch { return null }
96
+ }
97
+
98
+ // The Harness install this process runs from, so its bundled Node (whose path changes
99
+ // with every Harness update) is never registered.
100
+ function harnessDir() {
101
+ const exe = process.execPath
102
+ const app = exe.match(/^(.*?\.app)(?=[\\/])/)
103
+ if (app) return app[1]
104
+ // Windows: the install folder, e.g. %LOCALAPPDATA%\Programs\DeepSeek Harness.
105
+ const i = exe.toLowerCase().indexOf('deepseek harness')
106
+ const end = i < 0 ? -1 : exe.slice(i).search(/[\\/]/)
107
+ return end > 0 ? exe.slice(0, i + end) : null
108
+ }
109
+
88
110
  export function apply(ctx) {
89
111
  // This code runs inside the Harness host process: an uncaught error here can
90
112
  // take Harness down. Every event listener and timer goes through guard().
@@ -630,6 +652,40 @@ export function apply(ctx) {
630
652
  }
631
653
 
632
654
 
655
+ // --- MCP registration -----------------------------------------------------
656
+ // Switching the plugin on registers the dsh-baton MCP server for Claude Code and Codex,
657
+ // the same entries `dsh-baton setup` writes, so adding the plugin is the whole install.
658
+ // It runs again on every Harness start: a plugin update brings the server along, and an
659
+ // entry that is already right is left untouched. The Plugins page reads the outcome from
660
+ // GET /dsh-baton/status (lib/client.js). DSH_BATON_AUTO_REGISTER=0 turns it off.
661
+ let registration = { state: 'pending' }
662
+ if (setting('AUTO_REGISTER') !== '0') {
663
+ setImmediate(guard('register', async () => {
664
+ const root = serverRoot()
665
+ if (!root) { registration = { state: 'done', ok: false, error: 'no-server', clients: [] }; return }
666
+ const { autoRegister } = await import(pathToFileURL(path.join(root, 'src', 'register.mjs')).href)
667
+ const result = autoRegister({ root, harnessDir: harnessDir() })
668
+ registration = { state: 'done', ...result }
669
+ const summary = result.clients.map((c) => `${c.label}: ${c.state}${c.error ? ` (${c.error})` : ''}`).join(', ')
670
+ ctx.logger?.[result.ok ? 'info' : 'error']?.(`dsh-baton-host MCP registration: ${summary || result.error}`)
671
+ }))
672
+ } else {
673
+ registration = { state: 'off' }
674
+ }
675
+ if (typeof ctx.inject === 'function') {
676
+ ctx.inject(['webServer'], (web) => {
677
+ web.effect(() => web.webServer.register({
678
+ kind: 'exact',
679
+ path: '/dsh-baton/status',
680
+ handler: (request, response) => {
681
+ const { state, ok, error, version, clients = [] } = registration
682
+ response.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
683
+ response.end(JSON.stringify({ version: VERSION, registration: { state, ok, error, version, clients: clients.map(({ id, label, state }) => ({ id, label, state })) } }))
684
+ },
685
+ }), 'dsh-baton-host status route')
686
+ })
687
+ }
688
+
633
689
  ctx.effect(() => {
634
690
  const sock = socketPath()
635
691
  let server = null
package/host/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-baton-host",
3
- "version": "0.4.0",
4
- "description": "DeepSeek Harness plugin for dsh-baton: takes the baton from Claude Code or Codex 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.",
3
+ "version": "0.5.1",
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",
7
7
  "exports": {
@@ -19,6 +19,9 @@
19
19
  "README.md",
20
20
  "LICENSE"
21
21
  ],
22
+ "dependencies": {
23
+ "dsh-baton-mcp": "0.5.1"
24
+ },
22
25
  "license": "MIT",
23
26
  "dsh": {
24
27
  "manifestVersion": 1,
@@ -33,7 +36,8 @@
33
36
  "inject": [
34
37
  "@deepseek-ai/dsh-client-locale",
35
38
  "@deepseek-ai/dsh-client-ui-renderer",
36
- "@deepseek-ai/dsh-client-ui-chat"
39
+ "@deepseek-ai/dsh-client-ui-chat",
40
+ "@deepseek-ai/dsh-client-ui-primitives"
37
41
  ]
38
42
  }
39
43
  },
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "dsh-baton-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
+ "mcpName": "io.github.JialaoLiu/dsh-baton",
4
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.",
5
6
  "type": "module",
6
7
  "bin": {
@@ -18,7 +19,8 @@
18
19
  "README.md",
19
20
  "README.zh-CN.md",
20
21
  "WINDOWS.md",
21
- "LICENSE"
22
+ "LICENSE",
23
+ "server.json"
22
24
  ],
23
25
  "engines": {
24
26
  "node": ">=22.15"
package/server.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.JialaoLiu/dsh-baton",
4
+ "title": "dsh-baton",
5
+ "description": "Hand tasks from Claude Code, Codex or any MCP client to a local DeepSeek Harness.",
6
+ "repository": {
7
+ "url": "https://github.com/JialaoLiu/dsh-baton",
8
+ "source": "github"
9
+ },
10
+ "version": "0.5.1",
11
+ "packages": [
12
+ {
13
+ "registryType": "npm",
14
+ "identifier": "dsh-baton-mcp",
15
+ "version": "0.5.1",
16
+ "transport": {
17
+ "type": "stdio"
18
+ }
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,236 @@
1
+ // Registering the dsh-baton MCP server for Claude Code and Codex, user-wide, by writing
2
+ // their config files directly. This needs no `claude` or `codex` on PATH (desktop-only
3
+ // installs have neither) and no hand-editing of TOML.
4
+ //
5
+ // Two callers: `dsh-baton setup` (src/setup.mjs), and the Harness plugin, which runs
6
+ // autoRegister() when it is switched on. So this module imports nothing that does work
7
+ // at load time: it runs inside the Harness host process as well.
8
+ import fs from 'node:fs'
9
+ import os from 'node:os'
10
+ import path from 'node:path'
11
+ import { execFileSync } from 'node:child_process'
12
+ import { batonHome } from '../host/lib/socket.js'
13
+
14
+ export const NAME = 'dsh-baton'
15
+ // The name before the rename: its entries are removed so the tools are not listed twice.
16
+ const OLD_NAME = 'dsh-bridge'
17
+
18
+ export const claudeConfigPath = (env = process.env, home = os.homedir()) =>
19
+ path.join(env.CLAUDE_CONFIG_DIR || home, '.claude.json')
20
+ export const codexConfigPath = (env = process.env, home = os.homedir()) =>
21
+ path.join(env.CODEX_HOME || path.join(home, '.codex'), 'config.toml')
22
+
23
+ // ~/.claude.json: the top-level "mcpServers" map is Claude Code's user scope.
24
+ export function editClaudeConfig(text, entry) {
25
+ const cfg = text.trim() ? JSON.parse(text) : {}
26
+ const servers = { ...(cfg.mcpServers ?? {}) }
27
+ delete servers[OLD_NAME]
28
+ if (entry) servers[NAME] = { type: 'stdio', command: entry.command, args: entry.args, env: {} }
29
+ else delete servers[NAME]
30
+ return JSON.stringify({ ...cfg, mcpServers: servers }, null, 2) + '\n'
31
+ }
32
+
33
+ export function readClaudeEntry(text) {
34
+ const e = text.trim() ? JSON.parse(text).mcpServers?.[NAME] : null
35
+ return e?.command ? { command: e.command, args: e.args ?? [] } : null
36
+ }
37
+
38
+ const tomlString = (s) => (/['\n\r]/.test(s) ? JSON.stringify(s) : `'${s}'`)
39
+ // Our own table and its sub-tables ([mcp_servers.dsh-baton], [mcp_servers.dsh-baton.env], …),
40
+ // under the current name or the one before the rename.
41
+ const OWN_TABLE = /^\s*\[\s*mcp_servers\s*\.\s*(["']?)dsh-(?:baton|bridge)\1\s*(?:\.[^\]]*)?\]\s*(?:#.*)?$/
42
+ const ANY_TABLE = /^\s*\[/
43
+
44
+ // ~/.codex/config.toml: replace any earlier dsh-baton tables with one fresh table at the
45
+ // end. Codex stops a tool call after 300 s; the server keeps each wait under that, so no
46
+ // tool_timeout_sec is needed.
47
+ export function editCodexConfig(text, entry) {
48
+ const kept = []
49
+ let skipping = false
50
+ for (const line of text.split(/\r?\n/)) {
51
+ if (OWN_TABLE.test(line)) skipping = true
52
+ else if (ANY_TABLE.test(line)) skipping = false
53
+ if (!skipping) kept.push(line)
54
+ }
55
+ let out = kept.join('\n').replace(/\s+$/, '')
56
+ if (entry) {
57
+ out += `${out ? '\n\n' : ''}[mcp_servers.${NAME}]\ncommand = ${tomlString(entry.command)}\nargs = [${entry.args.map(tomlString).join(', ')}]`
58
+ }
59
+ return out ? out + '\n' : ''
60
+ }
61
+
62
+ // The command and args of [mcp_servers.dsh-baton], as setup writes them (single-line strings).
63
+ export function readCodexEntry(text) {
64
+ const str = String.raw`'[^'\n]*'|"(?:[^"\\\n]|\\.)*"`
65
+ const unquote = (s) => (s.startsWith("'") ? s.slice(1, -1) : JSON.parse(s))
66
+ let inTable = false
67
+ let command = null, args = []
68
+ for (const line of text.split(/\r?\n/)) {
69
+ if (ANY_TABLE.test(line)) inTable = /^\s*\[\s*mcp_servers\s*\.\s*(["']?)dsh-baton\1\s*\]/.test(line)
70
+ else if (inTable) {
71
+ const c = line.match(new RegExp(`^\\s*command\\s*=\\s*(${str})`))
72
+ if (c) command = unquote(c[1])
73
+ const a = line.match(/^\s*args\s*=\s*\[(.*)\]/)
74
+ if (a) args = [...a[1].matchAll(new RegExp(str, 'g'))].map((m) => unquote(m[0]))
75
+ }
76
+ }
77
+ return command ? { command, args } : null
78
+ }
79
+
80
+ // Write only when the content changes, keep one backup, and replace the file atomically.
81
+ export function rewrite(file, edit) {
82
+ const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
83
+ const after = edit(before)
84
+ if (after === before) return 'unchanged'
85
+ if (before) fs.copyFileSync(file, `${file}.bak-dsh-baton`)
86
+ fs.mkdirSync(path.dirname(file), { recursive: true })
87
+ const tmp = `${file}.tmp-${process.pid}`
88
+ fs.writeFileSync(tmp, after, { mode: before ? fs.statSync(file).mode : 0o600 })
89
+ fs.renameSync(tmp, file)
90
+ return 'updated'
91
+ }
92
+
93
+ // Which clients are installed for this user, and where their config lives.
94
+ export function clients(env = process.env, home = os.homedir()) {
95
+ const claude = claudeConfigPath(env, home)
96
+ const codex = codexConfigPath(env, home)
97
+ return [
98
+ { id: 'claude', label: 'Claude Code', file: claude, present: fs.existsSync(claude) || fs.existsSync(path.join(home, '.claude')), edit: editClaudeConfig, read: readClaudeEntry },
99
+ { id: 'codex', label: 'Codex', file: codex, present: fs.existsSync(path.dirname(codex)), edit: editCodexConfig, read: readCodexEntry },
100
+ ]
101
+ }
102
+
103
+ // The node to register: the one on PATH when it is this same binary, since that path
104
+ // survives upgrades (Homebrew's /opt/homebrew/bin/node, not the versioned Cellar path
105
+ // that process.execPath resolves to); otherwise the running binary itself.
106
+ export function stableNodePath(env = process.env, execPath = process.execPath) {
107
+ const real = (p) => { try { return fs.realpathSync(p) } catch { return null } }
108
+ const target = real(execPath)
109
+ const names = process.platform === 'win32' ? ['node.exe'] : ['node']
110
+ for (const dir of (env.PATH ?? env.Path ?? '').split(path.delimiter).filter(Boolean)) {
111
+ for (const name of names) {
112
+ const candidate = path.join(dir, name)
113
+ if (candidate !== execPath && target && real(candidate) === target) return candidate
114
+ }
115
+ }
116
+ return execPath
117
+ }
118
+
119
+ export const MIN_NODE = [22, 15]
120
+ export function nodeVersionOk(version) {
121
+ const [major, minor] = String(version).replace(/^v/, '').split('.').map(Number)
122
+ return major > MIN_NODE[0] || (major === MIN_NODE[0] && minor >= MIN_NODE[1])
123
+ }
124
+
125
+ // A Node.js for the MCP server when the caller is not one itself: the Harness plugin runs
126
+ // on the app's own binary, whose path changes with every Harness update. So: the first
127
+ // `node` on PATH, then the usual install folders, never one inside the Harness app, and
128
+ // only a new enough one.
129
+ export function findNode({ env = process.env, platform = process.platform, exclude = [], version = nodeVersion } = {}) {
130
+ const exe = platform === 'win32' ? 'node.exe' : 'node'
131
+ const p = platform === 'win32' ? path.win32 : path.posix
132
+ const dirs = (env.PATH ?? env.Path ?? '').split(p.delimiter).filter(Boolean)
133
+ if (platform === 'win32') {
134
+ dirs.push(...[env.NVM_SYMLINK, env.ProgramFiles && p.join(env.ProgramFiles, 'nodejs'), env.LOCALAPPDATA && p.join(env.LOCALAPPDATA, 'Programs', 'nodejs')].filter(Boolean))
135
+ } else {
136
+ dirs.push('/opt/homebrew/bin', '/usr/local/bin', '/usr/bin')
137
+ }
138
+ const real = (x) => { try { return fs.realpathSync(x) } catch { return x } }
139
+ const fold = (s) => (platform === 'win32' ? s.toLowerCase() : s)
140
+ const skip = exclude.filter(Boolean).map((x) => fold(real(x)))
141
+ const seen = new Set()
142
+ for (const dir of dirs) {
143
+ const candidate = p.join(dir, exe)
144
+ if (seen.has(candidate)) continue
145
+ seen.add(candidate)
146
+ if (!fs.existsSync(candidate)) continue
147
+ const at = fold(real(candidate))
148
+ if (skip.some((x) => at === x || at.startsWith(x + p.sep))) continue
149
+ if (nodeVersionOk(version(candidate))) return candidate
150
+ }
151
+ return null
152
+ }
153
+
154
+ function nodeVersion(bin) {
155
+ try {
156
+ return execFileSync(bin, ['--version'], { windowsHide: true, timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], env: { ...process.env, ELECTRON_RUN_AS_NODE: undefined } }).toString().trim()
157
+ } catch {
158
+ return ''
159
+ }
160
+ }
161
+
162
+ // The stable folder the server runs from when it came from a package cache (npx's, or
163
+ // the Harness profile's): those can be cleared or replaced at any time.
164
+ export const appDir = (home = batonHome()) => path.join(home, 'app')
165
+ const APP_PARTS = ['src', 'host', 'patches', 'package.json', 'LICENSE']
166
+
167
+ export function installApp(from, to = appDir()) {
168
+ fs.mkdirSync(to, { recursive: true })
169
+ for (const part of APP_PARTS) {
170
+ const src = path.join(from, part)
171
+ if (!fs.existsSync(src)) continue
172
+ // Replace each part whole, so files dropped in a newer version do not linger.
173
+ fs.rmSync(path.join(to, part), { recursive: true, force: true })
174
+ fs.cpSync(src, path.join(to, part), { recursive: true })
175
+ }
176
+ return to
177
+ }
178
+
179
+ const versionOf = (root) => { try { return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version } catch { return null } }
180
+ export function compareVersions(a, b) {
181
+ const parse = (v) => String(v).split('-')[0].split('.').map(Number)
182
+ const [x, y] = [parse(a), parse(b)]
183
+ for (let i = 0; i < 3; i++) if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) - (y[i] || 0)
184
+ // A prerelease sorts before its release.
185
+ return (String(b).includes('-') ? 1 : 0) - (String(a).includes('-') ? 1 : 0)
186
+ }
187
+
188
+ // Run by the Harness plugin when it is switched on (and on every Harness start, so a
189
+ // plugin update brings the server along). `root` holds the MCP server package: the repo
190
+ // checkout the plugin was added from, or the dsh-baton-mcp package installed with it.
191
+ // - The server is copied to ~/.dsh-baton/app, unless root is a git checkout (registered
192
+ // as is, like `setup` from a clone) or that copy is already as new.
193
+ // - An entry that already starts this server, or a working one that starts another copy
194
+ // (a clone, say), is left alone; a missing or broken one is (re)written.
195
+ // Never throws: the result says what happened to each client.
196
+ export function autoRegister({ root, home = os.homedir(), env = process.env, node = null, harnessDir = null, find = findNode, app = appDir(batonHome(env, home)) } = {}) {
197
+ const result = { ok: false, version: versionOf(root), clients: [], error: null, node: null }
198
+ try {
199
+ let serverRoot = root
200
+ if (!fs.existsSync(path.join(root, '.git')) && path.resolve(root) !== path.resolve(app)) {
201
+ const installed = versionOf(app)
202
+ if (!installed || !fs.existsSync(path.join(app, 'src', 'server.mjs')) || compareVersions(installed, result.version) < 0) installApp(root, app)
203
+ serverRoot = app
204
+ }
205
+ const script = path.join(serverRoot, 'src', 'server.mjs')
206
+ const works = (e) => e && fs.existsSync(e.command) && e.args?.[0] && fs.existsSync(e.args[0])
207
+ const list = clients(env, home)
208
+ let bin = node
209
+ for (const c of list) {
210
+ if (!c.present) { result.clients.push({ id: c.id, label: c.label, state: 'not-installed' }); continue }
211
+ try {
212
+ const text = fs.existsSync(c.file) ? fs.readFileSync(c.file, 'utf8') : ''
213
+ const current = c.read(text)
214
+ if (works(current)) {
215
+ const same = path.resolve(current.args[0]) === path.resolve(script)
216
+ result.clients.push({ id: c.id, label: c.label, state: same ? 'unchanged' : 'kept', script: current.args[0] })
217
+ continue
218
+ }
219
+ bin ??= find({ env, exclude: [harnessDir] })
220
+ if (!bin) { result.clients.push({ id: c.id, label: c.label, state: 'failed', error: 'no-node' }); continue }
221
+ rewrite(c.file, (t) => c.edit(t, { command: bin, args: [script] }))
222
+ result.clients.push({ id: c.id, label: c.label, state: 'registered' })
223
+ } catch (e) {
224
+ result.clients.push({ id: c.id, label: c.label, state: 'failed', error: e.message })
225
+ }
226
+ }
227
+ result.node = bin
228
+ const present = result.clients.filter((c) => c.state !== 'not-installed')
229
+ result.ok = present.length > 0 && present.every((c) => c.state !== 'failed')
230
+ if (!present.length) result.error = 'no-clients'
231
+ else if (present.some((c) => c.error === 'no-node')) result.error = 'no-node'
232
+ } catch (e) {
233
+ result.error = e.message
234
+ }
235
+ return result
236
+ }
package/src/setup.mjs CHANGED
@@ -1,67 +1,13 @@
1
- // One-step setup: register the dsh-baton MCP server for Claude Code and Codex, user-wide,
2
- // by writing their config files directly. This needs no `claude` or `codex` on PATH
3
- // (desktop-only installs have neither) and no hand-editing of TOML.
1
+ // `dsh-baton setup`: register the dsh-baton MCP server for Claude Code and Codex, user-wide
2
+ // (see register.mjs, which the Harness plugin also uses), and say what is left to do in Harness.
4
3
  import fs from 'node:fs'
5
4
  import os from 'node:os'
6
5
  import path from 'node:path'
7
6
  import { execFileSync } from 'node:child_process'
8
7
  import { ROOT, config, hostAvailable, hostVersionSeen as versionOfHost, versionWarning } from './core.mjs'
8
+ import { appDir, installApp, rewrite, stableNodePath, claudeConfigPath, codexConfigPath, editClaudeConfig, editCodexConfig } from './register.mjs'
9
9
 
10
- const NAME = 'dsh-baton'
11
- // The name before the rename: setup removes its entries so the tools are not listed twice.
12
- const OLD_NAME = 'dsh-bridge'
13
-
14
- export const claudeConfigPath = (env = process.env, home = os.homedir()) =>
15
- path.join(env.CLAUDE_CONFIG_DIR || home, '.claude.json')
16
- export const codexConfigPath = (env = process.env, home = os.homedir()) =>
17
- path.join(env.CODEX_HOME || path.join(home, '.codex'), 'config.toml')
18
-
19
- // ~/.claude.json: the top-level "mcpServers" map is Claude Code's user scope.
20
- export function editClaudeConfig(text, entry) {
21
- const cfg = text.trim() ? JSON.parse(text) : {}
22
- const servers = { ...(cfg.mcpServers ?? {}) }
23
- delete servers[OLD_NAME]
24
- if (entry) servers[NAME] = { type: 'stdio', command: entry.command, args: entry.args, env: {} }
25
- else delete servers[NAME]
26
- return JSON.stringify({ ...cfg, mcpServers: servers }, null, 2) + '\n'
27
- }
28
-
29
- const tomlString = (s) => (/['\n\r]/.test(s) ? JSON.stringify(s) : `'${s}'`)
30
- // Our own table and its sub-tables ([mcp_servers.dsh-baton], [mcp_servers.dsh-baton.env], …),
31
- // under the current name or the one before the rename.
32
- const OWN_TABLE = /^\s*\[\s*mcp_servers\s*\.\s*(["']?)dsh-(?:baton|bridge)\1\s*(?:\.[^\]]*)?\]\s*(?:#.*)?$/
33
- const ANY_TABLE = /^\s*\[/
34
-
35
- // ~/.codex/config.toml: replace any earlier dsh-baton tables with one fresh table at the
36
- // end. Codex stops a tool call after 300 s; the server keeps each wait under that, so no
37
- // tool_timeout_sec is needed.
38
- export function editCodexConfig(text, entry) {
39
- const kept = []
40
- let skipping = false
41
- for (const line of text.split(/\r?\n/)) {
42
- if (OWN_TABLE.test(line)) skipping = true
43
- else if (ANY_TABLE.test(line)) skipping = false
44
- if (!skipping) kept.push(line)
45
- }
46
- let out = kept.join('\n').replace(/\s+$/, '')
47
- if (entry) {
48
- out += `${out ? '\n\n' : ''}[mcp_servers.${NAME}]\ncommand = ${tomlString(entry.command)}\nargs = [${entry.args.map(tomlString).join(', ')}]`
49
- }
50
- return out ? out + '\n' : ''
51
- }
52
-
53
- // Write only when the content changes, keep one backup, and replace the file atomically.
54
- function rewrite(file, edit) {
55
- const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
56
- const after = edit(before)
57
- if (after === before) return 'unchanged'
58
- if (before) fs.copyFileSync(file, `${file}.bak-dsh-baton`)
59
- fs.mkdirSync(path.dirname(file), { recursive: true })
60
- const tmp = `${file}.tmp-${process.pid}`
61
- fs.writeFileSync(tmp, after, { mode: before ? fs.statSync(file).mode : 0o600 })
62
- fs.renameSync(tmp, file)
63
- return 'updated'
64
- }
10
+ export { appDir, installApp, stableNodePath, claudeConfigPath, codexConfigPath, editClaudeConfig, editCodexConfig }
65
11
 
66
12
  function copyToClipboard(text) {
67
13
  try {
@@ -144,42 +90,12 @@ const PLUGIN = 'dsh-baton-host'
144
90
 
145
91
  const pluginFrom = (source, hostDir) => source === `link:${hostDir}` || source === `link:${hostDir.split(path.sep).join('/')}`
146
92
 
147
- // The node to register: the one on PATH when it is this same binary, since that path
148
- // survives upgrades (Homebrew's /opt/homebrew/bin/node, not the versioned Cellar path
149
- // that process.execPath resolves to); otherwise the running binary itself.
150
- export function stableNodePath(env = process.env, execPath = process.execPath) {
151
- const real = (p) => { try { return fs.realpathSync(p) } catch { return null } }
152
- const target = real(execPath)
153
- const names = process.platform === 'win32' ? ['node.exe'] : ['node']
154
- for (const dir of (env.PATH ?? env.Path ?? '').split(path.delimiter).filter(Boolean)) {
155
- for (const name of names) {
156
- const candidate = path.join(dir, name)
157
- if (candidate !== execPath && target && real(candidate) === target) return candidate
158
- }
159
- }
160
- return execPath
161
- }
162
-
163
93
  // Run through npx, the package sits in npm's cache, which npm may clear or replace at any
164
94
  // time. setup then copies it to a stable folder and registers that copy instead.
165
- export const appDir = () => path.join(config.batonHome, 'app')
166
95
  export const inNpxCache = (root = ROOT) => root.split(/[\\/]/).includes('_npx')
167
- const APP_PARTS = ['src', 'host', 'patches', 'package.json', 'LICENSE']
168
-
169
- export function installApp(from = ROOT, to = appDir()) {
170
- fs.mkdirSync(to, { recursive: true })
171
- for (const part of APP_PARTS) {
172
- const src = path.join(from, part)
173
- if (!fs.existsSync(src)) continue
174
- // Replace each part whole, so files dropped in a newer version do not linger.
175
- fs.rmSync(path.join(to, part), { recursive: true, force: true })
176
- fs.cpSync(src, path.join(to, part), { recursive: true })
177
- }
178
- return to
179
- }
180
96
 
181
97
  export async function setup({ remove = false } = {}) {
182
- const root = !remove && inNpxCache() ? installApp() : ROOT
98
+ const root = !remove && inNpxCache() ? installApp(ROOT) : ROOT
183
99
  const entry = remove ? null : { command: stableNodePath(), args: [path.join(root, 'src', 'server.mjs')] }
184
100
  const lines = []
185
101
  if (root !== ROOT) lines.push(`Installed dsh-baton to ${root}`)
@@ -187,7 +103,10 @@ export async function setup({ remove = false } = {}) {
187
103
  if (!remove && !present) return lines.push(`- ${label}: not installed, skipped`)
188
104
  try {
189
105
  const result = rewrite(file, (t) => edit(t, entry))
190
- lines.push(`- ${label}: ${remove ? 'removed from' : 'registered in'} ${file}${result === 'unchanged' ? ' (already up to date)' : ''}`)
106
+ const what = remove
107
+ ? (result === 'unchanged' ? 'not registered in' : 'removed from')
108
+ : 'registered in'
109
+ lines.push(`- ${label}: ${what} ${file}${!remove && result === 'unchanged' ? ' (already up to date)' : ''}`)
191
110
  } catch (e) {
192
111
  lines.push(`- ${label}: FAILED to edit ${file}: ${e.message}`)
193
112
  process.exitCode = 1