dsh-herdr-bridge 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-herdr-bridge contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,178 @@
1
- # Temporary Holding Version
1
+ # dsh-herdr-bridge
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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`)。
4
+
5
+ 装好以后:
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 里回答都行,先答者生效,另一边的对话框会撤回。
11
+
12
+ 插件运行在 DSH 自己的进程里,不需要第二份 DSH,也不修改 DSH、Herdr 的任何源码。
13
+
14
+ ## 效果
15
+
16
+ <!-- TODO: Perch / Herdr App 里显示 dsh-xxxx 行的截图 -->
17
+
18
+ Herdr Agent 读取一个 DSH 会话时,看到的是和原生 TUI Agent 相同格式的文本(示例):
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
+ ```
29
+
30
+ ## 要求
31
+
32
+ | | 开发时使用的版本 | 说明 |
33
+ |---|---|---|
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` | 可选 |
38
+
39
+ Desktop 和 `dsh web` 同一时间只能有一个接入 Herdr(见已知限制)。
40
+
41
+ 平台:只在 macOS 上验证过。Windows / Linux 的代码路径已写好,但没有测试过。暂无最低版本要求,只保证上表这一组合。
42
+
43
+ ## 安装
44
+
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 "任务"` 可用:
51
+ ```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
54
+ ```
55
+
56
+ **确认安装成功**:
57
+
58
+ ```sh
59
+ dsh-hc ls # 列出 DSH 里最近的会话(还没有会话时显示 no DSH sessions in that period)
60
+ ```
61
+
62
+ 然后在 DSH 里随便发一条消息,`hc ls` 里应该出现 `dsh-<短 id>` 终端。
63
+
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-*` 终端会在宽限期后自行退出。
65
+
66
+ ## 快速上手
67
+
68
+ **1. 在 DSH 里正常使用。** 会话第一次运行时,插件为它开一个 `dsh-<短 id>` 终端,带标签 `dsh.session=<会话 id>` 和 `dsh.title=<会话标题>`(标题随改名同步)。还没运行过的会话,在 DSH 里对它输入 `/herdr`,它就会出现在 Herdr 里,命令会显示它的 hc 地址。会话关闭时,终端随之结束。
69
+
70
+ **2. 从 Herdr 新建一个 DSH 会话。** 下面的 `/repo`、`fix` 都是示例:
71
+
72
+ ```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 "继续,把边界情况也测了"
77
+ ```
78
+
79
+ 新会话的目录如果正好是 DSH 里已有的 Workspace,就加入它;是某个 Workspace 的子目录,就建成“未分组”会话;其他目录会自动添加为新的 Workspace。
80
+
81
+ **3. 接管一个已有会话。**
82
+
83
+ ```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 # 先看到之前的对话,再看到新的
87
+ ```
88
+
89
+ 如果这个会话已经有自动映射的 `dsh-<短 id>` 终端,那个终端会退出,由接管的终端代替。
90
+
91
+ **4. 需要时取消。** `hc interrupt fix` 取消当前回合,相当于在 DSH 里点停止。
92
+
93
+ ## 审批、提问与信任边界
94
+
95
+ 会话等待审批或提问时,DSH 界面照常弹出对话框,hc 终端里也显示同一个问题,状态为 `blocked`。在 hc 侧回答必须加 `--force`:
96
+
97
+ ```sh
98
+ hc send --force fix y # 批准一次;n 为拒绝
99
+ hc send --force fix 1,3 # 提问:选项序号(多选用逗号),也可以写自由文本,或 - 跳过
100
+ ```
101
+
102
+ 这和 hc 对待其他 Agent 的规则一致:hc 拒绝向 blocked 的程序普通 `send`,`--force` 是唯一的闸门。插件本身从不自动回答,什么时候可以用 `--force`,由驱动它的 Agent 的指令决定(见 `hc --skill`)。
103
+
104
+ 需要知道的边界:
105
+
106
+ - **谁能操控会话**:能使用你的 hc 的进程,都能读取和操控已映射的 DSH 会话,和 hc 里的其他终端一样。插件的控制 socket 在 `~/.dsh/herdr-bridge/control.sock`,只在本机。
107
+ - **权限不放大**:hc 发来的消息走 DSH 正常的 prompt 流程,会话原有的沙箱和审批规则照常生效。
108
+ - **来自 hc 的输入会成为用户消息**:`hc send` 的文本,以及 helper 的回报和 hc 通知(完成、blocked、退出),都会作为下一条用户消息进入 DSH 会话并唤醒它。hc 发出的消息带有 `<hc …>` 标记,模型能看出来源,但它们和你亲手输入的消息处在同一个位置。
109
+
110
+ ## DSH 调度其他 Agent
111
+
112
+ 插件给已映射的会话注入 `DSH_HC_TERMINAL`(会话自己的 hc 地址),并注册一个 skill `herdr-bridge`,告诉模型这样启动 helper:
113
+
114
+ ```sh
115
+ hc run --bg --parent "$DSH_HC_TERMINAL" --name helper --cwd /repo claude "…完成后用 hc send $DSH_HC_TERMINAL 汇报"
116
+ ```
117
+
118
+ 通用的 hc 用法来自 `~/.agents/skills/herdr-core`,DSH 会自动发现这个目录。
119
+
120
+ ## 配置
121
+
122
+ 写在 `~/.dsh/profiles/<profile>/cordis.patch.yml` 的 `herdr-bridge` 条目下,全部可选:
123
+
124
+ ```yaml
125
+ - id: herdr-bridge
126
+ config:
127
+ reconnectGraceSeconds: 30 # 插件断开后,终端等待重连的秒数,超时退出(默认 30)
128
+ herdrSpace: s3 # 给终端打 herdr.space 标签,在 Herdr App 的这个 space 里显示
129
+ hc: /path/to/hc # hc 可执行文件,默认是 PATH 上的 hc
130
+ ```
131
+
132
+ ## 故障排查
133
+
134
+ 先看日志 `~/.dsh/herdr-bridge/bridge.log`。
135
+
136
+ | 现象 | 检查 |
137
+ |---|---|
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 里 |
145
+
146
+ ## 已知限制
147
+
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 界面的对话框不消失,不会导致错误批准。
154
+
155
+ 计划和未验证的路径见 [TODO.md](TODO.md)。
156
+
157
+ ## 工作原理
158
+
159
+ 每个 hc 终端里跑一个很薄的代理 `dsh-hc`(用运行 DSH 的同一个 Node,Desktop 上就是它自带的那个),通过本机 socket 连到 DSH 进程里的插件。插件把 DSH 事件渲染成终端文本和 OSC 7501 状态,把终端输入转成 prompt、取消或审批回答。插件重载时代理会重连,hc 地址不变。
160
+
161
+ 架构、状态映射、竞争应答的实现、被否决的方案和测试覆盖,见 [docs/architecture.md](docs/architecture.md)。
162
+
163
+ ## 开发
164
+
165
+ 从源码安装、开发回路和隔离测试环境见 [docs/development.md](docs/development.md),Agent 的工作约定见 [AGENTS.md](AGENTS.md)。简述:
166
+
167
+ ```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)
172
+ scripts/dev.sh down
173
+ scripts/desktop.sh reload # 把改动热重载进真实 Desktop(只改 cordis.patch.yml 里 herdr-bridge 这一项,首次会备份)
174
+ ```
175
+
176
+ ## 许可
177
+
178
+ MIT
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: herdr-bridge
3
+ name: dsh-herdr-bridge
package/index.js ADDED
@@ -0,0 +1,582 @@
1
+ // dsh-herdr-bridge: DSH sessions as ordinary Herdr Core (hc) terminals.
2
+ //
3
+ // Each mirrored session has an hc terminal running `dsh-hc` (proxy.js), a thin pipe that connects
4
+ // to this plugin's control socket. The plugin renders the conversation (render.js) and OSC 7501
5
+ // status into that pipe, and turns keys coming back into prompts, cancellation, and answers.
6
+ // See README.md for the design.
7
+ import net from 'node:net'
8
+ import { execFile } from 'node:child_process'
9
+ import { appendFileSync, chmodSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
10
+ import { basename, join, sep } from 'node:path'
11
+ import { homedir } from 'node:os'
12
+ import { createHash, randomUUID } from 'node:crypto'
13
+ import { fileURLToPath } from 'node:url'
14
+ import { Renderer, crlf, oneLine, osc, style } from './render.js'
15
+
16
+ export const name = 'herdr-bridge'
17
+
18
+ const PROTOCOL = 1
19
+ const PASTE_START = '\x1b[200~'
20
+ const PASTE_END = '\x1b[201~'
21
+ const HC_REQUEST_PREFIX = 'hc-'
22
+ const HISTORY_MESSAGES = 20
23
+
24
+ /** Split terminal input into submitted text (Enter outside a paste) and cancel (lone Esc / Ctrl+C). */
25
+ function inputParser({ submit, cancel, echo }) {
26
+ let text = ''
27
+ let pasting = false
28
+ let held = ''
29
+ return chunk => {
30
+ const s = held + chunk
31
+ held = ''
32
+ if (s === '\x1b') return cancel()
33
+ let i = 0
34
+ while (i < s.length) {
35
+ const rest = s.slice(i)
36
+ if (rest.startsWith(PASTE_START)) { pasting = true; i += PASTE_START.length; continue }
37
+ if (rest.startsWith(PASTE_END)) { pasting = false; i += PASTE_END.length; continue }
38
+ if (rest[0] === '\x1b') {
39
+ if (PASTE_START.startsWith(rest) || PASTE_END.startsWith(rest)) { held = rest; break }
40
+ const seq = /^\x1b(\[[0-9;?]*[ -/]*[@-~]|.)/.exec(rest)
41
+ i += seq ? seq[0].length : 1
42
+ continue
43
+ }
44
+ const c = rest[0]
45
+ i += 1
46
+ if (pasting) { text += c === '\r' ? '\n' : c; continue }
47
+ if (c === '\r') {
48
+ const done = text
49
+ text = ''
50
+ echo('\r\n')
51
+ if (done.trim()) submit(done)
52
+ } else if (c === '\x7f' || c === '\b') {
53
+ if (text) { text = text.slice(0, -1); echo('\b \b') }
54
+ } else if (c === '\x03') {
55
+ cancel()
56
+ } else if (c >= ' ' || c === '\t') {
57
+ text += c
58
+ echo(c)
59
+ }
60
+ }
61
+ }
62
+ }
63
+
64
+ /** Newline-delimited JSON on a socket. */
65
+ function lines(socket, onMessage) {
66
+ let buffer = ''
67
+ socket.setEncoding('utf8')
68
+ socket.on('data', chunk => {
69
+ buffer += chunk
70
+ let end
71
+ while ((end = buffer.indexOf('\n')) >= 0) {
72
+ const line = buffer.slice(0, end)
73
+ buffer = buffer.slice(end + 1)
74
+ if (!line) continue
75
+ let message
76
+ try { message = JSON.parse(line) } catch { continue }
77
+ onMessage(message)
78
+ }
79
+ })
80
+ }
81
+ const send = (socket, message) => { if (!socket.destroyed) socket.write(`${JSON.stringify(message)}\n`) }
82
+
83
+ const realpath = p => { try { return realpathSync(p) } catch { return p } }
84
+
85
+ const SKILL = `# DSH session in Herdr (herdr-bridge)
86
+
87
+ This DSH session is mirrored into Herdr Core (hc) as an ordinary terminal, so you can use \`hc\`
88
+ (see the \`herdr-core\` skill or \`hc --skill\`) to run and coordinate other agents.
89
+
90
+ - \`$DSH_HC_TERMINAL\` is this session's own hc address (empty until the session is mirrored,
91
+ which happens on its first run).
92
+ - hc cannot tell which terminal your shell belongs to, so name yourself explicitly:
93
+ start helpers with \`hc run --bg --parent "$DSH_HC_TERMINAL" --name <name> --cwd <dir> <template|command> "<task>"\`
94
+ and tell them to report with \`hc send $DSH_HC_TERMINAL "<result>"\`.
95
+ - Their reports, and hc's notes about them (finished, blocked, exited), arrive as your next user
96
+ message. After starting helpers, end your turn instead of waiting; you will be woken.
97
+ - Your shell calls time out after about 60 s: if you must wait, use \`hc wait <t> --timeout 50\`
98
+ and repeat, never a bare \`hc wait\`.
99
+ - Without \`$DSH_HC_TERMINAL\`, use \`hc run --bg --no-parent …\` and ask for reports to a file.
100
+ `
101
+
102
+ export function apply(ctx, config = {}) {
103
+ const hc = config.hc || 'hc'
104
+ const herdrSpace = config.herdrSpace || ''
105
+ const grace = Number(config.reconnectGraceSeconds ?? 30)
106
+ const home = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'herdr-bridge')
107
+ mkdirSync(home, { recursive: true })
108
+ const log = (...a) => appendFileSync(join(home, 'bridge.log'), `${new Date().toISOString()} ${a.join(' ')}\n`)
109
+ const socketPath = process.platform === 'win32'
110
+ ? `\\\\.\\pipe\\dsh-herdr-bridge-${createHash('sha1').update(home).digest('hex').slice(0, 12)}`
111
+ : join(home, 'control.sock')
112
+
113
+ // The launcher runs proxy.js with the Node that ships with DSH (this process's executable).
114
+ const launcher = (() => {
115
+ const proxy = fileURLToPath(new URL('./proxy.js', import.meta.url))
116
+ const bin = join(home, 'bin')
117
+ mkdirSync(bin, { recursive: true })
118
+ if (process.platform === 'win32') {
119
+ const file = join(bin, 'dsh-hc.cmd')
120
+ writeFileSync(file, `@echo off\r\nset ELECTRON_RUN_AS_NODE=1\r\nset "DSH_HERDR_SOCKET=${socketPath}"\r\nset "DSH_HERDR_LAUNCHER=${file}"\r\n"${process.execPath}" "${proxy}" %*\r\n`)
121
+ return file
122
+ }
123
+ const q = s => `'${s.replace(/'/g, `'\\''`)}'`
124
+ const file = join(bin, 'dsh-hc')
125
+ writeFileSync(file, `#!/bin/sh\nELECTRON_RUN_AS_NODE=1 DSH_HERDR_SOCKET=${q(socketPath)} DSH_HERDR_LAUNCHER=${q(file)} exec ${q(process.execPath)} ${q(proxy)} "$@"\n`)
126
+ chmodSync(file, 0o755)
127
+ return file
128
+ })()
129
+
130
+ const runHc = (args, done) => execFile(hc, args, (error, stdout, stderr) => {
131
+ if (error) log('hc', args[0], 'failed:', oneLine(stderr || error.message, 300))
132
+ done?.(error, stdout)
133
+ })
134
+
135
+ /** Host session services, present only in hosts that have them (Desktop, `dsh web`). */
136
+ let sessions = null
137
+ /** The Agent registry, to know whether a session is running when its bridge is (re)created. */
138
+ let agents = null
139
+ /** @type {Map<string, Bridge>} by session id */
140
+ const bridges = new Map()
141
+
142
+ class Bridge {
143
+ constructor(sessionId, cwd) {
144
+ this.sessionId = sessionId
145
+ this.cwd = cwd
146
+ this.title = basename(cwd || '') || 'dsh'
147
+ this.terminal = '' // hc address of the terminal mirroring it
148
+ this.status = { state: agents?.get(sessionId)?.status === 'running' ? 'working' : 'idle' }
149
+ this.lastEnd = null
150
+ this.waiter = null // a pending answer typed in hc
151
+ this.conns = new Map() // socket → { auto }
152
+ this.renderer = new Renderer()
153
+ this.launching = false
154
+ bridges.set(sessionId, this)
155
+ this.loadTitle()
156
+ }
157
+
158
+ get short() { return this.sessionId.replace(/^session-/, '').slice(0, 8) }
159
+ get mirrored() { return this.conns.size > 0 || this.launching }
160
+
161
+ out(s) { if (s) for (const socket of this.conns.keys()) send(socket, { t: 'out', d: s }) }
162
+ set(state, extra = {}) {
163
+ this.status = { state, ...extra }
164
+ this.out(osc(this.status, this.title))
165
+ }
166
+ retitle(title) {
167
+ title = oneLine(title, 80)
168
+ if (!title || title === this.title) return
169
+ this.title = title
170
+ this.out(osc(this.status, this.title))
171
+ if (this.terminal) runHc(['label', this.terminal, `dsh.title=${title}`])
172
+ }
173
+
174
+ async loadTitle() {
175
+ try {
176
+ const item = (await sessions?.sessionController.list({}, new AbortController().signal))
177
+ ?.items.find(i => i.sessionId === this.sessionId)
178
+ this.retitle(item?.projections?.values?.title)
179
+ } catch (error) {
180
+ log('title lookup failed', this.sessionId, error?.message ?? error)
181
+ }
182
+ }
183
+
184
+ /** Recent conversation, rendered like the live one, for a terminal that just joined. */
185
+ async history(socket) {
186
+ try {
187
+ const page = await sessions?.sessionController.page(
188
+ { address: { kind: 'session', sessionId: this.sessionId }, maxMessages: HISTORY_MESSAGES },
189
+ new AbortController().signal)
190
+ const replay = new Renderer()
191
+ replay.cols = this.renderer.cols
192
+ // History records wrap the session event: { type: 'event', event }.
193
+ 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`) })
195
+ } catch (error) {
196
+ log('history failed', this.sessionId, error?.message ?? error)
197
+ }
198
+ }
199
+
200
+ async attach(socket, hello, origin) {
201
+ this.launching = false
202
+ this.conns.set(socket, { auto: Boolean(hello.auto) })
203
+ if (hello.term) {
204
+ const changed = this.terminal !== hello.term
205
+ this.terminal = hello.term
206
+ if (changed) runHc(['label', this.terminal, `dsh.session=${this.sessionId}`, `dsh.title=${this.title}`])
207
+ }
208
+ if (hello.cols) this.renderer.cols = hello.cols
209
+ socket.on('close', () => this.conns.delete(socket))
210
+ send(socket, { t: 'welcome', v: PROTOCOL, session: this.sessionId, origin, grace })
211
+ send(socket, { t: 'out', d: osc(this.status, this.title) })
212
+ const feed = inputParser({
213
+ submit: text => this.submit(text),
214
+ cancel: () => this.cancel(),
215
+ echo: s => send(socket, { t: 'out', d: s }),
216
+ })
217
+ lines(socket, message => {
218
+ if (message.t === 'in' && typeof message.d === 'string') feed(message.d)
219
+ else if (message.t === 'size' && message.cols) this.renderer.cols = message.cols
220
+ })
221
+ if (!hello.resumed) {
222
+ 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)
224
+ }
225
+ }
226
+
227
+ submit(text) {
228
+ if (this.waiter) {
229
+ const resolve = this.waiter
230
+ this.waiter = null
231
+ return resolve(text.trim())
232
+ }
233
+ return this.prompt(text)
234
+ }
235
+
236
+ /** Wait for the next line typed in hc; null when withdrawn. */
237
+ ask(signal) {
238
+ return new Promise(resolve => {
239
+ if (signal?.aborted) return resolve(null)
240
+ this.waiter = resolve
241
+ signal?.addEventListener('abort', () => {
242
+ if (this.waiter === resolve) { this.waiter = null; resolve(null) }
243
+ }, { once: true })
244
+ })
245
+ }
246
+
247
+ async prompt(text) {
248
+ const controller = sessions?.sessionController
249
+ if (!controller) return this.out(crlf('[bridge] this DSH host cannot take prompts\n'))
250
+ try {
251
+ await controller.prompt({
252
+ requestId: HC_REQUEST_PREFIX + randomUUID(),
253
+ sessionId: this.sessionId,
254
+ mode: 'queue',
255
+ content: [{ type: 'text', text }],
256
+ }, new AbortController().signal)
257
+ } catch (error) {
258
+ log('prompt failed', this.sessionId, error?.message ?? error)
259
+ this.out(crlf(`${style.red(`[bridge] prompt failed: ${error?.message ?? error}`)}\n`))
260
+ }
261
+ }
262
+
263
+ async cancel() {
264
+ try {
265
+ await sessions?.sessionController.cancel({ sessionId: this.sessionId })
266
+ } catch (error) {
267
+ log('cancel failed', this.sessionId, error?.message ?? error)
268
+ }
269
+ }
270
+
271
+ /** Mirror into hc: a terminal named dsh-<short id> running `dsh-hc attach`. */
272
+ launch() {
273
+ if (this.mirrored) return
274
+ this.launching = true
275
+ const args = ['run', '--bg', '--rm', '--no-parent', '--json', '--name', `dsh-${this.short}`,
276
+ '--label', `dsh.session=${this.sessionId}`, '--label', `dsh.title=${this.title}`]
277
+ if (herdrSpace) args.push('--label', `herdr.space=${herdrSpace}`)
278
+ if (this.cwd) args.push('--cwd', this.cwd)
279
+ args.push('--', launcher, 'attach', '--session', this.sessionId, '--auto')
280
+ // A failure is usually the name being taken: that terminal is still ours and reconnecting.
281
+ runHc(args, error => { if (error) this.launching = false })
282
+ }
283
+
284
+ /** Close every terminal mirroring this session; `except` keeps one. */
285
+ release(reason, except) {
286
+ for (const socket of this.conns.keys()) {
287
+ if (socket === except) continue
288
+ send(socket, { t: 'bye', reason })
289
+ socket.end()
290
+ this.conns.delete(socket)
291
+ }
292
+ }
293
+
294
+ dispose(reason) {
295
+ this.waiter?.(null)
296
+ this.release(reason)
297
+ bridges.delete(this.sessionId)
298
+ }
299
+ }
300
+
301
+ const sessionOf = agent => agent?.session
302
+ const isSubagent = agent => agent?.session.header?.origin === 'subagent'
303
+ const bridgeFor = (sessionId, cwd) => bridges.get(sessionId) ?? new Bridge(sessionId, cwd)
304
+ /** A top-level agent's bridge; a subagent speaks through its parent's. */
305
+ const bridgeOf = agent => {
306
+ const session = sessionOf(agent)
307
+ if (!session) return undefined
308
+ if (!isSubagent(agent)) return bridgeFor(session.id, session.header?.cwd)
309
+ return session.header?.parentSession ? bridges.get(session.header.parentSession) : undefined
310
+ }
311
+
312
+ ctx.on('agent/status', ({ agent, status }) => {
313
+ if (isSubagent(agent)) return
314
+ const bridge = bridgeOf(agent)
315
+ if (status === 'running') {
316
+ bridge.lastEnd = null
317
+ bridge.launch()
318
+ bridge.set('working')
319
+ return
320
+ }
321
+ const end = bridge.lastEnd
322
+ if (!end || end.kind === 'completed' || end.kind === 'max-tokens') bridge.set(end ? 'done' : 'idle')
323
+ else if (end.kind === 'aborted') bridge.set('idle')
324
+ else bridge.set('error', { msg: oneLine(end.error?.message ?? end.kind) })
325
+ })
326
+
327
+ ctx.on('session/event', (session, event) => {
328
+ if (session.header?.origin === 'subagent') return
329
+ const bridge = bridges.get(session.id)
330
+ if (!bridge) return
331
+ if (event.type === 'turn/end') bridge.lastEnd = event.data?.reason ?? { kind: 'completed' }
332
+ else if (event.type === 'session/title') bridge.retitle(event.data?.title)
333
+ bridge.out(bridge.renderer.event(event))
334
+ })
335
+
336
+ ctx.on('agent/assistant-stream', ({ agent, frame }) => {
337
+ if (isSubagent(agent)) return
338
+ const bridge = bridges.get(agent.session.id)
339
+ if (bridge) bridge.out(bridge.renderer.stream(frame))
340
+ })
341
+
342
+ ctx.on('agent/disposed', ({ agent }) => {
343
+ if (!isSubagent(agent)) bridges.get(agent.session.id)?.dispose('the DSH session closed')
344
+ })
345
+
346
+ // Blocked: Desktop and hc race to answer; the first answer wins and withdraws the other.
347
+ // DSH forwards an approval/question to Desktop's UI and withdraws that dialog when the request's
348
+ // signal aborts, so we hand the rest of the chain a signal we can abort. Prepended: the shipped
349
+ // answerer never calls next(), so a later listener would never run.
350
+ const WITHDRAWN = Symbol('withdrawn')
351
+ const race = (kind, describe, render, fromHc, settledByDesktop) => async (req, next) => {
352
+ const bridge = req?.agent && bridgeOf(req.agent)
353
+ if (!bridge || bridge.conns.size === 0) return next()
354
+ const ours = new AbortController()
355
+ const original = req.signal
356
+ try { req.signal = original ? AbortSignal.any([original, ours.signal]) : ours.signal } catch {}
357
+ bridge.set('blocked', { kind, msg: oneLine(describe(req)) })
358
+ bridge.out(render(req))
359
+ const desktop = Promise.resolve().then(next).then(
360
+ value => ({ from: 'desktop', value }),
361
+ error => ({ from: 'desktop', error }),
362
+ )
363
+ const answered = fromHc(bridge, req, ours.signal).then(value => ({ from: 'hc', value }))
364
+ try {
365
+ let first = await Promise.race([desktop, answered])
366
+ // No Desktop answerer (e.g. no window): keep waiting for hc.
367
+ if (first.from === 'desktop' && !settledByDesktop(first)) {
368
+ const later = await answered
369
+ if (later.value !== WITHDRAWN) first = later
370
+ }
371
+ // The request was withdrawn (the turn was cancelled): Desktop's side settles on its own.
372
+ if (first.from === 'hc' && first.value === WITHDRAWN) first = await desktop
373
+ if (first.from === 'hc') {
374
+ ours.abort(new Error('answered in hc'))
375
+ desktop.catch(() => {})
376
+ bridge.out(crlf(` ${style.dim(`answered in hc: ${oneLine(JSON.stringify(first.value), 120)}`)}\n`))
377
+ return first.value
378
+ }
379
+ ours.abort(new Error('answered in DSH Desktop'))
380
+ bridge.out(crlf(` ${style.dim('answered in DSH Desktop')}\n`))
381
+ if (first.error) throw first.error
382
+ return first.value
383
+ } finally {
384
+ try { req.signal = original } catch {}
385
+ if (bridge.status.state === 'blocked') bridge.set('working')
386
+ }
387
+ }
388
+
389
+ const approvalFromHc = async (bridge, req, signal) => {
390
+ for (;;) {
391
+ const answer = await bridge.ask(AbortSignal.any([signal, req.signal].filter(Boolean)))
392
+ if (answer === null) return WITHDRAWN
393
+ if (/^(y|yes|allow|1)$/i.test(answer)) return 'allowed-once'
394
+ if (/^(n|no|reject|deny|2)$/i.test(answer)) return 'rejected'
395
+ bridge.out(crlf(` ${style.dim('y = allow once, n = reject')}\n`))
396
+ }
397
+ }
398
+
399
+ const questionsFromHc = async (bridge, req, signal) => {
400
+ const answers = []
401
+ const questions = req.questions ?? []
402
+ const stop = AbortSignal.any([signal, req.signal].filter(Boolean))
403
+ for (const [index, q] of questions.entries()) {
404
+ if (index > 0) bridge.out(bridge.renderer.question(q, index, questions.length))
405
+ const answer = await bridge.ask(stop)
406
+ if (answer === null) return WITHDRAWN
407
+ const options = q.options ?? []
408
+ const picks = /^\d+(\s*,\s*\d+)*$/.test(answer)
409
+ ? answer.split(',').map(n => options[Number(n) - 1]?.label).filter(Boolean)
410
+ : null
411
+ if (answer === '-') answers.push({ id: q.id, selected: [] })
412
+ else if (picks?.length) answers.push({ id: q.id, selected: q.multiSelect ? picks : picks.slice(0, 1) })
413
+ else answers.push({ id: q.id, selected: [], custom: answer })
414
+ }
415
+ return { answers }
416
+ }
417
+
418
+ ctx.on('approval/request', race('permission',
419
+ r => `${r.toolName}${r.reason ? `: ${r.reason}` : ''}`,
420
+ r => bridgeOf(r.agent).renderer.approval(r),
421
+ approvalFromHc,
422
+ first => !first.error && first.value !== 'unavailable',
423
+ ), { prepend: true })
424
+ ctx.on('user-questions/request', race('question',
425
+ r => (r.questions ?? []).map(q => q.question ?? q.header ?? '').join(' / ') || 'question',
426
+ r => (r.questions?.length ? bridgeOf(r.agent).renderer.question(r.questions[0], 0, r.questions.length) : ''),
427
+ questionsFromHc,
428
+ first => !first.error,
429
+ ), { prepend: true })
430
+
431
+ // The control socket: dsh-hc terminals (new / attach / reconnect) and `dsh-hc ls`.
432
+ // Only hosts that can create and prompt sessions serve it, and a live owner is never displaced.
433
+ ctx.inject(['sessionController', 'workspaceRegistry'], c => {
434
+ sessions = c
435
+
436
+ /** The folder's Workspace; a subfolder of one goes ungrouped; anything else gets its own. */
437
+ const placement = async cwd => {
438
+ const real = realpath(cwd)
439
+ const spaces = c.workspaceRegistry.list().map(w => ({ id: w.id, path: realpath(w.path) }))
440
+ const exact = spaces.find(w => w.path === real)
441
+ if (exact) return { workspaceId: exact.id }
442
+ if (spaces.some(w => real.startsWith(w.path + sep))) return { cwd: real }
443
+ return { workspaceId: (await c.workspaceRegistry.create(real)).id }
444
+ }
445
+
446
+ const recent = async days => {
447
+ const { items } = await c.sessionController.list({}, new AbortController().signal)
448
+ const since = Date.now() - days * 86_400_000
449
+ return items
450
+ .filter(i => !i.origin && !i.blank && i.updatedAt >= since)
451
+ .sort((a, b) => b.updatedAt - a.updatedAt)
452
+ .map(i => ({
453
+ sessionId: i.sessionId,
454
+ title: i.projections?.values?.title ?? '',
455
+ cwd: i.cwd ?? '',
456
+ updatedAt: i.updatedAt,
457
+ running: i.running,
458
+ terminal: bridges.get(i.sessionId)?.terminal ?? '',
459
+ }))
460
+ }
461
+
462
+ const resolve = async (hello) => {
463
+ const items = await recent(36500)
464
+ if (hello.session) return items.find(i => i.sessionId === hello.session) ?? { sessionId: hello.session }
465
+ const query = String(hello.query ?? '').trim()
466
+ const hits = items.filter(i => i.sessionId === query || i.sessionId === `session-${query}`
467
+ || i.sessionId.replace(/^session-/, '').startsWith(query))
468
+ const matches = hits.length ? hits : items.filter(i => i.title.toLowerCase().includes(query.toLowerCase()))
469
+ if (matches.length === 1) return matches[0]
470
+ if (!matches.length) throw new Error(`no DSH session matches "${query}" (see dsh-hc ls)`)
471
+ throw new Error(`"${query}" matches ${matches.length} sessions: ${matches.slice(0, 5)
472
+ .map(m => `${m.sessionId.replace(/^session-/, '').slice(0, 8)} ${m.title}`).join('; ')}`)
473
+ }
474
+
475
+ const serve = socket => {
476
+ let greeted = false
477
+ socket.on('error', () => {})
478
+ lines(socket, async message => {
479
+ if (greeted || message.t !== 'hello') return
480
+ greeted = true
481
+ socket.removeAllListeners('data')
482
+ try {
483
+ if (message.v !== PROTOCOL) throw new Error(`dsh-hc speaks protocol ${message.v}; this plugin speaks ${PROTOCOL}`)
484
+ if (message.mode === 'ls') {
485
+ send(socket, { t: 'ls', items: await recent(Number(message.days) || 7) })
486
+ return socket.end()
487
+ }
488
+ if (message.mode === 'new') {
489
+ if (!message.cwd) throw new Error('missing folder')
490
+ const { sessionId } = await c.sessionController.create(await placement(message.cwd))
491
+ return bridgeFor(sessionId, realpath(message.cwd)).attach(socket, message, 'hc')
492
+ }
493
+ if (message.mode === 'attach') {
494
+ const found = await resolve(message)
495
+ const bridge = bridgeFor(found.sessionId, found.cwd)
496
+ if (!message.auto && !message.resumed) {
497
+ // A Herdr agent takes the session over: the automatic mirror makes way for it.
498
+ for (const [other, meta] of bridge.conns) {
499
+ if (meta.auto) { send(other, { t: 'bye', reason: `taken over by ${message.term || 'another hc terminal'}` }); other.end(); bridge.conns.delete(other) }
500
+ }
501
+ }
502
+ const origin = message.resumed ? (message.origin ?? 'desktop') : (message.auto ? 'desktop' : 'hc')
503
+ return bridge.attach(socket, message, origin)
504
+ }
505
+ throw new Error(`unknown mode ${message.mode}`)
506
+ } catch (error) {
507
+ log('control', message.mode, 'failed:', error?.message ?? error)
508
+ send(socket, { t: 'error', message: String(error?.message ?? error) })
509
+ socket.end()
510
+ }
511
+ })
512
+ }
513
+
514
+ const control = net.createServer(serve)
515
+ control.on('error', error => log('control socket error', error.message))
516
+ const probe = net.connect(socketPath)
517
+ probe.on('connect', () => { probe.destroy(); log('control socket owned by another host; not serving it') })
518
+ probe.on('error', () => {
519
+ if (process.platform !== 'win32') rmSync(socketPath, { force: true })
520
+ control.listen(socketPath)
521
+ })
522
+ c.effect(() => () => { control.close(); sessions = null }, 'herdr-bridge: close control socket')
523
+ })
524
+
525
+ // /herdr: mirror this session into Herdr now and show its hc address.
526
+ ctx.inject(['commands'], c => {
527
+ c.commands.register({
528
+ name: 'herdr',
529
+ description: 'Show this session in Herdr (hc) and print its hc address',
530
+ handler: async ({ agent }) => {
531
+ if (!agent || isSubagent(agent)) return { kind: 'error', text: 'Only a top-level session can be shown in Herdr.' }
532
+ const bridge = bridgeOf(agent)
533
+ bridge.launch()
534
+ for (let i = 0; i < 50 && !bridge.terminal; i++) await new Promise(r => setTimeout(r, 100))
535
+ if (!bridge.terminal) return { kind: 'error', text: 'Could not open an hc terminal; see ~/.dsh/herdr-bridge/bridge.log' }
536
+ return { kind: 'success', text: `In Herdr as ${bridge.terminal} (dsh-${bridge.short}). A Herdr agent can use: hc send ${bridge.terminal} "…" / hc read ${bridge.terminal}` }
537
+ },
538
+ })
539
+ })
540
+
541
+ // Reverse direction: each session's shell learns its own hc address; the model learns how to use it.
542
+ ctx.inject(['shellEnv'], c => {
543
+ c.shellEnv.register({
544
+ name: 'herdr-bridge',
545
+ variables: { DSH_HC_TERMINAL: { description: "This session's own Herdr Core (hc) terminal address." } },
546
+ resolve: execution => {
547
+ const bridge = execution.agent && bridgeOf(execution.agent)
548
+ return bridge?.terminal ? { DSH_HC_TERMINAL: bridge.terminal } : {}
549
+ },
550
+ })
551
+ })
552
+ ctx.inject(['skills'], c => {
553
+ c.skills.register({
554
+ name: 'herdr-bridge',
555
+ description: 'Use hc from this DSH session: your own hc address ($DSH_HC_TERMINAL), starting helper agents that report back to you, and waiting without blocking.',
556
+ whenToUse: 'Before using hc (Herdr Core) to start, message, or wait for other agents or terminals.',
557
+ source: 'runtime',
558
+ content: SKILL,
559
+ })
560
+ })
561
+
562
+ // After a reload, proxies reconnect on their own; mirror the running sessions that did not.
563
+ ctx.inject(['agents'], c => {
564
+ agents = c.agents
565
+ const timer = setTimeout(() => {
566
+ for (const agent of c.agents.list()) {
567
+ if (agent.status === 'running' && !isSubagent(agent)) bridgeOf(agent).launch()
568
+ }
569
+ }, 3000)
570
+ c.effect(() => () => { clearTimeout(timer); agents = null }, 'herdr-bridge: re-mirror running sessions')
571
+ })
572
+
573
+ ctx.effect(() => () => {
574
+ // Proxies keep their terminals and reconnect to the next instance within the grace period.
575
+ for (const bridge of bridges.values()) {
576
+ bridge.waiter?.(null)
577
+ for (const socket of bridge.conns.keys()) socket.destroy()
578
+ }
579
+ bridges.clear()
580
+ }, 'herdr-bridge: drop connections')
581
+ log('loaded')
582
+ }
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "dsh-herdr-bridge",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Bridge DeepSeek Harness (DSH) sessions into Herdr Core as hc terminals",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "render.js",
13
+ "proxy.js",
14
+ "cordis.patch.yml",
15
+ "README.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=24.9"
19
+ },
20
+ "dsh": {
21
+ "bundle": {
22
+ "patch": "./cordis.patch.yml"
23
+ }
24
+ },
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/Hariketsu/dsh-herdr-bridge.git"
29
+ },
30
+ "keywords": [
31
+ "dsh",
32
+ "deepseek-harness",
33
+ "herdr",
34
+ "herdr-core",
35
+ "hc",
36
+ "agent"
37
+ ]
38
+ }
package/proxy.js ADDED
@@ -0,0 +1,204 @@
1
+ // dsh-hc: the program inside a DSH session's hc terminal, and a small CLI.
2
+ // It is a thin pipe: raw keys and window size go to the dsh-herdr-bridge plugin; rendered text
3
+ // (including OSC 7501 status) comes back and is written as is. When the plugin goes away (reload,
4
+ // update, Desktop quitting) it keeps the terminal and reconnects within a grace period.
5
+ //
6
+ // dsh-hc new a new DSH session in the current folder (hc template `dsh`)
7
+ // dsh-hc attach <session id | title> take over an existing DSH session
8
+ // dsh-hc ls [--days N] [--json] recent DSH sessions
9
+ // dsh-hc install-template install the hc template `dsh`
10
+ //
11
+ // Started through the launcher the plugin writes ($DSH_HOME/herdr-bridge/bin/dsh-hc), which sets
12
+ // DSH_HERDR_SOCKET and runs this file with the Node that ships with DSH Desktop.
13
+ import net from 'node:net'
14
+ import { mkdirSync, writeFileSync } from 'node:fs'
15
+ import { homedir } from 'node:os'
16
+ import { join } from 'node:path'
17
+
18
+ const PROTOCOL = 1
19
+ const SOCKET = process.env.DSH_HERDR_SOCKET
20
+ const LAUNCHER = process.env.DSH_HERDR_LAUNCHER
21
+ const dim = s => `\x1b[2m${s}\x1b[22m`
22
+ const b64 = s => Buffer.from(s).toString('base64')
23
+ const errorStatus = msg => `\x1b]7501;state=error:app=dsh:msg=${b64(msg)}\x1b\\`
24
+
25
+ function fail(message, code = 1) {
26
+ process.stderr.write(`dsh-hc: ${message}\n`)
27
+ process.exit(code)
28
+ }
29
+
30
+ /** Newline-delimited JSON on a socket. */
31
+ function lines(socket, onMessage) {
32
+ let buffer = ''
33
+ socket.setEncoding('utf8')
34
+ socket.on('data', chunk => {
35
+ buffer += chunk
36
+ let end
37
+ while ((end = buffer.indexOf('\n')) >= 0) {
38
+ const line = buffer.slice(0, end)
39
+ buffer = buffer.slice(end + 1)
40
+ if (!line) continue
41
+ let message
42
+ try { message = JSON.parse(line) } catch { continue }
43
+ onMessage(message)
44
+ }
45
+ })
46
+ }
47
+ const send = (socket, message) => { if (socket && !socket.destroyed) socket.write(`${JSON.stringify(message)}\n`) }
48
+
49
+ /** One request, one reply: `ls`. */
50
+ function request(hello) {
51
+ return new Promise((resolve, reject) => {
52
+ if (!SOCKET) return reject(new Error('DSH_HERDR_SOCKET is not set; run dsh-hc through its launcher'))
53
+ const socket = net.connect(SOCKET)
54
+ socket.on('error', () => reject(new Error('DSH Desktop is not running, or dsh-herdr-bridge is not loaded')))
55
+ socket.on('connect', () => send(socket, { t: 'hello', v: PROTOCOL, ...hello }))
56
+ lines(socket, message => {
57
+ socket.end()
58
+ if (message.t === 'error') reject(new Error(message.message))
59
+ else resolve(message)
60
+ })
61
+ })
62
+ }
63
+
64
+ /** Interactive terminal mode: `new` and `attach`. */
65
+ function terminal(first) {
66
+ const out = process.stdout
67
+ const size = () => ({ cols: out.columns || 100, rows: out.rows || 30 })
68
+ let socket = null
69
+ let welcome = null // { session, origin, grace } once the plugin accepted us
70
+ let live = false
71
+ let leaving = false
72
+ let deadline = 0
73
+
74
+ if (process.stdin.isTTY) process.stdin.setRawMode(true)
75
+ out.write('\x1b[?2004h') // bracketed paste: hc send arrives as one paste
76
+ process.stdin.setEncoding('utf8')
77
+ process.stdin.on('data', d => {
78
+ if (live) send(socket, { t: 'in', d })
79
+ else out.write(dim('\r\n(not sent: reconnecting to DSH Desktop)\r\n'))
80
+ })
81
+ out.on('resize', () => send(socket, { t: 'size', ...size() }))
82
+
83
+ const leave = code => {
84
+ leaving = true
85
+ socket?.destroy()
86
+ process.exit(code)
87
+ }
88
+
89
+ const connect = hello => {
90
+ socket = net.connect(SOCKET)
91
+ socket.on('connect', () => send(socket, { t: 'hello', v: PROTOCOL, term: process.env.HC_TERMINAL_ID ?? '', ...size(), ...hello }))
92
+ lines(socket, message => {
93
+ if (message.t === 'welcome') {
94
+ const again = welcome !== null
95
+ welcome = { session: message.session, origin: message.origin, grace: message.grace ?? 30 }
96
+ live = true
97
+ deadline = 0
98
+ if (again) out.write(dim('⋯ reconnected\r\n'))
99
+ } else if (message.t === 'out') {
100
+ out.write(message.d)
101
+ } else if (message.t === 'bye') {
102
+ if (message.reason) out.write(dim(`\r\n⋯ ${message.reason}\r\n`))
103
+ leave(0)
104
+ } else if (message.t === 'error') {
105
+ out.write(`${errorStatus(message.message)}dsh-hc: ${message.message}\r\n`)
106
+ leave(1)
107
+ }
108
+ })
109
+ socket.on('error', () => {})
110
+ socket.on('close', () => {
111
+ live = false
112
+ if (leaving) return
113
+ if (!welcome) {
114
+ // Never got in: the plugin is not there (or refused). Report it so hc and its caller see why.
115
+ out.write(`${errorStatus('DSH Desktop is not running, or dsh-herdr-bridge is not loaded')}`
116
+ + 'dsh-hc: DSH Desktop is not running, or dsh-herdr-bridge is not loaded\r\n')
117
+ return setTimeout(() => leave(1), 200)
118
+ }
119
+ if (!deadline) {
120
+ deadline = Date.now() + welcome.grace * 1000
121
+ out.write(dim(`\r\n⋯ DSH Desktop disconnected; reconnecting for up to ${welcome.grace}s\r\n`))
122
+ }
123
+ if (Date.now() > deadline) {
124
+ out.write(dim('⋯ DSH Desktop did not come back\r\n'))
125
+ return leave(0)
126
+ }
127
+ setTimeout(() => {
128
+ connect({ mode: 'attach', session: welcome.session, origin: welcome.origin, resumed: true })
129
+ }, 1000)
130
+ })
131
+ }
132
+
133
+ if (!SOCKET) fail('DSH_HERDR_SOCKET is not set; run dsh-hc through its launcher')
134
+ connect(first)
135
+ }
136
+
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')
140
+ }
141
+
142
+ function installTemplate() {
143
+ 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`)
155
+ }
156
+
157
+ // Terminal columns a string takes: East Asian wide and full-width characters take two.
158
+ const WIDE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
159
+ const width = s => [...s].reduce((w, ch) => w + (WIDE.test(ch) ? 2 : 1), 0)
160
+ const pad = (s, n) => s + ' '.repeat(Math.max(0, n - width(s)))
161
+
162
+ function table(items) {
163
+ if (!items.length) return 'no DSH sessions in that period\n'
164
+ const rows = items.map(i => [
165
+ i.sessionId.replace(/^session-/, '').slice(0, 8),
166
+ new Date(i.updatedAt).toISOString().slice(0, 16).replace('T', ' '),
167
+ i.running ? 'running' : '',
168
+ i.terminal || '',
169
+ i.title || '(untitled)',
170
+ i.cwd || '',
171
+ ])
172
+ const head = ['ID', 'UPDATED', 'STATE', 'HC', 'TITLE', 'FOLDER']
173
+ const widths = head.map((h, c) => Math.max(width(h), ...rows.map(r => width(r[c]))))
174
+ return [head, ...rows].map(r => r.map((v, c) => (c === r.length - 1 ? v : pad(v, widths[c]))).join(' ')).join('\n') + '\n'
175
+ }
176
+
177
+ const [command, ...args] = process.argv.slice(2)
178
+ const flag = name => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined }
179
+
180
+ switch (command) {
181
+ case 'new':
182
+ terminal({ mode: 'new', cwd: process.cwd() })
183
+ break
184
+ case 'attach': {
185
+ const session = flag('--session')
186
+ const query = session ?? args.filter(a => !a.startsWith('--')).join(' ')
187
+ if (!query) fail('usage: dsh-hc attach <session id | title>', 2)
188
+ terminal({ mode: 'attach', ...(session ? { session } : { query }), auto: args.includes('--auto') })
189
+ break
190
+ }
191
+ case 'ls': {
192
+ const days = Number(flag('--days') ?? 7)
193
+ request({ mode: 'ls', days }).then(
194
+ reply => process.stdout.write(args.includes('--json') ? `${JSON.stringify(reply.items, null, 2)}\n` : table(reply.items)),
195
+ error => fail(error.message),
196
+ )
197
+ break
198
+ }
199
+ case 'install-template':
200
+ installTemplate()
201
+ break
202
+ default:
203
+ fail('usage: dsh-hc new | attach <session id | title> | ls [--days N] [--json] | install-template', 2)
204
+ }
package/render.js ADDED
@@ -0,0 +1,150 @@
1
+ // Turn DSH session events into terminal text, laid out the way TUI agents (Claude Code, pi) show
2
+ // a conversation: the prompt, streamed assistant text, one line per tool call with its result, and
3
+ // a closing line per turn. Pure functions plus a small per-session state object.
4
+
5
+ const CSI = '\x1b['
6
+ export const style = {
7
+ bold: s => `${CSI}1m${s}${CSI}22m`,
8
+ dim: s => `${CSI}2m${s}${CSI}22m`,
9
+ red: s => `${CSI}31m${s}${CSI}39m`,
10
+ green: s => `${CSI}32m${s}${CSI}39m`,
11
+ yellow: s => `${CSI}33m${s}${CSI}39m`,
12
+ cyan: s => `${CSI}36m${s}${CSI}39m`,
13
+ }
14
+
15
+ /** Raw-mode terminals need CR LF. */
16
+ export const crlf = s => s.replace(/\r?\n/g, '\r\n')
17
+ export const oneLine = (s, n = 120) => String(s ?? '').replace(/\s+/g, ' ').trim().slice(0, n)
18
+ const clip = (s, n) => (s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s)
19
+ const indent = (s, pad) => s.replace(/\n/g, `\n${pad}`)
20
+ const b64 = s => Buffer.from(s).toString('base64')
21
+
22
+ /** OSC 7501 Program Status Protocol report. */
23
+ export function osc({ state, msg, kind }, title) {
24
+ const parts = [`state=${state}`, 'app=dsh']
25
+ if (title) parts.push(`title=${b64(title)}`)
26
+ if (msg) parts.push(`msg=${b64(msg)}`)
27
+ if (kind) parts.push(`kind=${kind}`)
28
+ return `\x1b]7501;${parts.join(':')}\x1b\\`
29
+ }
30
+
31
+ const textOf = content => (Array.isArray(content) ? content : [])
32
+ .filter(p => p?.type === 'text' && typeof p.text === 'string')
33
+ .map(p => p.text)
34
+ .join('\n')
35
+
36
+ /** The most telling argument of a tool call, for its one-line summary. */
37
+ function toolSummary(args) {
38
+ if (typeof args === 'string') {
39
+ try { args = JSON.parse(args) } catch { return oneLine(args) }
40
+ }
41
+ if (!args || typeof args !== 'object') return ''
42
+ for (const key of ['description', 'command', 'file_path', 'path', 'pattern', 'query', 'url', 'name', 'prompt']) {
43
+ if (typeof args[key] === 'string' && args[key].trim()) return oneLine(args[key])
44
+ }
45
+ if (Array.isArray(args.questions)) return oneLine(args.questions.map(q => q?.question).join(' / '))
46
+ return ''
47
+ }
48
+
49
+ function seconds(ms) {
50
+ const s = Math.max(0, Math.round(ms / 1000))
51
+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
52
+ }
53
+
54
+ /** Per-session rendering state: which steps were streamed live, and when each turn started. */
55
+ export class Renderer {
56
+ constructor() {
57
+ this.cols = 100
58
+ this.streamed = new Set()
59
+ this.turnStarted = new Map()
60
+ this.inText = false
61
+ }
62
+
63
+ /** A durable session event (live or replayed from history) to terminal text. */
64
+ event(event) {
65
+ const data = event?.data ?? {}
66
+ switch (event?.type) {
67
+ case 'turn/start':
68
+ this.turnStarted.set(data.turn, event.time ?? Date.now())
69
+ return ''
70
+ case 'user/message': {
71
+ if (data.source?.kind !== 'user') return ''
72
+ const text = textOf(data.content).trim()
73
+ return text ? crlf(`\n${style.bold('❯')} ${indent(text, ' ')}\n`) : ''
74
+ }
75
+ case 'assistant/message': {
76
+ // Streamed already, even if the stream began before this renderer existed (a reload).
77
+ if (this.inText) { this.inText = false; return '\r\n' }
78
+ if (this.streamed.has(`${data.turn}:${data.step}`)) return ''
79
+ const text = textOf(data.message?.content).trim()
80
+ return text ? crlf(`${style.cyan('⏺')} ${indent(text, ' ')}\n`) : ''
81
+ }
82
+ case 'tool/call': {
83
+ const summary = toolSummary(data.arguments)
84
+ const head = `${style.cyan('⏺')} ${style.bold(data.name ?? 'tool')}`
85
+ return crlf(`${head}${summary ? ` ${style.dim(clip(summary, this.cols - (data.name?.length ?? 4) - 4))}` : ''}\n`)
86
+ }
87
+ case 'tool/result': {
88
+ const message = data.message ?? {}
89
+ const lines = textOf(message.content).split('\n').map(l => l.trimEnd())
90
+ .filter(l => l.trim() && !/^\[(stdout|stderr)\]$/.test(l.trim()))
91
+ const failed = message.isError || data.error !== undefined
92
+ let first = lines[0] ?? (failed ? 'failed' : 'done')
93
+ first = clip(first.trim(), this.cols - 16)
94
+ const more = lines.length > 1 ? style.dim(` (+${lines.length - 1} lines)`) : ''
95
+ return crlf(` ${style.dim('⎿')} ${failed ? style.red(first) : style.dim(first)}${more}\n`)
96
+ }
97
+ case 'turn/end': {
98
+ const reason = data.reason ?? { kind: 'completed' }
99
+ const started = this.turnStarted.get(data.turn)
100
+ this.turnStarted.delete(data.turn)
101
+ const took = started !== undefined ? ` · ${seconds((event.time ?? Date.now()) - started)}` : ''
102
+ if (reason.kind === 'completed' || reason.kind === 'max-tokens') return crlf(`${style.dim(`✻ done${took}`)}\n`)
103
+ if (reason.kind === 'aborted') return crlf(`${style.yellow('✻ cancelled')}${style.dim(took)}\n`)
104
+ if (reason.kind === 'interrupted') return ''
105
+ return crlf(`${style.red(`✻ error: ${oneLine(reason.error?.message ?? reason.kind, 200)}`)}\n`)
106
+ }
107
+ default:
108
+ return ''
109
+ }
110
+ }
111
+
112
+ /** A live assistant-stream frame to terminal text (text deltas only). */
113
+ stream(frame) {
114
+ if (frame?.type === 'start') {
115
+ this.step = `${frame.turn}:${frame.step}`
116
+ this.inText = false
117
+ return ''
118
+ }
119
+ if (frame?.type === 'chunk' && frame.chunk?.type === 'text-delta' && frame.chunk.text) {
120
+ const lead = this.inText ? '' : `${style.cyan('⏺')} `
121
+ this.inText = true
122
+ if (this.step) this.streamed.add(this.step)
123
+ return lead + crlf(frame.chunk.text.replace(/\n/g, '\n '))
124
+ }
125
+ if (frame?.type === 'end') {
126
+ const close = this.inText ? '\r\n' : ''
127
+ this.inText = false
128
+ return close
129
+ }
130
+ return ''
131
+ }
132
+
133
+ /** The question an approval or ask_user_question puts to whoever answers first. */
134
+ approval(req) {
135
+ const what = `${req.toolName}${req.reason ? `: ${req.reason}` : ''}`
136
+ return crlf(`${style.yellow('⏸ permission')} ${oneLine(what, 400)}\n`
137
+ + ` ${style.dim('answer here (y = allow once, n = reject) or in DSH Desktop')}\n`)
138
+ }
139
+
140
+ question(q, index, total) {
141
+ const options = q.options ?? []
142
+ let s = `${style.yellow(`⏸ question ${index + 1}/${total}`)} ${q.header ? `${q.header}: ` : ''}${q.question}\n`
143
+ if (q.detail) s += ` ${q.detail}\n`
144
+ options.forEach((o, n) => { s += ` ${n + 1}. ${o.label}${o.description ? style.dim(` — ${o.description}`) : ''}\n` })
145
+ s += ` ${style.dim(options.length
146
+ ? `answer here (${q.multiSelect ? 'numbers like 1,3' : 'a number'}, your own text, or - to skip) or in DSH Desktop`
147
+ : 'answer here (your text, or - to skip) or in DSH Desktop')}\n`
148
+ return crlf(s)
149
+ }
150
+ }