dsh-herdr-bridge 0.1.0 → 0.2.0

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
@@ -1,178 +1,174 @@
1
1
  # dsh-herdr-bridge
2
2
 
3
- 一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)插件,把 DSH 的会话接进 [Herdr Core](https://github.com/herdrdev/herdr-core)(`hc`):每个会话变成一个 hc 终端,Herdr 里的其他 Agent 可以像操控 Claude Code 一样读取和操控它。适用于 DSH Desktop 和 DSH Web(`dsh web`)。
3
+ English | [简体中文](README.zh-CN.md)
4
4
 
5
- 装好以后:
5
+ A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) plugin that brings DSH sessions into [Herdr Core](https://github.com/herdrdev/herdr-core) (`hc`). Each session becomes an hc terminal, so other agents in Herdr can read and drive it the way they drive Claude Code. Works with DSH Desktop and DSH Web (`dsh web`).
6
6
 
7
- - **在 Herdr 里看到 DSH 会话**:会话一开始运行,[Herdr App](https://github.com/herdrdev/herdr-app) 和 [Perch](https://github.com/Hariketsu/herdr-perch) 里就出现一行 `dsh-<短 id>`,状态在 `working / done / blocked / error` 之间跟着变。
8
- - **从 Herdr 操控 DSH**:Herdr Agent 用 `hc run / send / wait / read / interrupt` 新建会话、接管已有会话、发消息、取消回合。
9
- - **让 DSH 调度其他 Agent**:DSH 里的模型可以用 `hc` 启动 helper(比如 Claude Code),helper 的回报会送回这个 DSH 会话。
10
- - **审批两边都能答**:会话等待审批或提问时,在 DSH 界面或 hc 里回答都行,先答者生效,另一边的对话框会撤回。
7
+ Once it's installed:
11
8
 
12
- 插件运行在 DSH 自己的进程里,不需要第二份 DSH,也不修改 DSH、Herdr 的任何源码。
9
+ - **DSH sessions show up in Herdr.** When a session starts running, a `dsh-<short id>` terminal appears in [Herdr App](https://github.com/herdrdev/herdr-app). Its status follows the session through `working / done / blocked / error`.
10
+ - **Herdr drives DSH.** Herdr agents use `hc run / send / wait / read / interrupt` to start sessions, take over existing ones, send messages and cancel turns.
11
+ - **DSH drives other agents.** The model in a DSH session can start helpers with `hc` (Claude Code, for example), and their reports come back to that session.
12
+ - **Approvals can be answered on either side.** When a session waits for an approval or a question, answer it in the DSH window or in hc. The first answer wins, and the other side's dialog is withdrawn.
13
13
 
14
- ## 效果
14
+ The plugin runs inside DSH's own process. It needs no second copy of DSH and changes no DSH or Herdr source code.
15
15
 
16
- <!-- TODO: Perch / Herdr App 里显示 dsh-xxxx 行的截图 -->
16
+ ## What it looks like
17
17
 
18
- Herdr Agent 读取一个 DSH 会话时,看到的是和原生 TUI Agent 相同格式的文本(示例):
18
+ ![Three DSH sessions in Herdr App: one waiting for approval, one working, one finished; on the right, the finished session's conversation](https://raw.githubusercontent.com/Hariketsu/dsh-herdr-bridge/main/docs/media/herdr-app.webp)
19
19
 
20
- ```text
21
- $ hc read fix
22
- ❯ 修复 x.rs 里失败的测试
23
- ⏺ 先跑一下测试看看失败在哪。
24
- ⏺ bash 运行 x 的单元测试
25
- ⎿ test parse_empty ... FAILED (+23 lines)
26
- ⏺ 空输入时没有提前返回,已修复并重跑,全部通过。
27
- ✻ done · 41s
28
- ```
20
+ Three DSH sessions in Herdr App, each in a different state. On the right is one session as it looks in its hc terminal: the prompt, the reply, tool calls with their descriptions, and how long the turn took, in the same format as native TUI agents.
29
21
 
30
- ## 要求
22
+ ## Requirements
31
23
 
32
- | | 开发时使用的版本 | 说明 |
24
+ | | Developed against | Notes |
33
25
  |---|---|---|
34
- | DSH(Desktop 或 `dsh web`) | 0.2.2-rc.1 | 插件依赖的 DSH 接口仍在变化,升级 DSH 后可能需要更新插件。`dsh headless` 不支持,`dsh tui` 没有测试过 |
35
- | herdr-core(`hc`) | 0.1.18 | `hc` 需要在登录 shell 的 PATH 上,或在配置里指定 |
36
- | herdr-app | 0.1.13 | 可选 |
37
- | Perch | `40e4e2c` | 可选 |
26
+ | DSH (Desktop or `dsh web`) | 0.2.2-rc.1 | The DSH interfaces the plugin uses are still changing, so a DSH upgrade may need a plugin update. `dsh headless` isn't supported; `dsh tui` hasn't been tested |
27
+ | herdr-core (`hc`) | 0.1.18 | `hc` must be on your login shell's PATH, or set in the config |
28
+ | herdr-app | 0.1.13 | Optional |
38
29
 
39
- Desktop 和 `dsh web` 同一时间只能有一个接入 Herdr(见已知限制)。
30
+ Only one of Desktop and `dsh web` can be connected to Herdr at a time (see Known limitations).
40
31
 
41
- 平台:只在 macOS 上验证过。Windows / Linux 的代码路径已写好,但没有测试过。暂无最低版本要求,只保证上表这一组合。
32
+ Platforms: tested on macOS only. Windows and Linux are implemented but untested. There are no minimum versions yet; only the combination above is known to work.
42
33
 
43
- ## 安装
34
+ ## Install
44
35
 
45
- 1. 装进你使用的 DSH profile。Desktop 用 `desktop`,要先完全退出 Desktop(⌘Q);Web 用 `web`,要先停掉 `dsh web`:
46
- ```sh
47
- dsh plugin --profile desktop add dsh-herdr-bridge --ignore-scripts --config.auto-install-peers=false # 或 --profile web
48
- ```
49
- 2. 重新打开 Desktop 或启动 `dsh web`。插件加载时会生成命令 `~/.dsh/herdr-bridge/bin/dsh-hc`。
50
- 3. 把它加进 PATH(下文的示例都假设已经加好),并安装 hc template,让 `hc run dsh "任务"` 可用:
36
+ 1. Add the plugin to the DSH profile you use: `desktop` for Desktop (quit Desktop fully with ⌘Q first), or `web` for `dsh web` (stop it first):
51
37
  ```sh
52
- fish_add_path ~/.dsh/herdr-bridge/bin # fish;zsh/bash 在 rc 文件里加 export PATH="$HOME/.dsh/herdr-bridge/bin:$PATH"
53
- dsh-hc install-template # 写入 ~/.config/herdr-core/templates/dsh.md
38
+ dsh plugin --profile desktop add dsh-herdr-bridge # or --profile web
54
39
  ```
40
+ 2. Reopen Desktop or start `dsh web`. That's all: when the plugin loads, it installs the hc template, so Herdr agents can run `hc run dsh "task"`, and, if you use Herdr App, DSH's name and icon there.
55
41
 
56
- **确认安装成功**:
42
+ **Check that it works:**
57
43
 
58
44
  ```sh
59
- dsh-hc ls # 列出 DSH 里最近的会话(还没有会话时显示 no DSH sessions in that period)
45
+ hc templates # lists dsh
60
46
  ```
61
47
 
62
- 然后在 DSH 里随便发一条消息,`hc ls` 里应该出现 `dsh-<短 id>` 终端。
48
+ Then send any message in DSH. A `dsh-<short id>` terminal should appear in `hc ls`.
63
49
 
64
- **卸载**:退出 Desktop 或停掉 `dsh web`,执行 `dsh plugin --profile <profile> remove dsh-herdr-bridge`;如果你在 `~/.dsh/profiles/<profile>/cordis.patch.yml` 里加过 `herdr-bridge` 配置,一并删掉。再删除 `~/.config/herdr-core/templates/dsh.md` 和 `~/.dsh/herdr-bridge/`。已经开着的 `dsh-*` 终端会在宽限期后自行退出。
50
+ **Uninstall:** quit Desktop or stop `dsh web`, then run `dsh plugin --profile <profile> remove dsh-herdr-bridge`. If you added `herdr-bridge` settings to `~/.dsh/profiles/<profile>/cordis.patch.yml`, remove them too. Then delete `~/.config/herdr-core/templates/dsh.md`, `~/.dsh/herdr-bridge/`, the `dsh` entry in `~/.config/herdr-app/programs.json`, and `~/.config/herdr-app/icons/dsh-herdr-bridge.svg`. Any `dsh-*` terminals still open exit on their own after the grace period.
65
51
 
66
- ## 快速上手
52
+ ## Quick start
67
53
 
68
- **1. 在 DSH 里正常使用。** 会话第一次运行时,插件为它开一个 `dsh-<短 id>` 终端,带标签 `dsh.session=<会话 id>` 和 `dsh.title=<会话标题>`(标题随改名同步)。还没运行过的会话,在 DSH 里对它输入 `/herdr`,它就会出现在 Herdr 里,命令会显示它的 hc 地址。会话关闭时,终端随之结束。
54
+ **1. Use DSH as usual.** The first time a session runs, the plugin opens a `dsh-<short id>` terminal for it. The terminal's title is the session title and follows renames. The terminal is labeled `dsh.session=<session id>` and `dsh.title=<session title>`. To bring a session that hasn't run yet into Herdr, type `/herdr` in it; the command prints its hc address. When the session closes, its terminal ends.
69
55
 
70
- **2. 从 Herdr 新建一个 DSH 会话。** 下面的 `/repo`、`fix` 都是示例:
56
+ **2. Start a DSH session from Herdr.** `/repo` and `fix` are example values:
71
57
 
72
58
  ```sh
73
- hc run --bg --name fix --cwd /repo dsh "修复 x.rs 里失败的测试"
74
- hc wait fix # 等这一回合结束
75
- hc read fix # 读回复
76
- hc send --wait fix "继续,把边界情况也测了"
59
+ hc run --bg --name fix --cwd /repo dsh "Fix the failing test in x.rs"
60
+ hc wait fix # wait for the turn to finish
61
+ hc read fix # read the reply
62
+ hc send --wait fix "Go on, and cover the edge cases too"
77
63
  ```
78
64
 
79
- 新会话的目录如果正好是 DSH 里已有的 Workspace,就加入它;是某个 Workspace 的子目录,就建成“未分组”会话;其他目录会自动添加为新的 Workspace。
65
+ Where the new session goes depends on its folder. If the folder is exactly one of your DSH workspaces, the session joins that workspace. If it's a subfolder of a workspace, the session is created as "Ungrouped" and keeps that folder. Any other folder is added as a new workspace.
80
66
 
81
- **3. 接管一个已有会话。**
67
+ **3. Take over an existing session.**
82
68
 
83
69
  ```sh
84
- dsh-hc ls --days 7 # 会话 id、标题、目录、是否已在 hc 里
85
- hc run --bg --name login dsh-hc attach 3b729fc9 # 用 id、id 前缀或标题片段
86
- hc read login # 先看到之前的对话,再看到新的
70
+ ~/.dsh/herdr-bridge/bin/dsh-hc ls --days 7 # session id, title, folder, and whether it's already in hc
71
+ hc run --bg --name login ~/.dsh/herdr-bridge/bin/dsh-hc attach 3b729fc9 # by id, id prefix, or part of the title
72
+ hc read login # the earlier conversation first, then anything new
87
73
  ```
88
74
 
89
- 如果这个会话已经有自动映射的 `dsh-<短 id>` 终端,那个终端会退出,由接管的终端代替。
75
+ `dsh-hc` is the small command the plugin creates; it doesn't need to be on your PATH. Herdr agents find its full path in the `dsh` template's description (`hc templates`).
76
+
77
+ If the session already has an automatic `dsh-<short id>` terminal, that terminal exits and the one taking over replaces it.
90
78
 
91
- **4. 需要时取消。** `hc interrupt fix` 取消当前回合,相当于在 DSH 里点停止。
79
+ **4. Cancel when needed.** `hc interrupt fix` cancels the current turn, like pressing stop in DSH.
92
80
 
93
- ## 审批、提问与信任边界
81
+ ## Approvals, questions and trust
94
82
 
95
- 会话等待审批或提问时,DSH 界面照常弹出对话框,hc 终端里也显示同一个问题,状态为 `blocked`。在 hc 侧回答必须加 `--force`:
83
+ When a session waits for an approval or a question, DSH shows its dialog as usual. The hc terminal shows the same question, with status `blocked`.
84
+
85
+ ![Left: the approval dialog in DSH Web. Right: the same session in Herdr App, waiting on the same approval](https://raw.githubusercontent.com/Hariketsu/dsh-herdr-bridge/main/docs/media/approval.webp)
86
+
87
+ Answering from hc needs `--force`:
96
88
 
97
89
  ```sh
98
- hc send --force fix y # 批准一次;n 为拒绝
99
- hc send --force fix 1,3 # 提问:选项序号(多选用逗号),也可以写自由文本,或 - 跳过
90
+ hc send --force fix y # allow once; n rejects
91
+ hc send --force fix 1,3 # a question: option numbers (comma-separated for several), free text, or - to skip
100
92
  ```
101
93
 
102
- 这和 hc 对待其他 Agent 的规则一致:hc 拒绝向 blocked 的程序普通 `send`,`--force` 是唯一的闸门。插件本身从不自动回答,什么时候可以用 `--force`,由驱动它的 Agent 的指令决定(见 `hc --skill`)。
94
+ This is the same rule hc applies to every agent: it refuses a plain `send` to a blocked program, and `--force` is the only way through. The plugin never answers on its own. When `--force` is appropriate is up to the instructions of the agent driving it (see `hc --skill`).
103
95
 
104
- 需要知道的边界:
96
+ Boundaries to know:
105
97
 
106
- - **谁能操控会话**:能使用你的 hc 的进程,都能读取和操控已映射的 DSH 会话,和 hc 里的其他终端一样。插件的控制 socket 在 `~/.dsh/herdr-bridge/control.sock`,只在本机。
107
- - **权限不放大**:hc 发来的消息走 DSH 正常的 prompt 流程,会话原有的沙箱和审批规则照常生效。
108
- - **来自 hc 的输入会成为用户消息**:`hc send` 的文本,以及 helper 的回报和 hc 通知(完成、blocked、退出),都会作为下一条用户消息进入 DSH 会话并唤醒它。hc 发出的消息带有 `<hc …>` 标记,模型能看出来源,但它们和你亲手输入的消息处在同一个位置。
98
+ - **Who can drive a session:** any process that can use your hc can read and drive a mirrored DSH session, as with any other hc terminal. The plugin's control socket is `~/.dsh/herdr-bridge/control.sock`, local to your machine.
99
+ - **No extra permissions:** messages from hc go through DSH's normal prompt path, so the session's sandbox and approval rules still apply.
100
+ - **Input from hc becomes a user message:** text from `hc send`, helper reports and hc notices (finished, blocked, exited) arrive as the next user message in the DSH session and wake it up. Messages from hc carry an `<hc …>` marker, so the model can see where they came from, but they sit in the same place as messages you type.
109
101
 
110
- ## DSH 调度其他 Agent
102
+ ## DSH driving other agents
111
103
 
112
- 插件给已映射的会话注入 `DSH_HC_TERMINAL`(会话自己的 hc 地址),并注册一个 skill `herdr-bridge`,告诉模型这样启动 helper:
104
+ The plugin gives each mirrored session `DSH_HC_TERMINAL`, its own hc address. It also registers a skill, `herdr-bridge`, that tells the model to start helpers like this:
113
105
 
114
106
  ```sh
115
- hc run --bg --parent "$DSH_HC_TERMINAL" --name helper --cwd /repo claude "…完成后用 hc send $DSH_HC_TERMINAL 汇报"
107
+ hc run --bg --parent "$DSH_HC_TERMINAL" --name helper --cwd /repo claude "… when done, report with hc send $DSH_HC_TERMINAL"
116
108
  ```
117
109
 
118
- 通用的 hc 用法来自 `~/.agents/skills/herdr-core`,DSH 会自动发现这个目录。
110
+ General hc usage comes from `~/.agents/skills/herdr-core`, which DSH discovers automatically.
119
111
 
120
- ## 配置
112
+ ## Configuration
121
113
 
122
- 写在 `~/.dsh/profiles/<profile>/cordis.patch.yml` 的 `herdr-bridge` 条目下,全部可选:
114
+ Open **Plugins → Herdr Bridge** in DSH to choose where sessions show in Herdr App; changes apply without a restart. Everything can also be set, all optional, under the `herdr-bridge` entry in `~/.dsh/profiles/<profile>/cordis.patch.yml`:
123
115
 
124
116
  ```yaml
125
117
  - id: herdr-bridge
126
118
  config:
127
- reconnectGraceSeconds: 30 # 插件断开后,终端等待重连的秒数,超时退出(默认 30)
128
- herdrSpace: s3 # 给终端打 herdr.space 标签,在 Herdr App 的这个 space 里显示
129
- hc: /path/to/hc # hc 可执行文件,默认是 PATH 上的 hc
119
+ herdrPlacement: folder # where Herdr App shows sessions started in DSH: folder (default), dsh, or none
120
+ herdrSpace: sp123 # a fixed Herdr App space id; overrides herdrPlacement
121
+ reconnectGraceSeconds: 30 # how long a terminal waits for the plugin to come back before exiting (default 30)
122
+ hc: /path/to/hc # the hc executable; default: hc on PATH
130
123
  ```
131
124
 
132
- ## 故障排查
125
+ Herdr App only shows hc terminals placed in one of its spaces. With `folder`, a session started in DSH joins the Herdr App space that already has a terminal in the same folder; if there is none, Herdr App opens a new space for that folder. With `dsh`, all of them share one space named DSH; Herdr App names new spaces after a folder, so the plugin renames it through Herdr App's own command line, and leaves a name you chose alone. With `none`, they stay out of Herdr App but still show in `hc ls` and other clients. Sessions a Herdr agent starts with `hc run dsh` show under that agent either way.
126
+
127
+ ## Troubleshooting
133
128
 
134
- 先看日志 `~/.dsh/herdr-bridge/bridge.log`。
129
+ Start with the log, `~/.dsh/herdr-bridge/bridge.log`.
135
130
 
136
- | 现象 | 检查 |
131
+ | Symptom | Check |
137
132
  |---|---|
138
- | DSH 里在用的会话,`hc ls` 里没有 | 日志里有没有插件加载和开终端失败的记录;DSH 进程能否找到 `hc`(Desktop 用登录 shell 的 PATH,`dsh web` 用启动它的 shell 的 PATH;找不到就在配置里设 `hc`);是否同时开着 Desktop 和 `dsh web`(见已知限制) |
139
- | `dsh-hc: command not found` | PATH 没加好;直接用 `~/.dsh/herdr-bridge/bin/dsh-hc`。这个文件不存在说明插件没加载 |
140
- | `hc run dsh …` 提示找不到 template | 执行 `dsh-hc install-template` |
141
- | 终端显示“断开 → 重新连接” | 插件重载了(比如你在 DSH 里改了设置),正常现象,地址不变 |
142
- | `dsh-*` 终端突然退出 | DSH 退出或插件超过 `reconnectGraceSeconds` 没回来;重开 DSH 后,会话下次运行时会重新映射 |
143
- | 终端报协议版本不符 | 插件升级后旧终端还在跑旧代理;关掉终端重新 `attach` |
144
- | 升级 DSH 后插件不工作 | DSH 接口有变化,暂时降级 DSH 或等插件更新,并把日志附在 issue 里 |
133
+ | A session you started in DSH shows in `hc ls` but not in Herdr App | Check `herdrPlacement` isn't `none`. Herdr App only shows terminals placed in a space; terminals from an older plugin version are placed when they reconnect |
134
+ | A session you're using in DSH isn't in `hc ls` | The log, for plugin load or terminal start failures. Whether DSH can find `hc`: Desktop uses your login shell's PATH and `dsh web` uses the PATH of the shell that started it; if needed, set `hc` in the config. Whether Desktop and `dsh web` are both running (see Known limitations) |
135
+ | `hc run dsh …` can't find the template, or `~/.dsh/herdr-bridge/bin/dsh-hc` doesn't exist | The plugin didn't load; check the log. A `dsh` template you wrote yourself is never replaced; delete it to get ours, or run `~/.dsh/herdr-bridge/bin/dsh-hc install-template` |
136
+ | DSH sessions show a generic robot icon in Herdr App | Herdr App had never been started when the plugin loaded: restart DSH, or run `~/.dsh/herdr-bridge/bin/dsh-hc install-template`. A `dsh` entry already in `programs.json` is kept |
137
+ | The terminal shows "disconnected → reconnected" | The plugin reloaded (for example, you changed a setting in DSH). This is expected, and the address stays the same |
138
+ | A `dsh-*` terminal exits suddenly | DSH quit, or the plugin didn't come back within `reconnectGraceSeconds`. Reopen DSH; the session is mirrored again the next time it runs |
139
+ | A terminal reports a protocol version mismatch | It's still running the proxy from before a plugin upgrade. Close it and `attach` again |
140
+ | The plugin stops working after a DSH upgrade | The DSH interfaces changed. Downgrade DSH for now or wait for a plugin update, and attach the log to an issue |
145
141
 
146
- ## 已知限制
142
+ ## Known limitations
147
143
 
148
- - **Desktop 和 `dsh web` 不能同时接入**:两者共用 `~/.dsh/herdr-bridge/`,只有先启动的那个能正常接入 Herdr。
149
- - **DSH 里看过的回合,Perch 仍显示“完成未看”**:Herdr 不知道 DSH 界面也是一个观看者。
150
- - **插件重载后,空闲会话从 `done` 变成 `idle`**:上一回合的结论丢失。Perch 的“完成未看”按回合数判断,不受影响。
151
- - **每个映射出去的会话有一个代理进程**,实际物理内存约 13–14 MB。
152
- - **插件升级后,已开着的终端仍运行旧代理**,直到重开。
153
- - **审批的“另一边撤回”依赖 DSH 的内部实现**:DSH 改动后可能失效,表现是 DSH 界面的对话框不消失,不会导致错误批准。
144
+ - **Desktop and `dsh web` can't both be connected:** they share `~/.dsh/herdr-bridge/`, and only the one started first connects to Herdr.
145
+ - **Turns you've read in DSH still show as unseen in Herdr:** Herdr doesn't know the DSH window is also a viewer.
146
+ - **Each mirrored session runs a proxy process**, using about 13–14 MB of physical memory.
147
+ - **After a plugin upgrade, open terminals keep the old proxy** until they're reopened.
148
+ - **Withdrawing the other side's dialog relies on DSH internals:** if a DSH change breaks it, the DSH dialog stays open. It never causes a wrong approval.
154
149
 
155
- 计划和未验证的路径见 [TODO.md](TODO.md)。
150
+ Plans and untested paths are in [TODO.md](TODO.md).
156
151
 
157
- ## 工作原理
152
+ ## How it works
158
153
 
159
- 每个 hc 终端里跑一个很薄的代理 `dsh-hc`(用运行 DSH 的同一个 Node,Desktop 上就是它自带的那个),通过本机 socket 连到 DSH 进程里的插件。插件把 DSH 事件渲染成终端文本和 OSC 7501 状态,把终端输入转成 prompt、取消或审批回答。插件重载时代理会重连,hc 地址不变。
154
+ Each hc terminal runs a thin proxy, `dsh-hc`, on the same Node that runs DSH (on Desktop, the one bundled with it). It connects over a local socket to the plugin inside the DSH process. The plugin renders DSH events as terminal text and OSC 7501 status, and turns terminal input into prompts, cancels or approval answers. When the plugin reloads, the proxy reconnects and the hc address stays the same.
160
155
 
161
- 架构、状态映射、竞争应答的实现、被否决的方案和测试覆盖,见 [docs/architecture.md](docs/architecture.md)。
156
+ The architecture, status mapping, how racing answers work, rejected approaches and test coverage are in [docs/architecture.md](docs/architecture.md) (in Chinese).
162
157
 
163
- ## 开发
158
+ ## Development
164
159
 
165
- 从源码安装、开发回路和隔离测试环境见 [docs/development.md](docs/development.md),Agent 的工作约定见 [AGENTS.md](AGENTS.md)。简述:
160
+ Installing from source, the dev loop and the isolated test environment are in [docs/development.md](docs/development.md) (in Chinese); how agents work on this repo is in [AGENTS.md](AGENTS.md). In short:
166
161
 
167
162
  ```sh
168
- scripts/dev.sh up # 隔离环境:模拟 LLM + 隔离 hc core + 隔离 dsh web(Desktop 替身)
169
- scripts/dev.sh reload # 热重载插件
170
- scripts/e2e.sh # 32 项 hc 侧回归
171
- scripts/e2e-ui.sh # 12 项 UI 侧回归(ego-browser)
163
+ scripts/dev.sh up # isolated world: mock LLM + isolated hc core + isolated dsh web (Desktop stand-in)
164
+ scripts/dev.sh reload # hot-reload the plugin
165
+ scripts/e2e.sh # 39 hc-side checks
166
+ scripts/e2e-ui.sh # 14 UI-side checks (ego-browser)
172
167
  scripts/dev.sh down
173
- scripts/desktop.sh reload # 把改动热重载进真实 Desktop(只改 cordis.patch.yml 里 herdr-bridge 这一项,首次会备份)
168
+ scripts/desktop.sh reload # hot-reload changes into your real Desktop (only touches the herdr-bridge entry in cordis.patch.yml; backs it up the first time)
169
+ scripts/demo-media.py # regenerate the README media from a scripted, fully isolated demo
174
170
  ```
175
171
 
176
- ## 许可
172
+ ## License
177
173
 
178
- MIT
174
+ MIT. `assets/dsh.svg` is the DeepSeek Harness logo, taken from [deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) (MIT, Copyright (c) 2026 DeepSeek). The DeepSeek name and logo belong to their owner.
@@ -0,0 +1,173 @@
1
+ # dsh-herdr-bridge
2
+
3
+ [English](README.md) | 简体中文
4
+
5
+ 一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)插件,把 DSH 的会话接进 [Herdr Core](https://github.com/herdrdev/herdr-core)(`hc`):每个会话变成一个 hc 终端,Herdr 里的其他 Agent 可以像操控 Claude Code 一样读取和操控它。适用于 DSH Desktop 和 DSH Web(`dsh web`)。
6
+
7
+ 装好以后:
8
+
9
+ - **在 Herdr 里看到 DSH 会话**:会话一开始运行,[Herdr App](https://github.com/herdrdev/herdr-app) 里就出现一个 `dsh-<短 id>` 终端,状态在 `working / done / blocked / error` 之间跟着变。
10
+ - **从 Herdr 操控 DSH**:Herdr Agent 用 `hc run / send / wait / read / interrupt` 新建会话、接管已有会话、发消息、取消回合。
11
+ - **让 DSH 调度其他 Agent**:DSH 里的模型可以用 `hc` 启动 helper(比如 Claude Code),helper 的回报会送回这个 DSH 会话。
12
+ - **审批两边都能答**:会话等待审批或提问时,在 DSH 界面或 hc 里回答都行,先答者生效,另一边的对话框会撤回。
13
+
14
+ 插件运行在 DSH 自己的进程里,不需要第二份 DSH,也不修改 DSH、Herdr 的任何源码。
15
+
16
+ ## 效果
17
+
18
+ ![Herdr App 里的三个 DSH 会话:一个等待审批,一个正在工作,一个已完成;右侧是已完成会话的对话](https://raw.githubusercontent.com/Hariketsu/dsh-herdr-bridge/main/docs/media/herdr-app.webp)
19
+
20
+ Herdr App 里的三个 DSH 会话,状态各不相同。右侧是其中一个会话在 hc 终端里的样子:提示、回复、带描述的工具调用和回合用时,格式和原生 TUI Agent 一样。
21
+
22
+ ## 要求
23
+
24
+ | | 开发时使用的版本 | 说明 |
25
+ |---|---|---|
26
+ | DSH(Desktop 或 `dsh web`) | 0.2.2-rc.1 | 插件依赖的 DSH 接口仍在变化,升级 DSH 后可能需要更新插件。`dsh headless` 不支持,`dsh tui` 没有测试过 |
27
+ | herdr-core(`hc`) | 0.1.18 | `hc` 需要在登录 shell 的 PATH 上,或在配置里指定 |
28
+ | herdr-app | 0.1.13 | 可选 |
29
+
30
+ Desktop 和 `dsh web` 同一时间只能有一个接入 Herdr(见已知限制)。
31
+
32
+ 平台:只在 macOS 上验证过。Windows / Linux 的代码路径已写好,但没有测试过。暂无最低版本要求,只保证上表这一组合。
33
+
34
+ ## 安装
35
+
36
+ 1. 装进你使用的 DSH profile。Desktop 用 `desktop`,要先完全退出 Desktop(⌘Q);Web 用 `web`,要先停掉 `dsh web`:
37
+ ```sh
38
+ dsh plugin --profile desktop add dsh-herdr-bridge # 或 --profile web
39
+ ```
40
+ 2. 重新打开 Desktop 或启动 `dsh web`。这就装好了:插件加载时会自己安装 hc template,Herdr Agent 就能用 `hc run dsh "任务"`;如果你用 Herdr App,还会在它那里加上 DSH 的名字和图标。
41
+
42
+ **确认安装成功**:
43
+
44
+ ```sh
45
+ hc templates # 能看到 dsh
46
+ ```
47
+
48
+ 然后在 DSH 里随便发一条消息,`hc ls` 里应该出现 `dsh-<短 id>` 终端。
49
+
50
+ **卸载**:退出 Desktop 或停掉 `dsh web`,执行 `dsh plugin --profile <profile> remove dsh-herdr-bridge`;如果你在 `~/.dsh/profiles/<profile>/cordis.patch.yml` 里加过 `herdr-bridge` 配置,一并删掉。再删除 `~/.config/herdr-core/templates/dsh.md`、`~/.dsh/herdr-bridge/`,以及 `~/.config/herdr-app/programs.json` 里的 `dsh` 条目和 `~/.config/herdr-app/icons/dsh-herdr-bridge.svg`。已经开着的 `dsh-*` 终端会在宽限期后自行退出。
51
+
52
+ ## 快速上手
53
+
54
+ **1. 在 DSH 里正常使用。** 会话第一次运行时,插件为它开一个 `dsh-<短 id>` 终端,终端标题就是会话标题(随改名同步),另带标签 `dsh.session=<会话 id>` 和 `dsh.title=<会话标题>`。还没运行过的会话,在 DSH 里对它输入 `/herdr`,它就会出现在 Herdr 里,命令会显示它的 hc 地址。会话关闭时,终端随之结束。
55
+
56
+ **2. 从 Herdr 新建一个 DSH 会话。** 下面的 `/repo`、`fix` 都是示例:
57
+
58
+ ```sh
59
+ hc run --bg --name fix --cwd /repo dsh "修复 x.rs 里失败的测试"
60
+ hc wait fix # 等这一回合结束
61
+ hc read fix # 读回复
62
+ hc send --wait fix "继续,把边界情况也测了"
63
+ ```
64
+
65
+ 新会话的目录如果正好是 DSH 里已有的 Workspace,就加入它;是某个 Workspace 的子目录,就建成“未分组”会话;其他目录会自动添加为新的 Workspace。
66
+
67
+ **3. 接管一个已有会话。**
68
+
69
+ ```sh
70
+ ~/.dsh/herdr-bridge/bin/dsh-hc ls --days 7 # 会话 id、标题、目录、是否已在 hc 里
71
+ hc run --bg --name login ~/.dsh/herdr-bridge/bin/dsh-hc attach 3b729fc9 # 用 id、id 前缀或标题片段
72
+ hc read login # 先看到之前的对话,再看到新的
73
+ ```
74
+
75
+ `dsh-hc` 是插件生成的一个小命令,不需要加进 PATH。Herdr Agent 从 `dsh` template 的描述里(`hc templates`)就能看到它的完整路径。
76
+
77
+ 如果这个会话已经有自动映射的 `dsh-<短 id>` 终端,那个终端会退出,由接管的终端代替。
78
+
79
+ **4. 需要时取消。** `hc interrupt fix` 取消当前回合,相当于在 DSH 里点停止。
80
+
81
+ ## 审批、提问与信任边界
82
+
83
+ 会话等待审批或提问时,DSH 界面照常弹出对话框,hc 终端里也显示同一个问题,状态为 `blocked`。
84
+
85
+ ![左边是 DSH Web 里的审批对话框,右边是 Herdr App 里同一个会话在等待同一个审批](https://raw.githubusercontent.com/Hariketsu/dsh-herdr-bridge/main/docs/media/approval.webp)
86
+
87
+ 在 hc 侧回答必须加 `--force`:
88
+
89
+ ```sh
90
+ hc send --force fix y # 批准一次;n 为拒绝
91
+ hc send --force fix 1,3 # 提问:选项序号(多选用逗号),也可以写自由文本,或 - 跳过
92
+ ```
93
+
94
+ 这和 hc 对待其他 Agent 的规则一致:hc 拒绝向 blocked 的程序普通 `send`,`--force` 是唯一的闸门。插件本身从不自动回答,什么时候可以用 `--force`,由驱动它的 Agent 的指令决定(见 `hc --skill`)。
95
+
96
+ 需要知道的边界:
97
+
98
+ - **谁能操控会话**:能使用你的 hc 的进程,都能读取和操控已映射的 DSH 会话,和 hc 里的其他终端一样。插件的控制 socket 在 `~/.dsh/herdr-bridge/control.sock`,只在本机。
99
+ - **权限不放大**:hc 发来的消息走 DSH 正常的 prompt 流程,会话原有的沙箱和审批规则照常生效。
100
+ - **来自 hc 的输入会成为用户消息**:`hc send` 的文本,以及 helper 的回报和 hc 通知(完成、blocked、退出),都会作为下一条用户消息进入 DSH 会话并唤醒它。hc 发出的消息带有 `<hc …>` 标记,模型能看出来源,但它们和你亲手输入的消息处在同一个位置。
101
+
102
+ ## DSH 调度其他 Agent
103
+
104
+ 插件给已映射的会话注入 `DSH_HC_TERMINAL`(会话自己的 hc 地址),并注册一个 skill `herdr-bridge`,告诉模型这样启动 helper:
105
+
106
+ ```sh
107
+ hc run --bg --parent "$DSH_HC_TERMINAL" --name helper --cwd /repo claude "…完成后用 hc send $DSH_HC_TERMINAL 汇报"
108
+ ```
109
+
110
+ 通用的 hc 用法来自 `~/.agents/skills/herdr-core`,DSH 会自动发现这个目录。
111
+
112
+ ## 配置
113
+
114
+ 在 DSH 里打开 **插件 → Herdr 桥接**,就能在插件页上直接选择会话在 Herdr App 里放在哪里,改完不用重启就生效。所有设置也都可以写在 `~/.dsh/profiles/<profile>/cordis.patch.yml` 的 `herdr-bridge` 条目下,全部可选:
115
+
116
+ ```yaml
117
+ - id: herdr-bridge
118
+ config:
119
+ herdrPlacement: folder # 在 DSH 里发起的会话放进 Herdr App 的哪个 space:folder(默认)、dsh 或 none
120
+ herdrSpace: sp123 # 固定的 Herdr App space id;填了就以它为准
121
+ reconnectGraceSeconds: 30 # 插件断开后,终端等待重连的秒数,超时退出(默认 30)
122
+ hc: /path/to/hc # hc 可执行文件,默认是 PATH 上的 hc
123
+ ```
124
+
125
+ Herdr App 只显示放进它某个 space 的 hc 终端。`folder`:在 DSH 里发起的会话,加入 Herdr App 里已经有终端在同一文件夹的那个 space;没有的话,Herdr App 为这个文件夹新开一个 space。`dsh`:所有会话共用一个名为 DSH 的 space。Herdr App 新建 space 时会用文件夹名命名,所以插件会通过 Herdr App 自己的命令行把它改名为 DSH;你自己改过的名字不会被覆盖。`none`:不放进 Herdr App,但 `hc ls` 和其他客户端照样能看到。Herdr Agent 用 `hc run dsh` 发起的会话,无论哪种设置,都显示在那个 Agent 下面。
126
+
127
+ ## 故障排查
128
+
129
+ 先看日志 `~/.dsh/herdr-bridge/bridge.log`。
130
+
131
+ | 现象 | 检查 |
132
+ |---|---|
133
+ | 在 DSH 里发起的会话 `hc ls` 里有,但 Herdr App 里没有 | 检查 `herdrPlacement` 不是 `none`。Herdr App 只显示放进 space 的终端;旧版插件开出的终端会在重连时补上 |
134
+ | DSH 里在用的会话,`hc ls` 里没有 | 日志里有没有插件加载和开终端失败的记录;DSH 进程能否找到 `hc`(Desktop 用登录 shell 的 PATH,`dsh web` 用启动它的 shell 的 PATH;找不到就在配置里设 `hc`);是否同时开着 Desktop 和 `dsh web`(见已知限制) |
135
+ | `hc run dsh …` 提示找不到 template,或 `~/.dsh/herdr-bridge/bin/dsh-hc` 不存在 | 插件没加载,看日志。你自己写的 `dsh` template 不会被替换;删掉它就会换成插件的,或执行 `~/.dsh/herdr-bridge/bin/dsh-hc install-template` |
136
+ | Herdr App 里 DSH 会话显示通用的机器人图标 | 插件加载时 Herdr App 还从没启动过:重启 DSH,或执行 `~/.dsh/herdr-bridge/bin/dsh-hc install-template`。`programs.json` 里已有的 `dsh` 条目会保留 |
137
+ | 终端显示“断开 → 重新连接” | 插件重载了(比如你在 DSH 里改了设置),正常现象,地址不变 |
138
+ | `dsh-*` 终端突然退出 | DSH 退出或插件超过 `reconnectGraceSeconds` 没回来;重开 DSH 后,会话下次运行时会重新映射 |
139
+ | 终端报协议版本不符 | 插件升级后旧终端还在跑旧代理;关掉终端重新 `attach` |
140
+ | 升级 DSH 后插件不工作 | DSH 接口有变化,暂时降级 DSH 或等插件更新,并把日志附在 issue 里 |
141
+
142
+ ## 已知限制
143
+
144
+ - **Desktop 和 `dsh web` 不能同时接入**:两者共用 `~/.dsh/herdr-bridge/`,只有先启动的那个能正常接入 Herdr。
145
+ - **在 DSH 里看过的回合,Herdr 仍会标成没看过**:Herdr 不知道 DSH 界面也是一个观看者。
146
+ - **每个映射出去的会话有一个代理进程**,实际物理内存约 13–14 MB。
147
+ - **插件升级后,已开着的终端仍运行旧代理**,直到重开。
148
+ - **审批的“另一边撤回”依赖 DSH 的内部实现**:DSH 改动后可能失效,表现是 DSH 界面的对话框不消失,不会导致错误批准。
149
+
150
+ 计划和未验证的路径见 [TODO.md](TODO.md)。
151
+
152
+ ## 工作原理
153
+
154
+ 每个 hc 终端里跑一个很薄的代理 `dsh-hc`(用运行 DSH 的同一个 Node,Desktop 上就是它自带的那个),通过本机 socket 连到 DSH 进程里的插件。插件把 DSH 事件渲染成终端文本和 OSC 7501 状态,把终端输入转成 prompt、取消或审批回答。插件重载时代理会重连,hc 地址不变。
155
+
156
+ 架构、状态映射、竞争应答的实现、被否决的方案和测试覆盖,见 [docs/architecture.md](docs/architecture.md)。
157
+
158
+ ## 开发
159
+
160
+ 从源码安装、开发回路和隔离测试环境见 [docs/development.md](docs/development.md),Agent 的工作约定见 [AGENTS.md](AGENTS.md)。简述:
161
+
162
+ ```sh
163
+ scripts/dev.sh up # 隔离环境:模拟 LLM + 隔离 hc core + 隔离 dsh web(Desktop 替身)
164
+ scripts/dev.sh reload # 热重载插件
165
+ scripts/e2e.sh # 39 项 hc 侧回归
166
+ scripts/e2e-ui.sh # 14 项 UI 侧回归(ego-browser)
167
+ scripts/dev.sh down
168
+ scripts/desktop.sh reload # 把改动热重载进真实 Desktop(只改 cordis.patch.yml 里 herdr-bridge 这一项,首次会备份)
169
+ ```
170
+
171
+ ## 许可
172
+
173
+ MIT。`assets/dsh.svg` 是 DeepSeek Harness 的标志,取自 [deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)(MIT,Copyright (c) 2026 DeepSeek);DeepSeek 的名称和标志归其所有者。
package/assets/dsh.svg ADDED
@@ -0,0 +1,2 @@
1
+ <!-- DeepSeek Harness logo, from deepseek-ai/deepseek-harness packages/client/ui-primitives/src/FishLogo.tsx (MIT License, Copyright (c) 2026 DeepSeek). The DeepSeek name and logo belong to their owner. -->
2
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -3.06 23.16 23.16"><path d="M22.9168 1.43018C22.6713 1.31018 22.5658 1.53918 22.4223 1.65519C22.3733 1.69269 22.3318 1.74169 22.2903 1.78669C21.9317 2.1697 21.5127 2.42121 20.9657 2.39121C20.1657 2.34621 19.4827 2.59771 18.8787 3.20973C18.7502 2.45521 18.3236 2.0047 17.6746 1.71569C17.3351 1.56568 16.9916 1.41518 16.7536 1.08867C16.5876 0.856163 16.5421 0.597155 16.4591 0.341647C16.4061 0.187643 16.3536 0.0301382 16.1761 0.00363739C15.9836 -0.0263635 15.9081 0.135141 15.8326 0.270145C15.5306 0.822162 15.4136 1.43018 15.4251 2.0462C15.4516 3.43174 16.0366 4.53527 17.1991 5.3203C17.3311 5.4103 17.3651 5.5003 17.3236 5.63181C17.2441 5.90231 17.1501 6.16482 17.0671 6.43533C17.0141 6.60784 16.9351 6.64584 16.7501 6.57033C16.1121 6.30383 15.5611 5.90931 15.074 5.4328C14.2475 4.63328 13.5 3.75075 12.568 3.05973C12.349 2.89822 12.13 2.74822 11.9034 2.60522C10.9524 1.68169 12.028 0.923165 12.277 0.833162C12.5375 0.739159 12.3675 0.41615 11.5259 0.42015C10.6844 0.42365 9.91439 0.705658 8.93286 1.08117C8.78935 1.13767 8.63835 1.17867 8.48384 1.21267C7.59332 1.04367 6.66829 1.00617 5.70226 1.11517C3.88321 1.31768 2.43016 2.1777 1.36213 3.64575C0.0790928 5.4103 -0.222916 7.41536 0.146595 9.50642C0.535106 11.7105 1.66014 13.535 3.38869 14.9616C5.18125 16.4406 7.24581 17.1657 9.60138 17.0266C11.0319 16.9441 12.6245 16.7526 14.421 15.2321C14.874 15.4576 15.3496 15.5476 16.1381 15.6151C16.7456 15.6716 17.3306 15.5851 17.7836 15.4911C18.4931 15.3411 18.4441 14.6841 18.1876 14.5636C16.1081 13.595 16.5646 13.9891 16.1496 13.67C17.2061 12.42 18.8202 10.1979 19.3182 7.17235C19.3672 6.83834 19.4297 6.36783 19.4222 6.09732C19.4182 5.93231 19.4562 5.86831 19.6447 5.84931C20.1657 5.78931 20.6712 5.64681 21.1357 5.3913C22.4833 4.65528 23.0268 3.44624 23.1548 1.9972C23.1738 1.77569 23.1508 1.54668 22.9168 1.43018ZM11.1749 14.4736C9.15936 12.889 8.18184 12.3675 7.77832 12.39C7.40081 12.4125 7.46881 12.8445 7.55182 13.126C7.63882 13.404 7.75182 13.5955 7.91033 13.8396C8.01983 14.0011 8.09533 14.2411 7.80083 14.4216C7.15181 14.8231 6.02327 14.2866 5.97027 14.2601C4.65673 13.4865 3.5587 12.4655 2.78467 11.069C2.03715 9.72493 1.60314 8.28289 1.53164 6.74384C1.51264 6.37233 1.62214 6.24082 1.99215 6.17332C2.47916 6.08332 2.98118 6.06432 3.46769 6.13582C5.52476 6.43633 7.27581 7.35586 8.74385 8.8129C9.58188 9.64243 10.2159 10.634 10.8689 11.6025C11.5634 12.631 12.3105 13.611 13.262 14.4146C13.598 14.6961 13.866 14.9101 14.1225 15.0681C13.349 15.1546 12.058 15.1731 11.1749 14.4746L11.1749 14.4736ZM12.141 8.25988C12.141 8.09488 12.273 7.96338 12.439 7.96338C12.4765 7.96338 12.5105 7.97088 12.541 7.98188C12.5825 7.99688 12.6205 8.01938 12.6505 8.05338C12.7035 8.10588 12.7335 8.18088 12.7335 8.25988C12.7335 8.42489 12.6015 8.55639 12.4355 8.55639C12.2695 8.55639 12.141 8.42489 12.141 8.25988ZM15.1415 9.79893C14.949 9.87793 14.7565 9.94544 14.5715 9.95294C14.2845 9.96794 13.9715 9.85143 13.8015 9.70893C13.5375 9.48742 13.3485 9.36342 13.2695 8.97691C13.2355 8.8119 13.2545 8.55639 13.2845 8.40989C13.3525 8.09438 13.277 7.89187 13.0545 7.70787C12.8735 7.55786 12.643 7.51636 12.39 7.51636C12.2955 7.51636 12.209 7.47486 12.1445 7.44136C12.039 7.38886 11.9519 7.25735 12.035 7.09585C12.0615 7.04335 12.19 6.91584 12.22 6.89334C12.5635 6.69784 12.9595 6.76184 13.326 6.90834C13.6655 7.04735 13.9225 7.30236 14.292 7.66287C14.6695 8.09838 14.7375 8.21838 14.9525 8.54539C15.1225 8.8009 15.277 9.06341 15.3831 9.36392C15.4471 9.55142 15.3641 9.70493 15.1415 9.79893Z" fill="currentColor"/></svg>
package/client.js ADDED
@@ -0,0 +1,151 @@
1
+ // dsh-herdr-bridge, browser half: this plugin's settings, shown on its own page under Plugins.
2
+ // Hand-written in the client module system's lazy-CJS factory format (the format DSH's own tsdown
3
+ // preset emits), so the package needs no build. It edits the volatile fields of the plugin's
4
+ // Config (`herdrPlacement`, `herdrSpace`) through the Host's settings form for the `herdr-bridge`
5
+ // profile entry; the Host validates and persists them, and the plugin reads them live.
6
+ window.__ModuleLoader__.load({
7
+ id: "dsh-herdr-bridge",
8
+ factory: (require) => {
9
+ var module = { exports: {} };
10
+ var exports = module.exports;
11
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
12
+ const { createElement: h, useId } = require("react");
13
+ const { Button, SettingsForm, SettingsFormModel, SettingsValueField, Tag, settingsTextField } = require("@deepseek-ai/dsh-client-ui-primitives");
14
+
15
+ /** Dictionary namespace owned by this page. */
16
+ const NS = "herdrBridge";
17
+ /** Settings namespace: the plugin's profile entry id. */
18
+ const SETTINGS = "herdr-bridge";
19
+ /** Plugins page key of this bundle: its package name. */
20
+ const BUNDLE = "dsh-herdr-bridge";
21
+ const PLACEMENTS = ["folder", "dsh", "none"];
22
+
23
+ const en = {
24
+ placement: "Where Herdr App shows sessions started in DSH",
25
+ folder: "By folder",
26
+ folderHint: "A session joins the Herdr App space that already has a terminal in its folder, or opens a new space for that folder.",
27
+ dsh: "One space for all DSH sessions",
28
+ dshHint: "All sessions share one space, named DSH. Rename it in Herdr App if you like; the new name is kept.",
29
+ none: "Not in Herdr App",
30
+ noneHint: "Sessions stay out of Herdr App, but still show in hc and other Herdr clients.",
31
+ space: "Fixed space id",
32
+ spaceHint: "A Herdr App space id. When set, it replaces the choice above.",
33
+ applies: "Applies to terminals opened after you save; open ones stay where they are.",
34
+ overridden: "Custom",
35
+ reset: "Reset",
36
+ invalid: "Not a valid value",
37
+ unavailable: "This profile doesn't expose herdr-bridge settings.",
38
+ readOnly: "This profile's configuration is read-only.",
39
+ saveFailed: "Couldn't save. Try again.",
40
+ save: "Save",
41
+ saving: "Saving…",
42
+ };
43
+ const zh = {
44
+ placement: "在 DSH 里发起的会话放在 Herdr App 的哪里",
45
+ folder: "按文件夹归位",
46
+ folderHint: "加入 Herdr App 里已经有终端在同一文件夹的那个 space;没有的话,为这个文件夹新开一个 space。",
47
+ dsh: "统一放进一个 DSH space",
48
+ dshHint: "所有会话共用一个名为 DSH 的 space。你可以在 Herdr App 里给它改名,改过的名字会保留。",
49
+ none: "不放进 Herdr App",
50
+ noneHint: "会话不出现在 Herdr App 里,但 hc 和其他 Herdr 客户端照样能看到。",
51
+ space: "固定 space id",
52
+ spaceHint: "Herdr App 的 space id。填了就以它为准,忽略上面的选择。",
53
+ applies: "保存后新开的终端按新设置放置;已经开着的终端位置不变。",
54
+ overridden: "已自定义",
55
+ reset: "重置",
56
+ invalid: "不是有效的值",
57
+ unavailable: "当前 profile 没有暴露 herdr-bridge 的设置。",
58
+ readOnly: "当前 profile 的配置是只读的。",
59
+ saveFailed: "保存失败,请重试。",
60
+ save: "保存",
61
+ saving: "保存中…",
62
+ };
63
+
64
+ const css = {
65
+ field: { display: "flex", flexDirection: "column", gap: "6px", padding: "8px 0" },
66
+ toolbar: { display: "flex", alignItems: "center", gap: "8px" },
67
+ label: { fontSize: "13px", fontWeight: "600", color: "var(--dsw-alias-label-primary)" },
68
+ select: {
69
+ height: "34px", padding: "0 10px", maxWidth: "360px",
70
+ border: "0.5px solid var(--dsw-alias-border-l4)", borderRadius: "var(--dsw-radius-md)",
71
+ background: "var(--dsw-alias-bg-layer-3)", color: "var(--dsw-alias-label-primary)", font: "inherit", fontSize: "13px",
72
+ },
73
+ hint: { margin: 0, fontSize: "12px", lineHeight: "1.6", color: "var(--dsw-alias-label-secondary)" },
74
+ };
75
+
76
+ /** Staged edits of the two placement settings over the Host's form for this profile entry. */
77
+ function placementForm(scope) {
78
+ const form = new SettingsFormModel(scope, [{
79
+ ...settingsTextField("herdrPlacement"),
80
+ parse: (text) => {
81
+ const value = text.trim();
82
+ if (value === "") return { kind: "clear" };
83
+ return PLACEMENTS.includes(value) ? { kind: "set", value } : undefined;
84
+ },
85
+ }, settingsTextField("herdrSpace")]);
86
+ const store = form.bind(() => ({
87
+ ...form.shell(),
88
+ herdrPlacement: form.field("herdrPlacement"),
89
+ herdrSpace: form.field("herdrSpace"),
90
+ }));
91
+ return { face: { hooks: { herdrBridgeForm: store }, ...form.actions() }, dispose: () => { form.dispose(); } };
92
+ }
93
+
94
+ /** The page: placement choice, optional fixed space id, and the shared save footer. */
95
+ function SettingsPage(props) {
96
+ const t = props.t;
97
+ const state = props.useHerdrBridgeForm((snapshot) => snapshot);
98
+ const id = useId();
99
+ const disabled = !state.writable || state.saving;
100
+ const placement = PLACEMENTS.includes(state.herdrPlacement.text) ? state.herdrPlacement.text : "folder";
101
+ return h(SettingsForm, {
102
+ labels: { unavailable: t("unavailable"), readOnly: t("readOnly"), saveFailed: t("saveFailed"), save: t("save"), saving: t("saving") },
103
+ state,
104
+ onSave: props.save,
105
+ onDiscard: props.discard,
106
+ },
107
+ h("div", { style: css.field },
108
+ h("div", { style: css.toolbar },
109
+ h("label", { htmlFor: `${id}-placement`, style: css.label }, t("placement")),
110
+ state.herdrPlacement.overridden ? h(Tag, { tone: "neutral" }, t("overridden")) : null,
111
+ state.herdrPlacement.overridden
112
+ ? h(Button, { size: "sm", disabled, onClick: () => { props.resetField("herdrPlacement"); } }, t("reset"))
113
+ : null,
114
+ ),
115
+ h("select", {
116
+ id: `${id}-placement`, value: placement, disabled, style: css.select,
117
+ "aria-invalid": state.herdrPlacement.invalid || undefined,
118
+ onChange: (event) => { props.edit("herdrPlacement", event.target.value); },
119
+ }, PLACEMENTS.map((value) => h("option", { key: value, value }, t(value)))),
120
+ h("p", { style: css.hint }, t(`${placement}Hint`)),
121
+ ),
122
+ h(SettingsValueField, {
123
+ id: `${id}-space`, label: t("space"), hint: t("spaceHint"), ...state.herdrSpace,
124
+ overriddenLabel: t("overridden"), resetLabel: t("reset"), invalidLabel: t("invalid"), disabled,
125
+ onEdit: (text) => { props.edit("herdrSpace", text); },
126
+ onReset: () => { props.resetField("herdrSpace"); },
127
+ }),
128
+ h("p", { style: css.hint }, t("applies")),
129
+ );
130
+ }
131
+
132
+ const inject = ["slots", "locale", "configForms"];
133
+
134
+ /** Register the dictionaries and, while the Host serves the namespace, the settings on the bundle's page (between its detail and its components). */
135
+ function apply(ctx) {
136
+ ctx.effect(() => ctx.locale.register(NS, { en, zh }), "herdr-bridge: dictionaries");
137
+ const form = placementForm(ctx.configForms.get(SETTINGS));
138
+ ctx.effect(() => () => { form.dispose(); }, "herdr-bridge: settings form");
139
+ ctx.effect(() => ctx.configForms.whileServed([SETTINGS], () => ctx.slots.inject("plugins.bundle.config", () => ctx.slots.register({
140
+ name: "plugins.bundle.config",
141
+ key: BUNDLE,
142
+ locale: NS,
143
+ inject: () => form.face,
144
+ }, SettingsPage))), "herdr-bridge: settings page");
145
+ }
146
+
147
+ exports.apply = apply;
148
+ exports.inject = inject;
149
+ return module.exports;
150
+ }
151
+ });
package/docs/DETAIL.md ADDED
@@ -0,0 +1,3 @@
1
+ Once a DSH session runs, it's mirrored as an hc terminal automatically. Choose which Herdr App space it goes to below.
2
+
3
+ There's nothing else to set up. More in the [full guide](https://github.com/Hariketsu/dsh-herdr-bridge#readme).
@@ -0,0 +1,3 @@
1
+ DSH 会话运行后会自动映射为 hc 终端。你可以在下方设置它们在 Herdr App 中的空间归属。
2
+
3
+ 装好就能用,不需要别的准备。更多说明见[完整文档](https://github.com/Hariketsu/dsh-herdr-bridge/blob/main/README.zh-CN.md)。
package/index.js CHANGED
@@ -6,15 +6,28 @@
6
6
  // See README.md for the design.
7
7
  import net from 'node:net'
8
8
  import { execFile } from 'node:child_process'
9
- import { appendFileSync, chmodSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
9
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
10
10
  import { basename, join, sep } from 'node:path'
11
11
  import { homedir } from 'node:os'
12
12
  import { createHash, randomUUID } from 'node:crypto'
13
13
  import { fileURLToPath } from 'node:url'
14
- import { Renderer, crlf, oneLine, osc, style } from './render.js'
14
+ import { Renderer, crlf, oneLine, osc, style, windowTitle } from './render.js'
15
+ import { installHerdrApp, installTemplate } from './install.js'
15
16
 
16
17
  export const name = 'herdr-bridge'
17
18
 
19
+ // Settings DSH validates and edits. Schemastery ships with DSH but may not resolve from a linked
20
+ // checkout; without it the settings still work from cordis.patch.yml, just unvalidated and not live.
21
+ // The volatile fields are what the Plugins page form (client.js) edits; they apply without a reload.
22
+ const Schema = await import('@deepseek-ai/schemastery').then(m => m.default, () => null)
23
+ export const Config = Schema?.object({
24
+ herdrPlacement: Schema.union(['folder', 'dsh', 'none']).default('folder').volatile()
25
+ .description('Where Herdr App shows the terminals of sessions started in DSH: by folder, in one space, or not at all'),
26
+ herdrSpace: Schema.string().volatile().description('A fixed Herdr App space id; overrides herdrPlacement when set'),
27
+ reconnectGraceSeconds: Schema.natural().default(30).description('How long a terminal waits for the plugin to come back before it exits'),
28
+ hc: Schema.string().description('The hc executable (default: hc on PATH)'),
29
+ })
30
+
18
31
  const PROTOCOL = 1
19
32
  const PASTE_START = '\x1b[200~'
20
33
  const PASTE_END = '\x1b[201~'
@@ -101,7 +114,10 @@ This DSH session is mirrored into Herdr Core (hc) as an ordinary terminal, so yo
101
114
 
102
115
  export function apply(ctx, config = {}) {
103
116
  const hc = config.hc || 'hc'
104
- const herdrSpace = config.herdrSpace || ''
117
+ // Volatile settings are references read at each use; a linked checkout passes plain values.
118
+ const live = value => typeof value?.get === 'function' ? value.get() : value
119
+ const herdrSpace = () => live(config.herdrSpace) || ''
120
+ const herdrPlacement = () => live(config.herdrPlacement) || 'folder'
105
121
  const grace = Number(config.reconnectGraceSeconds ?? 30)
106
122
  const home = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'herdr-bridge')
107
123
  mkdirSync(home, { recursive: true })
@@ -127,10 +143,82 @@ export function apply(ctx, config = {}) {
127
143
  return file
128
144
  })()
129
145
 
146
+ // hc's `dsh` template and Herdr App's DSH entry, kept current so nobody runs an install step.
147
+ for (const install of [() => installTemplate(launcher), installHerdrApp]) {
148
+ try {
149
+ const { file, result } = install()
150
+ if (result !== 'unchanged' && result !== 'absent') log('install', file, result)
151
+ } catch (error) {
152
+ log('install failed:', error?.message ?? error)
153
+ }
154
+ }
155
+
130
156
  const runHc = (args, done) => execFile(hc, args, (error, stdout, stderr) => {
131
157
  if (error) log('hc', args[0], 'failed:', oneLine(stderr || error.message, 300))
132
158
  done?.(error, stdout)
133
159
  })
160
+ const hcJson = args => new Promise(resolve => runHc([...args, '--json'], (error, stdout) => {
161
+ try { resolve(error ? null : JSON.parse(stdout)) } catch { resolve(null) }
162
+ }))
163
+
164
+ // Herdr App names a space it doesn't know after its first terminal's folder, and no hc label can
165
+ // name one; only its own CLI (`herdr-app do space.rename`) can. So the shared space is named DSH
166
+ // through that CLI, once Herdr App has made it, while its name is still the automatic folder name
167
+ // (a name the person chose is left alone). Skipped when Herdr App isn't installed or running.
168
+ const DSH_SPACE = 'dsh'
169
+ const herdrAppCandidates = [process.env.HERDR_APP_BIN, '/Applications/Herdr.app/Contents/MacOS/Herdr',
170
+ join(homedir(), 'Applications/Herdr.app/Contents/MacOS/Herdr')].filter(file => file && existsSync(file))
171
+ const herdrApp = args => new Promise(resolve => execFile(herdrAppCandidates[0] ?? 'herdr-app', args, { timeout: 5000 },
172
+ (error, stdout) => resolve(error ? null : stdout)))
173
+ let naming = false
174
+ async function nameDshSpace(cwd) {
175
+ if (naming) return
176
+ naming = true
177
+ try {
178
+ // Herdr App places a newly labeled terminal on its next listing, so the space can take a moment to exist.
179
+ for (let attempt = 0; attempt < 15; attempt++) {
180
+ const listed = await herdrApp(['ls'])
181
+ if (listed === null) return
182
+ const space = JSON.parse(listed).spaces?.find(s => s.id === DSH_SPACE)
183
+ if (space) {
184
+ const automatic = [space.folder, cwd].filter(Boolean).map(folder => basename(folder))
185
+ if (space.name !== 'DSH' && automatic.includes(space.name)) {
186
+ const done = await herdrApp(['do', 'space.rename', JSON.stringify({ id: DSH_SPACE, name: 'DSH' })])
187
+ log('herdr app: named the shared space DSH', done === null ? '(failed)' : '')
188
+ }
189
+ return
190
+ }
191
+ await new Promise(resolve => setTimeout(resolve, 1000))
192
+ }
193
+ } catch (error) {
194
+ log('herdr app: naming the shared space failed:', error?.message ?? error)
195
+ } finally {
196
+ naming = false
197
+ }
198
+ }
199
+
200
+ /**
201
+ * The Herdr App space for a session started in DSH (`herdr.space` label; Herdr App only shows
202
+ * hc terminals that carry one, and makes a new space for an id it doesn't know).
203
+ * By folder: the space a live terminal in the same folder is already in (the person's own space
204
+ * first), else one per folder. `dsh`: one space for every session. `none`: not placed.
205
+ */
206
+ async function herdrSpaceFor(cwd) {
207
+ if (herdrSpace()) return herdrSpace()
208
+ if (herdrPlacement() === 'none') return ''
209
+ if (herdrPlacement() === 'dsh') return DSH_SPACE
210
+ const here = realpath(cwd || homedir())
211
+ const spaces = []
212
+ for (const t of (await hcJson(['ls']))?.terminals ?? []) {
213
+ if (!t.cwd || realpath(t.cwd) !== here) continue
214
+ const space = (await hcJson(['get', t.address]))?.labels?.['herdr.space']
215
+ if (space) spaces.push(space)
216
+ }
217
+ // The person's own space first; the plugin's (`dsh`, `dsh-…`) never count, so switching back
218
+ // from one shared space doesn't keep pulling sessions into it.
219
+ return spaces.find(s => s !== DSH_SPACE && !s.startsWith('dsh-'))
220
+ ?? `dsh-${createHash('sha1').update(here).digest('hex').slice(0, 12)}`
221
+ }
134
222
 
135
223
  /** Host session services, present only in hosts that have them (Desktop, `dsh web`). */
136
224
  let sessions = null
@@ -146,6 +234,7 @@ export function apply(ctx, config = {}) {
146
234
  this.title = basename(cwd || '') || 'dsh'
147
235
  this.terminal = '' // hc address of the terminal mirroring it
148
236
  this.status = { state: agents?.get(sessionId)?.status === 'running' ? 'working' : 'idle' }
237
+ this.statusSeen = false // whether this instance has seen the session change state itself
149
238
  this.lastEnd = null
150
239
  this.waiter = null // a pending answer typed in hc
151
240
  this.conns = new Map() // socket → { auto }
@@ -158,16 +247,24 @@ export function apply(ctx, config = {}) {
158
247
  get short() { return this.sessionId.replace(/^session-/, '').slice(0, 8) }
159
248
  get mirrored() { return this.conns.size > 0 || this.launching }
160
249
 
161
- out(s) { if (s) for (const socket of this.conns.keys()) send(socket, { t: 'out', d: s }) }
250
+ // A terminal that is still being caught up queues live output until its history is out.
251
+ out(s) {
252
+ if (!s) return
253
+ for (const [socket, conn] of this.conns) {
254
+ if (conn.queue) conn.queue.push(s)
255
+ else send(socket, { t: 'out', d: s })
256
+ }
257
+ }
162
258
  set(state, extra = {}) {
163
259
  this.status = { state, ...extra }
260
+ this.statusSeen = true
164
261
  this.out(osc(this.status, this.title))
165
262
  }
166
263
  retitle(title) {
167
264
  title = oneLine(title, 80)
168
265
  if (!title || title === this.title) return
169
266
  this.title = title
170
- this.out(osc(this.status, this.title))
267
+ this.out(osc(this.status, this.title) + windowTitle(this.title))
171
268
  if (this.terminal) runHc(['label', this.terminal, `dsh.title=${title}`])
172
269
  }
173
270
 
@@ -182,7 +279,7 @@ export function apply(ctx, config = {}) {
182
279
  }
183
280
 
184
281
  /** Recent conversation, rendered like the live one, for a terminal that just joined. */
185
- async history(socket) {
282
+ async history(socket, auto) {
186
283
  try {
187
284
  const page = await sessions?.sessionController.page(
188
285
  { address: { kind: 'session', sessionId: this.sessionId }, maxMessages: HISTORY_MESSAGES },
@@ -191,7 +288,8 @@ export function apply(ctx, config = {}) {
191
288
  replay.cols = this.renderer.cols
192
289
  // History records wrap the session event: { type: 'event', event }.
193
290
  const text = (page?.records ?? []).map(record => replay.event(record?.event ?? record)).join('')
194
- if (text) send(socket, { t: 'out', d: text + crlf(`${style.dim('─── earlier conversation above ───')}\n`) })
291
+ // An automatic mirror opens on the session's first run: its history is the turn in flight.
292
+ if (text) send(socket, { t: 'out', d: auto ? text : text + crlf(`${style.dim('─── earlier conversation above ───')}\n`) })
195
293
  } catch (error) {
196
294
  log('history failed', this.sessionId, error?.message ?? error)
197
295
  }
@@ -199,16 +297,25 @@ export function apply(ctx, config = {}) {
199
297
 
200
298
  async attach(socket, hello, origin) {
201
299
  this.launching = false
202
- this.conns.set(socket, { auto: Boolean(hello.auto) })
300
+ // After a plugin reload an idle session can't tell how its last turn ended; the terminal
301
+ // that comes back remembers what it last showed (done or error), so keep that.
302
+ const kept = hello.status
303
+ if (hello.resumed && !this.statusSeen && this.status.state === 'idle' && ['done', 'error'].includes(kept?.state)) {
304
+ this.status = { state: kept.state, ...(kept.msg ? { msg: oneLine(kept.msg) } : {}) }
305
+ }
306
+ const conn = { auto: Boolean(hello.auto), queue: [] }
307
+ const inFlight = this.renderer.inFlight()
308
+ this.conns.set(socket, conn)
203
309
  if (hello.term) {
204
310
  const changed = this.terminal !== hello.term
205
311
  this.terminal = hello.term
206
312
  if (changed) runHc(['label', this.terminal, `dsh.session=${this.sessionId}`, `dsh.title=${this.title}`])
313
+ if (hello.auto || origin === 'desktop') this.place(hello.term)
207
314
  }
208
315
  if (hello.cols) this.renderer.cols = hello.cols
209
316
  socket.on('close', () => this.conns.delete(socket))
210
317
  send(socket, { t: 'welcome', v: PROTOCOL, session: this.sessionId, origin, grace })
211
- send(socket, { t: 'out', d: osc(this.status, this.title) })
318
+ send(socket, { t: 'out', d: osc(this.status, this.title) + windowTitle(this.title) })
212
319
  const feed = inputParser({
213
320
  submit: text => this.submit(text),
214
321
  cancel: () => this.cancel(),
@@ -220,8 +327,12 @@ export function apply(ctx, config = {}) {
220
327
  })
221
328
  if (!hello.resumed) {
222
329
  send(socket, { t: 'out', d: crlf(`${style.bold(this.title)} ${style.dim(`· dsh ${this.short} · ${this.cwd ?? ''}`)}\n${style.dim('Enter sends · Esc cancels')}\n`) })
223
- if (hello.mode === 'attach') await this.history(socket)
330
+ if (hello.mode === 'attach') await this.history(socket, conn.auto)
331
+ // Text streamed before this terminal joined is not in the history yet.
332
+ if (inFlight) send(socket, { t: 'out', d: inFlight })
224
333
  }
334
+ for (const s of conn.queue) send(socket, { t: 'out', d: s })
335
+ conn.queue = null
225
336
  }
226
337
 
227
338
  submit(text) {
@@ -269,18 +380,28 @@ export function apply(ctx, config = {}) {
269
380
  }
270
381
 
271
382
  /** Mirror into hc: a terminal named dsh-<short id> running `dsh-hc attach`. */
272
- launch() {
383
+ async launch() {
273
384
  if (this.mirrored) return
274
385
  this.launching = true
275
386
  const args = ['run', '--bg', '--rm', '--no-parent', '--json', '--name', `dsh-${this.short}`,
276
387
  '--label', `dsh.session=${this.sessionId}`, '--label', `dsh.title=${this.title}`]
277
- if (herdrSpace) args.push('--label', `herdr.space=${herdrSpace}`)
388
+ const space = await herdrSpaceFor(this.cwd)
389
+ if (space) args.push('--label', `herdr.space=${space}`)
390
+ if (space === DSH_SPACE) nameDshSpace(this.cwd)
278
391
  if (this.cwd) args.push('--cwd', this.cwd)
279
392
  args.push('--', launcher, 'attach', '--session', this.sessionId, '--auto')
280
393
  // A failure is usually the name being taken: that terminal is still ours and reconnecting.
281
394
  runHc(args, error => { if (error) this.launching = false })
282
395
  }
283
396
 
397
+ /** Put an automatic mirror into a Herdr App space, unless it is in one already (placed by us before, or moved by the person). */
398
+ async place(term) {
399
+ if ((await hcJson(['get', term]))?.labels?.['herdr.space']) return
400
+ const space = await herdrSpaceFor(this.cwd)
401
+ if (space) runHc(['label', term, `herdr.space=${space}`])
402
+ if (space === DSH_SPACE) nameDshSpace(this.cwd)
403
+ }
404
+
284
405
  /** Close every terminal mirroring this session; `except` keeps one. */
285
406
  release(reason, except) {
286
407
  for (const socket of this.conns.keys()) {
@@ -523,6 +644,9 @@ export function apply(ctx, config = {}) {
523
644
  })
524
645
 
525
646
  // /herdr: mirror this session into Herdr now and show its hc address.
647
+ // The Plugins page shows this plugin's own settings page (client.js), not a generated one.
648
+ ctx.inject(['settings'], c => { c.effect(() => c.settings.configure({ auto: false }, ctx.fiber), 'herdr-bridge: own settings page') })
649
+
526
650
  ctx.inject(['commands'], c => {
527
651
  c.commands.register({
528
652
  name: 'herdr',
@@ -578,5 +702,5 @@ export function apply(ctx, config = {}) {
578
702
  }
579
703
  bridges.clear()
580
704
  }, 'herdr-bridge: drop connections')
581
- log('loaded')
705
+ log('loaded', Schema ? '(settings schema on)' : '(settings schema off: schemastery not found)', `herdrPlacement=${herdrPlacement()}`)
582
706
  }
package/install.js ADDED
@@ -0,0 +1,79 @@
1
+ // What hc and Herdr App need on disk to know about DSH: the hc template `dsh` and Herdr App's
2
+ // program entry. The plugin installs both whenever it loads; `dsh-hc install-template` runs the
3
+ // same code by hand. Both leave a file or entry the person made themselves alone.
4
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
5
+ import { homedir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+
9
+ /** Marks a template this package wrote (hc ignores unknown header fields). */
10
+ const GENERATOR = 'dsh-herdr-bridge'
11
+
12
+ function writeAtomic(file, text) {
13
+ writeFileSync(`${file}.tmp`, text)
14
+ renameSync(`${file}.tmp`, file)
15
+ }
16
+
17
+ function templateDir() {
18
+ if (process.platform === 'win32') return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'herdr-core', 'templates')
19
+ return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'herdr-core', 'templates')
20
+ }
21
+
22
+ /**
23
+ * Install the hc template `dsh`, so `hc run dsh "task"` starts a DSH session. Its description tells
24
+ * Herdr agents (`hc templates`) the launcher's full path for listing and taking over sessions, so
25
+ * nobody needs `dsh-hc` on PATH.
26
+ * @param launcher - absolute path of the dsh-hc launcher.
27
+ * @returns the template file and `written`, `unchanged`, or `kept` (a template the person made).
28
+ */
29
+ export function installTemplate(launcher) {
30
+ const quoted = /\s/.test(launcher) ? `"${launcher}"` : launcher
31
+ const dir = templateDir()
32
+ const file = join(dir, 'dsh.md')
33
+ const text = `---
34
+ description: A DeepSeek Harness (DSH) session in this folder, via dsh-herdr-bridge; reports status. List recent sessions with \`${quoted} ls\`; take one over with \`hc run --bg --name <name> ${quoted} attach <id|title>\`.
35
+ command: ${quoted} new
36
+ generator: ${GENERATOR}
37
+ ---
38
+ `
39
+ if (existsSync(file)) {
40
+ const current = readFileSync(file, 'utf8')
41
+ if (current === text) return { file, result: 'unchanged' }
42
+ if (!current.includes(GENERATOR)) return { file, result: 'kept' }
43
+ }
44
+ mkdirSync(dir, { recursive: true })
45
+ writeAtomic(file, text)
46
+ return { file, result: 'written' }
47
+ }
48
+
49
+ // Herdr App names and draws agents from its program table: the built-in one, with the person's
50
+ // programs.json on top, entry by entry. DSH isn't built in, so add an entry for 7501 app `dsh`.
51
+ // Same folder on every platform: $HERDR_APP_HOME, else $XDG_CONFIG_HOME/herdr-app, else ~/.config/herdr-app.
52
+ const HERDR_PROGRAM = { name: 'DSH', icon: 'icons/dsh-herdr-bridge.svg', tint: true, tile: '#4d6bfe', color: '#ffffff' }
53
+
54
+ /**
55
+ * Give DSH its name and icon in Herdr App, when Herdr App has been started here.
56
+ * @returns the programs.json file and `written`, `kept` (a dsh entry is already there), `absent`
57
+ * (no Herdr App), or `invalid` (not valid JSON; left alone).
58
+ */
59
+ export function installHerdrApp() {
60
+ const dir = process.env.HERDR_APP_HOME || join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'herdr-app')
61
+ const file = join(dir, 'programs.json')
62
+ if (!existsSync(dir)) return { file, result: 'absent' } // Herdr App creates the folder on first start
63
+ let table = {}
64
+ try {
65
+ if (existsSync(file)) table = JSON.parse(readFileSync(file, 'utf8'))
66
+ } catch {
67
+ return { file, result: 'invalid' }
68
+ }
69
+ const icon = join(dir, HERDR_PROGRAM.icon)
70
+ const ours = fileURLToPath(new URL('./assets/dsh.svg', import.meta.url))
71
+ if (!existsSync(icon) || readFileSync(icon, 'utf8') !== readFileSync(ours, 'utf8')) {
72
+ mkdirSync(join(dir, 'icons'), { recursive: true })
73
+ copyFileSync(ours, icon)
74
+ }
75
+ if (table.programs?.dsh) return { file, result: 'kept' }
76
+ table.programs = { ...table.programs, dsh: HERDR_PROGRAM }
77
+ writeAtomic(file, `${JSON.stringify(table, null, 2)}\n`)
78
+ return { file, result: 'written' }
79
+ }
package/locale/en.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "Herdr Bridge",
4
+ "description": "Brings DSH sessions into Herdr, with live status, control from Herdr agents, and approvals on either side.",
5
+ "detail": "docs/DETAIL.md"
6
+ }
7
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "Herdr 桥接",
4
+ "description": "将 DSH 会话接入 Herdr,支持状态同步、Agent 操控与双端审批。",
5
+ "detail": "docs/DETAIL.zh.md"
6
+ }
7
+ }
package/package.json CHANGED
@@ -1,18 +1,26 @@
1
1
  {
2
2
  "name": "dsh-herdr-bridge",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Bridge DeepSeek Harness (DSH) sessions into Herdr Core as hc terminals",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
8
- ".": "./index.js"
8
+ ".": "./index.js",
9
+ "./client": "./client.js",
10
+ "./locale/*.json": "./locale/*.json"
9
11
  },
10
12
  "files": [
11
13
  "index.js",
12
14
  "render.js",
15
+ "client.js",
13
16
  "proxy.js",
17
+ "install.js",
18
+ "assets/dsh.svg",
14
19
  "cordis.patch.yml",
15
- "README.md"
20
+ "README.md",
21
+ "locale/*.json",
22
+ "docs/DETAIL.md",
23
+ "docs/DETAIL.zh.md"
16
24
  ],
17
25
  "engines": {
18
26
  "node": ">=24.9"
@@ -20,12 +28,20 @@
20
28
  "dsh": {
21
29
  "bundle": {
22
30
  "patch": "./cordis.patch.yml"
31
+ },
32
+ "client": {
33
+ "platform": "web",
34
+ "inject": [
35
+ "@deepseek-ai/dsh-client-locale",
36
+ "@deepseek-ai/dsh-client-ui-plugin-manager",
37
+ "@deepseek-ai/dsh-client-ui-settings"
38
+ ]
23
39
  }
24
40
  },
25
41
  "license": "MIT",
26
42
  "repository": {
27
43
  "type": "git",
28
- "url": "https://github.com/Hariketsu/dsh-herdr-bridge.git"
44
+ "url": "git+https://github.com/Hariketsu/dsh-herdr-bridge.git"
29
45
  },
30
46
  "keywords": [
31
47
  "dsh",
package/proxy.js CHANGED
@@ -6,14 +6,13 @@
6
6
  // dsh-hc new a new DSH session in the current folder (hc template `dsh`)
7
7
  // dsh-hc attach <session id | title> take over an existing DSH session
8
8
  // dsh-hc ls [--days N] [--json] recent DSH sessions
9
- // dsh-hc install-template install the hc template `dsh`
9
+ // dsh-hc install-template install the hc template `dsh` and DSH's Herdr App entry again
10
+ // (the plugin does this itself whenever it loads)
10
11
  //
11
12
  // Started through the launcher the plugin writes ($DSH_HOME/herdr-bridge/bin/dsh-hc), which sets
12
13
  // DSH_HERDR_SOCKET and runs this file with the Node that ships with DSH Desktop.
13
14
  import net from 'node:net'
14
- import { mkdirSync, writeFileSync } from 'node:fs'
15
- import { homedir } from 'node:os'
16
- import { join } from 'node:path'
15
+ import { installHerdrApp, installTemplate } from './install.js'
17
16
 
18
17
  const PROTOCOL = 1
19
18
  const SOCKET = process.env.DSH_HERDR_SOCKET
@@ -70,6 +69,7 @@ function terminal(first) {
70
69
  let live = false
71
70
  let leaving = false
72
71
  let deadline = 0
72
+ let status = null // the last OSC 7501 report shown, handed back on reconnect
73
73
 
74
74
  if (process.stdin.isTTY) process.stdin.setRawMode(true)
75
75
  out.write('\x1b[?2004h') // bracketed paste: hc send arrives as one paste
@@ -98,6 +98,7 @@ function terminal(first) {
98
98
  if (again) out.write(dim('⋯ reconnected\r\n'))
99
99
  } else if (message.t === 'out') {
100
100
  out.write(message.d)
101
+ status = lastStatus(message.d) ?? status
101
102
  } else if (message.t === 'bye') {
102
103
  if (message.reason) out.write(dim(`\r\n⋯ ${message.reason}\r\n`))
103
104
  leave(0)
@@ -125,7 +126,7 @@ function terminal(first) {
125
126
  return leave(0)
126
127
  }
127
128
  setTimeout(() => {
128
- connect({ mode: 'attach', session: welcome.session, origin: welcome.origin, resumed: true })
129
+ connect({ mode: 'attach', session: welcome.session, origin: welcome.origin, resumed: true, status })
129
130
  }, 1000)
130
131
  })
131
132
  }
@@ -134,24 +135,21 @@ function terminal(first) {
134
135
  connect(first)
135
136
  }
136
137
 
137
- function templateDir() {
138
- if (process.platform === 'win32') return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'herdr-core', 'templates')
139
- return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'herdr-core', 'templates')
138
+ /** The last OSC 7501 report in a chunk of output, as { state, msg }. */
139
+ function lastStatus(text) {
140
+ const reports = [...text.matchAll(/\x1b\]7501;([^\x07\x1b]*)/g)]
141
+ if (!reports.length) return null
142
+ const fields = Object.fromEntries(reports.at(-1)[1].split(':').map(f => [f.slice(0, f.indexOf('=')), f.slice(f.indexOf('=') + 1)]))
143
+ return { state: fields.state, msg: fields.msg ? Buffer.from(fields.msg, 'base64').toString() : '' }
140
144
  }
141
145
 
142
- function installTemplate() {
146
+ function installAll() {
143
147
  if (!LAUNCHER) fail('DSH_HERDR_LAUNCHER is not set; run dsh-hc through its launcher')
144
- const quoted = /\s/.test(LAUNCHER) ? `"${LAUNCHER}"` : LAUNCHER
145
- const dir = templateDir()
146
- mkdirSync(dir, { recursive: true })
147
- const file = join(dir, 'dsh.md')
148
- writeFileSync(file, `---
149
- name: dsh
150
- description: A DeepSeek Harness Desktop session in this folder (via dsh-herdr-bridge). Interactive; reports status. Find existing sessions with \`dsh-hc ls\` and take one over with \`hc run … ${quoted} attach <id|title>\`.
151
- command: ${quoted} new
152
- ---
153
- `)
154
- process.stdout.write(`${file}\n`)
148
+ for (const { file, result } of [installTemplate(LAUNCHER), installHerdrApp()]) {
149
+ const note = { written: '', unchanged: ' (already up to date)', kept: ' (kept: you made this one)',
150
+ absent: ' (skipped: Herdr App not started here yet)', invalid: ' (left alone: not valid JSON)' }[result]
151
+ process.stdout.write(`${file}${note}\n`)
152
+ }
155
153
  }
156
154
 
157
155
  // Terminal columns a string takes: East Asian wide and full-width characters take two.
@@ -197,7 +195,7 @@ switch (command) {
197
195
  break
198
196
  }
199
197
  case 'install-template':
200
- installTemplate()
198
+ installAll()
201
199
  break
202
200
  default:
203
201
  fail('usage: dsh-hc new | attach <session id | title> | ls [--days N] [--json] | install-template', 2)
package/render.js CHANGED
@@ -28,6 +28,9 @@ export function osc({ state, msg, kind }, title) {
28
28
  return `\x1b]7501;${parts.join(':')}\x1b\\`
29
29
  }
30
30
 
31
+ /** The terminal's own title (OSC 2): Herdr App names agent rows and tabs after it, as it does for TUI agents. */
32
+ export const windowTitle = title => `\x1b]2;${String(title).replace(/[\x00-\x1f\x7f]/g, '')}\x1b\\`
33
+
31
34
  const textOf = content => (Array.isArray(content) ? content : [])
32
35
  .filter(p => p?.type === 'text' && typeof p.text === 'string')
33
36
  .map(p => p.text)
@@ -58,6 +61,12 @@ export class Renderer {
58
61
  this.streamed = new Set()
59
62
  this.turnStarted = new Map()
60
63
  this.inText = false
64
+ this.partial = '' // the streamed text of the step in flight
65
+ }
66
+
67
+ /** The step in flight as a terminal that joins now would have seen it, so it can follow on. */
68
+ inFlight() {
69
+ return this.inText ? `${style.cyan('⏺')} ${crlf(this.partial.replace(/\n/g, '\n '))}` : ''
61
70
  }
62
71
 
63
72
  /** A durable session event (live or replayed from history) to terminal text. */
@@ -114,17 +123,20 @@ export class Renderer {
114
123
  if (frame?.type === 'start') {
115
124
  this.step = `${frame.turn}:${frame.step}`
116
125
  this.inText = false
126
+ this.partial = ''
117
127
  return ''
118
128
  }
119
129
  if (frame?.type === 'chunk' && frame.chunk?.type === 'text-delta' && frame.chunk.text) {
120
130
  const lead = this.inText ? '' : `${style.cyan('⏺')} `
121
131
  this.inText = true
132
+ this.partial += frame.chunk.text
122
133
  if (this.step) this.streamed.add(this.step)
123
134
  return lead + crlf(frame.chunk.text.replace(/\n/g, '\n '))
124
135
  }
125
136
  if (frame?.type === 'end') {
126
137
  const close = this.inText ? '\r\n' : ''
127
138
  this.inText = false
139
+ this.partial = ''
128
140
  return close
129
141
  }
130
142
  return ''