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 +96 -100
- package/README.zh-CN.md +173 -0
- package/assets/dsh.svg +2 -0
- package/index.js +30 -8
- package/package.json +2 -1
- package/proxy.js +41 -3
- package/render.js +12 -0
package/README.md
CHANGED
|
@@ -1,178 +1,174 @@
|
|
|
1
1
|
# dsh-herdr-bridge
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
+
## What it looks like
|
|
17
17
|
|
|
18
|
-
Herdr
|
|
18
|
+

|
|
19
19
|
|
|
20
|
-
|
|
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
|
|
35
|
-
| herdr-core
|
|
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
|
|
30
|
+
Only one of Desktop and `dsh web` can be connected to Herdr at a time (see Known limitations).
|
|
40
31
|
|
|
41
|
-
|
|
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.
|
|
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 #
|
|
38
|
+
dsh plugin --profile desktop add dsh-herdr-bridge --ignore-scripts --config.auto-install-peers=false # or --profile web
|
|
48
39
|
```
|
|
49
|
-
2.
|
|
50
|
-
3.
|
|
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
|
|
53
|
-
dsh-hc install-template #
|
|
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 #
|
|
50
|
+
dsh-hc ls # recent DSH sessions (prints "no DSH sessions in that period" before you have any)
|
|
60
51
|
```
|
|
61
52
|
|
|
62
|
-
|
|
53
|
+
Then send any message in DSH. A `dsh-<short id>` terminal should appear in `hc ls`.
|
|
63
54
|
|
|
64
|
-
|
|
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.
|
|
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.
|
|
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 "
|
|
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
|
-
|
|
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 #
|
|
85
|
-
hc run --bg --name login dsh-hc attach 3b729fc9 #
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+

|
|
94
89
|
|
|
95
|
-
|
|
90
|
+
Answering from hc needs `--force`:
|
|
96
91
|
|
|
97
92
|
```sh
|
|
98
|
-
hc send --force fix y #
|
|
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
|
-
|
|
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
|
-
-
|
|
107
|
-
-
|
|
108
|
-
-
|
|
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
|
|
105
|
+
## DSH driving other agents
|
|
111
106
|
|
|
112
|
-
|
|
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 "
|
|
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
|
-
|
|
113
|
+
General hc usage comes from `~/.agents/skills/herdr-core`, which DSH discovers automatically.
|
|
119
114
|
|
|
120
|
-
##
|
|
115
|
+
## Configuration
|
|
121
116
|
|
|
122
|
-
|
|
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 #
|
|
128
|
-
herdrSpace: s3 #
|
|
129
|
-
hc: /path/to/hc # 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
|
-
|
|
129
|
+
Start with the log, `~/.dsh/herdr-bridge/bridge.log`.
|
|
135
130
|
|
|
136
|
-
|
|
|
131
|
+
| Symptom | Check |
|
|
137
132
|
|---|---|
|
|
138
|
-
| DSH
|
|
139
|
-
| `dsh-hc: command not found` | PATH
|
|
140
|
-
| `hc run dsh …`
|
|
141
|
-
|
|
|
142
|
-
|
|
|
143
|
-
|
|
|
144
|
-
|
|
|
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
|
|
149
|
-
- **DSH
|
|
150
|
-
-
|
|
151
|
-
-
|
|
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
|
-
|
|
150
|
+
Plans and untested paths are in [TODO.md](TODO.md).
|
|
156
151
|
|
|
157
|
-
##
|
|
152
|
+
## How it works
|
|
158
153
|
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 #
|
|
169
|
-
scripts/dev.sh reload #
|
|
170
|
-
scripts/e2e.sh #
|
|
171
|
-
scripts/e2e-ui.sh #
|
|
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 #
|
|
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.
|
package/README.zh-CN.md
ADDED
|
@@ -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
|
+

|
|
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
|
+

|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 ''
|