dsh-herdr-bridge 0.1.0 → 0.1.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
@@ -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](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`:
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):
46
37
  ```sh
47
- dsh plugin --profile desktop add dsh-herdr-bridge --ignore-scripts --config.auto-install-peers=false # 或 --profile web
38
+ dsh plugin --profile desktop add dsh-herdr-bridge --ignore-scripts --config.auto-install-peers=false # or --profile web
48
39
  ```
49
- 2. 重新打开 Desktop 或启动 `dsh web`。插件加载时会生成命令 `~/.dsh/herdr-bridge/bin/dsh-hc`。
50
- 3. 把它加进 PATH(下文的示例都假设已经加好),并安装 hc template,让 `hc run dsh "任务"` 可用:
40
+ 2. Reopen Desktop or start `dsh web`. When the plugin loads, it creates the command `~/.dsh/herdr-bridge/bin/dsh-hc`.
41
+ 3. Add it to your PATH (the examples below assume you did), then run `install-template`. This installs the hc template so that `hc run dsh "task"` works. If Herdr App is installed, it also adds DSH's name and icon to Herdr App's program table, so DSH sessions don't show a generic icon in the agents list:
51
42
  ```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
43
+ fish_add_path ~/.dsh/herdr-bridge/bin # fish; for zsh/bash add export PATH="$HOME/.dsh/herdr-bridge/bin:$PATH" to your rc file
44
+ dsh-hc install-template # writes the hc template; with Herdr App, also its programs.json and icon
54
45
  ```
55
46
 
56
- **确认安装成功**:
47
+ **Check that it works:**
57
48
 
58
49
  ```sh
59
- dsh-hc ls # 列出 DSH 里最近的会话(还没有会话时显示 no DSH sessions in that period)
50
+ dsh-hc ls # recent DSH sessions (prints "no DSH sessions in that period" before you have any)
60
51
  ```
61
52
 
62
- 然后在 DSH 里随便发一条消息,`hc ls` 里应该出现 `dsh-<短 id>` 终端。
53
+ Then send any message in DSH. A `dsh-<short id>` terminal should appear in `hc ls`.
63
54
 
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-*` 终端会在宽限期后自行退出。
55
+ **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
56
 
66
- ## 快速上手
57
+ ## Quick start
67
58
 
68
- **1. 在 DSH 里正常使用。** 会话第一次运行时,插件为它开一个 `dsh-<短 id>` 终端,带标签 `dsh.session=<会话 id>` 和 `dsh.title=<会话标题>`(标题随改名同步)。还没运行过的会话,在 DSH 里对它输入 `/herdr`,它就会出现在 Herdr 里,命令会显示它的 hc 地址。会话关闭时,终端随之结束。
59
+ **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
60
 
70
- **2. 从 Herdr 新建一个 DSH 会话。** 下面的 `/repo`、`fix` 都是示例:
61
+ **2. Start a DSH session from Herdr.** `/repo` and `fix` are example values:
71
62
 
72
63
  ```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 "继续,把边界情况也测了"
64
+ hc run --bg --name fix --cwd /repo dsh "Fix the failing test in x.rs"
65
+ hc wait fix # wait for the turn to finish
66
+ hc read fix # read the reply
67
+ hc send --wait fix "Go on, and cover the edge cases too"
77
68
  ```
78
69
 
79
- 新会话的目录如果正好是 DSH 里已有的 Workspace,就加入它;是某个 Workspace 的子目录,就建成“未分组”会话;其他目录会自动添加为新的 Workspace。
70
+ 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
71
 
81
- **3. 接管一个已有会话。**
72
+ **3. Take over an existing session.**
82
73
 
83
74
  ```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 # 先看到之前的对话,再看到新的
75
+ dsh-hc ls --days 7 # session id, title, folder, and whether it's already in hc
76
+ hc run --bg --name login dsh-hc attach 3b729fc9 # by id, id prefix, or part of the title
77
+ hc read login # the earlier conversation first, then anything new
87
78
  ```
88
79
 
89
- 如果这个会话已经有自动映射的 `dsh-<短 id>` 终端,那个终端会退出,由接管的终端代替。
80
+ If the session already has an automatic `dsh-<short id>` terminal, that terminal exits and the one taking over replaces it.
81
+
82
+ **4. Cancel when needed.** `hc interrupt fix` cancels the current turn, like pressing stop in DSH.
83
+
84
+ ## Approvals, questions and trust
90
85
 
91
- **4. 需要时取消。** `hc interrupt fix` 取消当前回合,相当于在 DSH 里点停止。
86
+ 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`.
92
87
 
93
- ## 审批、提问与信任边界
88
+ ![Left: the approval dialog in DSH Web. Right: the same session in Herdr App, waiting on the same approval](docs/media/approval.webp)
94
89
 
95
- 会话等待审批或提问时,DSH 界面照常弹出对话框,hc 终端里也显示同一个问题,状态为 `blocked`。在 hc 侧回答必须加 `--force`:
90
+ Answering from hc needs `--force`:
96
91
 
97
92
  ```sh
98
- hc send --force fix y # 批准一次;n 为拒绝
99
- hc send --force fix 1,3 # 提问:选项序号(多选用逗号),也可以写自由文本,或 - 跳过
93
+ hc send --force fix y # allow once; n rejects
94
+ hc send --force fix 1,3 # a question: option numbers (comma-separated for several), free text, or - to skip
100
95
  ```
101
96
 
102
- 这和 hc 对待其他 Agent 的规则一致:hc 拒绝向 blocked 的程序普通 `send`,`--force` 是唯一的闸门。插件本身从不自动回答,什么时候可以用 `--force`,由驱动它的 Agent 的指令决定(见 `hc --skill`)。
97
+ 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
98
 
104
- 需要知道的边界:
99
+ Boundaries to know:
105
100
 
106
- - **谁能操控会话**:能使用你的 hc 的进程,都能读取和操控已映射的 DSH 会话,和 hc 里的其他终端一样。插件的控制 socket 在 `~/.dsh/herdr-bridge/control.sock`,只在本机。
107
- - **权限不放大**:hc 发来的消息走 DSH 正常的 prompt 流程,会话原有的沙箱和审批规则照常生效。
108
- - **来自 hc 的输入会成为用户消息**:`hc send` 的文本,以及 helper 的回报和 hc 通知(完成、blocked、退出),都会作为下一条用户消息进入 DSH 会话并唤醒它。hc 发出的消息带有 `<hc …>` 标记,模型能看出来源,但它们和你亲手输入的消息处在同一个位置。
101
+ - **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.
102
+ - **No extra permissions:** messages from hc go through DSH's normal prompt path, so the session's sandbox and approval rules still apply.
103
+ - **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
104
 
110
- ## DSH 调度其他 Agent
105
+ ## DSH driving other agents
111
106
 
112
- 插件给已映射的会话注入 `DSH_HC_TERMINAL`(会话自己的 hc 地址),并注册一个 skill `herdr-bridge`,告诉模型这样启动 helper:
107
+ 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
108
 
114
109
  ```sh
115
- hc run --bg --parent "$DSH_HC_TERMINAL" --name helper --cwd /repo claude "…完成后用 hc send $DSH_HC_TERMINAL 汇报"
110
+ hc run --bg --parent "$DSH_HC_TERMINAL" --name helper --cwd /repo claude "… when done, report with hc send $DSH_HC_TERMINAL"
116
111
  ```
117
112
 
118
- 通用的 hc 用法来自 `~/.agents/skills/herdr-core`,DSH 会自动发现这个目录。
113
+ General hc usage comes from `~/.agents/skills/herdr-core`, which DSH discovers automatically.
119
114
 
120
- ## 配置
115
+ ## Configuration
121
116
 
122
- 写在 `~/.dsh/profiles/<profile>/cordis.patch.yml` 的 `herdr-bridge` 条目下,全部可选:
117
+ All optional, under the `herdr-bridge` entry in `~/.dsh/profiles/<profile>/cordis.patch.yml`:
123
118
 
124
119
  ```yaml
125
120
  - id: herdr-bridge
126
121
  config:
127
- reconnectGraceSeconds: 30 # 插件断开后,终端等待重连的秒数,超时退出(默认 30)
128
- herdrSpace: s3 # 给终端打 herdr.space 标签,在 Herdr App 的这个 space 里显示
129
- hc: /path/to/hc # hc 可执行文件,默认是 PATH 上的 hc
122
+ reconnectGraceSeconds: 30 # how long a terminal waits for the plugin to come back before exiting (default 30)
123
+ herdrSpace: s3 # label terminals herdr.space=<id> so they show in that Herdr App space
124
+ hc: /path/to/hc # the hc executable; default: hc on PATH
130
125
  ```
131
126
 
132
- ## 故障排查
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'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) |
134
+ | `dsh-hc: command not found` | PATH isn't set; use `~/.dsh/herdr-bridge/bin/dsh-hc` directly. If that file doesn't exist, the plugin didn't load |
135
+ | `hc run dsh …` can't find the template | Run `dsh-hc install-template` |
136
+ | DSH sessions show a generic robot icon in Herdr App | Start Herdr App once, then run `dsh-hc install-template`. It reports whether it wrote the `dsh` entry to `programs.json` or kept one that was already there |
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 # 37 hc-side checks
166
+ scripts/e2e-ui.sh # 13 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 会话:一个等待审批,一个正在工作,一个已完成;右侧是已完成会话的对话](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 --ignore-scripts --config.auto-install-peers=false # 或 --profile web
39
+ ```
40
+ 2. 重新打开 Desktop 或启动 `dsh web`。插件加载时会生成命令 `~/.dsh/herdr-bridge/bin/dsh-hc`。
41
+ 3. 把它加进 PATH(下文的示例都假设已经加好),然后执行 `install-template`。它安装 hc template,让 `hc run dsh "任务"` 可用;装了 Herdr App 的话,还会在 Herdr App 的程序表里加上 DSH 的名字和图标,DSH 会话在 Agent 列表里就不再是通用图标:
42
+ ```sh
43
+ fish_add_path ~/.dsh/herdr-bridge/bin # fish;zsh/bash 在 rc 文件里加 export PATH="$HOME/.dsh/herdr-bridge/bin:$PATH"
44
+ dsh-hc install-template # 写入 hc template;有 Herdr App 时再写入它的 programs.json 和图标
45
+ ```
46
+
47
+ **确认安装成功**:
48
+
49
+ ```sh
50
+ dsh-hc ls # 列出 DSH 里最近的会话(还没有会话时显示 no DSH sessions in that period)
51
+ ```
52
+
53
+ 然后在 DSH 里随便发一条消息,`hc ls` 里应该出现 `dsh-<短 id>` 终端。
54
+
55
+ **卸载**:退出 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-*` 终端会在宽限期后自行退出。
56
+
57
+ ## 快速上手
58
+
59
+ **1. 在 DSH 里正常使用。** 会话第一次运行时,插件为它开一个 `dsh-<短 id>` 终端,终端标题就是会话标题(随改名同步),另带标签 `dsh.session=<会话 id>` 和 `dsh.title=<会话标题>`。还没运行过的会话,在 DSH 里对它输入 `/herdr`,它就会出现在 Herdr 里,命令会显示它的 hc 地址。会话关闭时,终端随之结束。
60
+
61
+ **2. 从 Herdr 新建一个 DSH 会话。** 下面的 `/repo`、`fix` 都是示例:
62
+
63
+ ```sh
64
+ hc run --bg --name fix --cwd /repo dsh "修复 x.rs 里失败的测试"
65
+ hc wait fix # 等这一回合结束
66
+ hc read fix # 读回复
67
+ hc send --wait fix "继续,把边界情况也测了"
68
+ ```
69
+
70
+ 新会话的目录如果正好是 DSH 里已有的 Workspace,就加入它;是某个 Workspace 的子目录,就建成“未分组”会话;其他目录会自动添加为新的 Workspace。
71
+
72
+ **3. 接管一个已有会话。**
73
+
74
+ ```sh
75
+ dsh-hc ls --days 7 # 会话 id、标题、目录、是否已在 hc 里
76
+ hc run --bg --name login dsh-hc attach 3b729fc9 # 用 id、id 前缀或标题片段
77
+ hc read login # 先看到之前的对话,再看到新的
78
+ ```
79
+
80
+ 如果这个会话已经有自动映射的 `dsh-<短 id>` 终端,那个终端会退出,由接管的终端代替。
81
+
82
+ **4. 需要时取消。** `hc interrupt fix` 取消当前回合,相当于在 DSH 里点停止。
83
+
84
+ ## 审批、提问与信任边界
85
+
86
+ 会话等待审批或提问时,DSH 界面照常弹出对话框,hc 终端里也显示同一个问题,状态为 `blocked`。
87
+
88
+ ![左边是 DSH Web 里的审批对话框,右边是 Herdr App 里同一个会话在等待同一个审批](docs/media/approval.webp)
89
+
90
+ 在 hc 侧回答必须加 `--force`:
91
+
92
+ ```sh
93
+ hc send --force fix y # 批准一次;n 为拒绝
94
+ hc send --force fix 1,3 # 提问:选项序号(多选用逗号),也可以写自由文本,或 - 跳过
95
+ ```
96
+
97
+ 这和 hc 对待其他 Agent 的规则一致:hc 拒绝向 blocked 的程序普通 `send`,`--force` 是唯一的闸门。插件本身从不自动回答,什么时候可以用 `--force`,由驱动它的 Agent 的指令决定(见 `hc --skill`)。
98
+
99
+ 需要知道的边界:
100
+
101
+ - **谁能操控会话**:能使用你的 hc 的进程,都能读取和操控已映射的 DSH 会话,和 hc 里的其他终端一样。插件的控制 socket 在 `~/.dsh/herdr-bridge/control.sock`,只在本机。
102
+ - **权限不放大**:hc 发来的消息走 DSH 正常的 prompt 流程,会话原有的沙箱和审批规则照常生效。
103
+ - **来自 hc 的输入会成为用户消息**:`hc send` 的文本,以及 helper 的回报和 hc 通知(完成、blocked、退出),都会作为下一条用户消息进入 DSH 会话并唤醒它。hc 发出的消息带有 `<hc …>` 标记,模型能看出来源,但它们和你亲手输入的消息处在同一个位置。
104
+
105
+ ## DSH 调度其他 Agent
106
+
107
+ 插件给已映射的会话注入 `DSH_HC_TERMINAL`(会话自己的 hc 地址),并注册一个 skill `herdr-bridge`,告诉模型这样启动 helper:
108
+
109
+ ```sh
110
+ hc run --bg --parent "$DSH_HC_TERMINAL" --name helper --cwd /repo claude "…完成后用 hc send $DSH_HC_TERMINAL 汇报"
111
+ ```
112
+
113
+ 通用的 hc 用法来自 `~/.agents/skills/herdr-core`,DSH 会自动发现这个目录。
114
+
115
+ ## 配置
116
+
117
+ 写在 `~/.dsh/profiles/<profile>/cordis.patch.yml` 的 `herdr-bridge` 条目下,全部可选:
118
+
119
+ ```yaml
120
+ - id: herdr-bridge
121
+ config:
122
+ reconnectGraceSeconds: 30 # 插件断开后,终端等待重连的秒数,超时退出(默认 30)
123
+ herdrSpace: s3 # 给终端打 herdr.space 标签,在 Herdr App 的这个 space 里显示
124
+ hc: /path/to/hc # hc 可执行文件,默认是 PATH 上的 hc
125
+ ```
126
+
127
+ ## 故障排查
128
+
129
+ 先看日志 `~/.dsh/herdr-bridge/bridge.log`。
130
+
131
+ | 现象 | 检查 |
132
+ |---|---|
133
+ | DSH 里在用的会话,`hc ls` 里没有 | 日志里有没有插件加载和开终端失败的记录;DSH 进程能否找到 `hc`(Desktop 用登录 shell 的 PATH,`dsh web` 用启动它的 shell 的 PATH;找不到就在配置里设 `hc`);是否同时开着 Desktop 和 `dsh web`(见已知限制) |
134
+ | `dsh-hc: command not found` | PATH 没加好;直接用 `~/.dsh/herdr-bridge/bin/dsh-hc`。这个文件不存在说明插件没加载 |
135
+ | `hc run dsh …` 提示找不到 template | 执行 `dsh-hc install-template` |
136
+ | Herdr App 里 DSH 会话显示通用的机器人图标 | 先启动过一次 Herdr App,再执行 `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 # 37 项 hc 侧回归
166
+ scripts/e2e-ui.sh # 13 项 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/index.js CHANGED
@@ -11,7 +11,7 @@ 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
15
 
16
16
  export const name = 'herdr-bridge'
17
17
 
@@ -146,6 +146,7 @@ export function apply(ctx, config = {}) {
146
146
  this.title = basename(cwd || '') || 'dsh'
147
147
  this.terminal = '' // hc address of the terminal mirroring it
148
148
  this.status = { state: agents?.get(sessionId)?.status === 'running' ? 'working' : 'idle' }
149
+ this.statusSeen = false // whether this instance has seen the session change state itself
149
150
  this.lastEnd = null
150
151
  this.waiter = null // a pending answer typed in hc
151
152
  this.conns = new Map() // socket → { auto }
@@ -158,16 +159,24 @@ export function apply(ctx, config = {}) {
158
159
  get short() { return this.sessionId.replace(/^session-/, '').slice(0, 8) }
159
160
  get mirrored() { return this.conns.size > 0 || this.launching }
160
161
 
161
- out(s) { if (s) for (const socket of this.conns.keys()) send(socket, { t: 'out', d: s }) }
162
+ // A terminal that is still being caught up queues live output until its history is out.
163
+ out(s) {
164
+ if (!s) return
165
+ for (const [socket, conn] of this.conns) {
166
+ if (conn.queue) conn.queue.push(s)
167
+ else send(socket, { t: 'out', d: s })
168
+ }
169
+ }
162
170
  set(state, extra = {}) {
163
171
  this.status = { state, ...extra }
172
+ this.statusSeen = true
164
173
  this.out(osc(this.status, this.title))
165
174
  }
166
175
  retitle(title) {
167
176
  title = oneLine(title, 80)
168
177
  if (!title || title === this.title) return
169
178
  this.title = title
170
- this.out(osc(this.status, this.title))
179
+ this.out(osc(this.status, this.title) + windowTitle(this.title))
171
180
  if (this.terminal) runHc(['label', this.terminal, `dsh.title=${title}`])
172
181
  }
173
182
 
@@ -182,7 +191,7 @@ export function apply(ctx, config = {}) {
182
191
  }
183
192
 
184
193
  /** Recent conversation, rendered like the live one, for a terminal that just joined. */
185
- async history(socket) {
194
+ async history(socket, auto) {
186
195
  try {
187
196
  const page = await sessions?.sessionController.page(
188
197
  { address: { kind: 'session', sessionId: this.sessionId }, maxMessages: HISTORY_MESSAGES },
@@ -191,7 +200,8 @@ export function apply(ctx, config = {}) {
191
200
  replay.cols = this.renderer.cols
192
201
  // History records wrap the session event: { type: 'event', event }.
193
202
  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`) })
203
+ // An automatic mirror opens on the session's first run: its history is the turn in flight.
204
+ if (text) send(socket, { t: 'out', d: auto ? text : text + crlf(`${style.dim('─── earlier conversation above ───')}\n`) })
195
205
  } catch (error) {
196
206
  log('history failed', this.sessionId, error?.message ?? error)
197
207
  }
@@ -199,7 +209,15 @@ export function apply(ctx, config = {}) {
199
209
 
200
210
  async attach(socket, hello, origin) {
201
211
  this.launching = false
202
- this.conns.set(socket, { auto: Boolean(hello.auto) })
212
+ // After a plugin reload an idle session can't tell how its last turn ended; the terminal
213
+ // that comes back remembers what it last showed (done or error), so keep that.
214
+ const kept = hello.status
215
+ if (hello.resumed && !this.statusSeen && this.status.state === 'idle' && ['done', 'error'].includes(kept?.state)) {
216
+ this.status = { state: kept.state, ...(kept.msg ? { msg: oneLine(kept.msg) } : {}) }
217
+ }
218
+ const conn = { auto: Boolean(hello.auto), queue: [] }
219
+ const inFlight = this.renderer.inFlight()
220
+ this.conns.set(socket, conn)
203
221
  if (hello.term) {
204
222
  const changed = this.terminal !== hello.term
205
223
  this.terminal = hello.term
@@ -208,7 +226,7 @@ export function apply(ctx, config = {}) {
208
226
  if (hello.cols) this.renderer.cols = hello.cols
209
227
  socket.on('close', () => this.conns.delete(socket))
210
228
  send(socket, { t: 'welcome', v: PROTOCOL, session: this.sessionId, origin, grace })
211
- send(socket, { t: 'out', d: osc(this.status, this.title) })
229
+ send(socket, { t: 'out', d: osc(this.status, this.title) + windowTitle(this.title) })
212
230
  const feed = inputParser({
213
231
  submit: text => this.submit(text),
214
232
  cancel: () => this.cancel(),
@@ -220,8 +238,12 @@ export function apply(ctx, config = {}) {
220
238
  })
221
239
  if (!hello.resumed) {
222
240
  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)
241
+ if (hello.mode === 'attach') await this.history(socket, conn.auto)
242
+ // Text streamed before this terminal joined is not in the history yet.
243
+ if (inFlight) send(socket, { t: 'out', d: inFlight })
224
244
  }
245
+ for (const s of conn.queue) send(socket, { t: 'out', d: s })
246
+ conn.queue = null
225
247
  }
226
248
 
227
249
  submit(text) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-herdr-bridge",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Bridge DeepSeek Harness (DSH) sessions into Herdr Core as hc terminals",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -11,6 +11,7 @@
11
11
  "index.js",
12
12
  "render.js",
13
13
  "proxy.js",
14
+ "assets/dsh.svg",
14
15
  "cordis.patch.yml",
15
16
  "README.md"
16
17
  ],
package/proxy.js CHANGED
@@ -6,14 +6,16 @@
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 name and icon in
10
+ // Herdr App's program table when Herdr App is there
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 { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
15
16
  import { homedir } from 'node:os'
16
17
  import { join } from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
17
19
 
18
20
  const PROTOCOL = 1
19
21
  const SOCKET = process.env.DSH_HERDR_SOCKET
@@ -70,6 +72,7 @@ function terminal(first) {
70
72
  let live = false
71
73
  let leaving = false
72
74
  let deadline = 0
75
+ let status = null // the last OSC 7501 report shown, handed back on reconnect
73
76
 
74
77
  if (process.stdin.isTTY) process.stdin.setRawMode(true)
75
78
  out.write('\x1b[?2004h') // bracketed paste: hc send arrives as one paste
@@ -98,6 +101,7 @@ function terminal(first) {
98
101
  if (again) out.write(dim('⋯ reconnected\r\n'))
99
102
  } else if (message.t === 'out') {
100
103
  out.write(message.d)
104
+ status = lastStatus(message.d) ?? status
101
105
  } else if (message.t === 'bye') {
102
106
  if (message.reason) out.write(dim(`\r\n⋯ ${message.reason}\r\n`))
103
107
  leave(0)
@@ -125,7 +129,7 @@ function terminal(first) {
125
129
  return leave(0)
126
130
  }
127
131
  setTimeout(() => {
128
- connect({ mode: 'attach', session: welcome.session, origin: welcome.origin, resumed: true })
132
+ connect({ mode: 'attach', session: welcome.session, origin: welcome.origin, resumed: true, status })
129
133
  }, 1000)
130
134
  })
131
135
  }
@@ -134,6 +138,14 @@ function terminal(first) {
134
138
  connect(first)
135
139
  }
136
140
 
141
+ /** The last OSC 7501 report in a chunk of output, as { state, msg }. */
142
+ function lastStatus(text) {
143
+ const reports = [...text.matchAll(/\x1b\]7501;([^\x07\x1b]*)/g)]
144
+ if (!reports.length) return null
145
+ const fields = Object.fromEntries(reports.at(-1)[1].split(':').map(f => [f.slice(0, f.indexOf('=')), f.slice(f.indexOf('=') + 1)]))
146
+ return { state: fields.state, msg: fields.msg ? Buffer.from(fields.msg, 'base64').toString() : '' }
147
+ }
148
+
137
149
  function templateDir() {
138
150
  if (process.platform === 'win32') return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'herdr-core', 'templates')
139
151
  return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'herdr-core', 'templates')
@@ -152,6 +164,32 @@ command: ${quoted} new
152
164
  ---
153
165
  `)
154
166
  process.stdout.write(`${file}\n`)
167
+ installHerdrApp()
168
+ }
169
+
170
+ // Herdr App names and draws agents from its program table: the built-in one, with the person's
171
+ // programs.json on top, entry by entry. DSH isn't built in, so add an entry for 7501 app `dsh`.
172
+ // Same folder on every platform: $HERDR_APP_HOME, else $XDG_CONFIG_HOME/herdr-app, else ~/.config/herdr-app.
173
+ const HERDR_PROGRAM = { name: 'DSH', icon: 'icons/dsh-herdr-bridge.svg', tint: true, tile: '#4d6bfe', color: '#ffffff' }
174
+
175
+ function installHerdrApp() {
176
+ const dir = process.env.HERDR_APP_HOME || join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'herdr-app')
177
+ if (!existsSync(dir)) return // no Herdr App here (it creates the folder on first start)
178
+ const file = join(dir, 'programs.json')
179
+ let table = {}
180
+ try {
181
+ if (existsSync(file)) table = JSON.parse(readFileSync(file, 'utf8'))
182
+ } catch (error) {
183
+ return process.stderr.write(`dsh-hc: left ${file} alone, it isn't valid JSON (${error.message})\n`)
184
+ }
185
+ mkdirSync(join(dir, 'icons'), { recursive: true })
186
+ copyFileSync(fileURLToPath(new URL('./assets/dsh.svg', import.meta.url)), join(dir, HERDR_PROGRAM.icon))
187
+ // An entry already there is the person's (or ours from before): keep it.
188
+ if (table.programs?.dsh) return process.stdout.write(`${file} (kept its dsh entry)\n`)
189
+ table.programs = { ...table.programs, dsh: HERDR_PROGRAM }
190
+ writeFileSync(`${file}.tmp`, `${JSON.stringify(table, null, 2)}\n`)
191
+ renameSync(`${file}.tmp`, file)
192
+ process.stdout.write(`${file}\n`)
155
193
  }
156
194
 
157
195
  // Terminal columns a string takes: East Asian wide and full-width characters take two.
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 ''