dsh-herdr-bridge 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,7 +15,7 @@ The plugin runs inside DSH's own process. It needs no second copy of DSH and cha
15
15
 
16
16
  ## What it looks like
17
17
 
18
- ![Three DSH sessions in Herdr App: one waiting for approval, one working, one finished; on the right, the finished session's conversation](docs/media/herdr-app.webp)
18
+ ![Three DSH sessions in Herdr App: one waiting for approval, one working, one finished; on the right, the finished session's conversation](https://raw.githubusercontent.com/Hariketsu/dsh-herdr-bridge/main/docs/media/herdr-app.webp)
19
19
 
20
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.
21
21
 
@@ -35,19 +35,14 @@ Platforms: tested on macOS only. Windows and Linux are implemented but untested.
35
35
 
36
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):
37
37
  ```sh
38
- dsh plugin --profile desktop add dsh-herdr-bridge --ignore-scripts --config.auto-install-peers=false # or --profile web
39
- ```
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:
42
- ```sh
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
38
+ dsh plugin --profile desktop add dsh-herdr-bridge # or --profile web
45
39
  ```
40
+ 2. Reopen Desktop or start `dsh web`. That's all: when the plugin loads, it installs the hc template, so Herdr agents can run `hc run dsh "task"`, and, if you use Herdr App, DSH's name and icon there.
46
41
 
47
42
  **Check that it works:**
48
43
 
49
44
  ```sh
50
- dsh-hc ls # recent DSH sessions (prints "no DSH sessions in that period" before you have any)
45
+ hc templates # lists dsh
51
46
  ```
52
47
 
53
48
  Then send any message in DSH. A `dsh-<short id>` terminal should appear in `hc ls`.
@@ -72,11 +67,13 @@ Where the new session goes depends on its folder. If the folder is exactly one o
72
67
  **3. Take over an existing session.**
73
68
 
74
69
  ```sh
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
70
+ ~/.dsh/herdr-bridge/bin/dsh-hc ls --days 7 # session id, title, folder, and whether it's already in hc
71
+ hc run --bg --name login ~/.dsh/herdr-bridge/bin/dsh-hc attach 3b729fc9 # by id, id prefix, or part of the title
72
+ hc read login # the earlier conversation first, then anything new
78
73
  ```
79
74
 
75
+ `dsh-hc` is the small command the plugin creates; it doesn't need to be on your PATH. Herdr agents find its full path in the `dsh` template's description (`hc templates`).
76
+
80
77
  If the session already has an automatic `dsh-<short id>` terminal, that terminal exits and the one taking over replaces it.
81
78
 
82
79
  **4. Cancel when needed.** `hc interrupt fix` cancels the current turn, like pressing stop in DSH.
@@ -85,7 +82,7 @@ If the session already has an automatic `dsh-<short id>` terminal, that terminal
85
82
 
86
83
  When a session waits for an approval or a question, DSH shows its dialog as usual. The hc terminal shows the same question, with status `blocked`.
87
84
 
88
- ![Left: the approval dialog in DSH Web. Right: the same session in Herdr App, waiting on the same approval](docs/media/approval.webp)
85
+ ![Left: the approval dialog in DSH Web. Right: the same session in Herdr App, waiting on the same approval](https://raw.githubusercontent.com/Hariketsu/dsh-herdr-bridge/main/docs/media/approval.webp)
89
86
 
90
87
  Answering from hc needs `--force`:
91
88
 
@@ -114,26 +111,29 @@ General hc usage comes from `~/.agents/skills/herdr-core`, which DSH discovers a
114
111
 
115
112
  ## Configuration
116
113
 
117
- All optional, under the `herdr-bridge` entry in `~/.dsh/profiles/<profile>/cordis.patch.yml`:
114
+ Open **Plugins → Herdr Bridge** in DSH to choose where sessions show in Herdr App; changes apply without a restart. Everything can also be set, all optional, under the `herdr-bridge` entry in `~/.dsh/profiles/<profile>/cordis.patch.yml`:
118
115
 
119
116
  ```yaml
120
117
  - id: herdr-bridge
121
118
  config:
119
+ herdrPlacement: folder # where Herdr App shows sessions started in DSH: folder (default), dsh, or none
120
+ herdrSpace: sp123 # a fixed Herdr App space id; overrides herdrPlacement
122
121
  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
122
  hc: /path/to/hc # the hc executable; default: hc on PATH
125
123
  ```
126
124
 
125
+ Herdr App only shows hc terminals placed in one of its spaces. With `folder`, a session started in DSH joins the Herdr App space that already has a terminal in the same folder; if there is none, Herdr App opens a new space for that folder. With `dsh`, all of them share one space named DSH; Herdr App names new spaces after a folder, so the plugin renames it through Herdr App's own command line, and leaves a name you chose alone. With `none`, they stay out of Herdr App but still show in `hc ls` and other clients. Sessions a Herdr agent starts with `hc run dsh` show under that agent either way.
126
+
127
127
  ## Troubleshooting
128
128
 
129
129
  Start with the log, `~/.dsh/herdr-bridge/bridge.log`.
130
130
 
131
131
  | Symptom | Check |
132
132
  |---|---|
133
+ | A session you started in DSH shows in `hc ls` but not in Herdr App | Check `herdrPlacement` isn't `none`. Herdr App only shows terminals placed in a space; terminals from an older plugin version are placed when they reconnect |
133
134
  | A session you're using in DSH isn't in `hc ls` | The log, for plugin load or terminal start failures. Whether DSH can find `hc`: Desktop uses your login shell's PATH and `dsh web` uses the PATH of the shell that started it; if needed, set `hc` in the config. Whether Desktop and `dsh web` are both running (see Known limitations) |
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 |
135
+ | `hc run dsh …` can't find the template, or `~/.dsh/herdr-bridge/bin/dsh-hc` doesn't exist | The plugin didn't load; check the log. A `dsh` template you wrote yourself is never replaced; delete it to get ours, or run `~/.dsh/herdr-bridge/bin/dsh-hc install-template` |
136
+ | DSH sessions show a generic robot icon in Herdr App | Herdr App had never been started when the plugin loaded: restart DSH, or run `~/.dsh/herdr-bridge/bin/dsh-hc install-template`. A `dsh` entry already in `programs.json` is kept |
137
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
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
139
  | A terminal reports a protocol version mismatch | It's still running the proxy from before a plugin upgrade. Close it and `attach` again |
@@ -162,8 +162,8 @@ Installing from source, the dev loop and the isolated test environment are in [d
162
162
  ```sh
163
163
  scripts/dev.sh up # isolated world: mock LLM + isolated hc core + isolated dsh web (Desktop stand-in)
164
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)
165
+ scripts/e2e.sh # 39 hc-side checks
166
+ scripts/e2e-ui.sh # 14 UI-side checks (ego-browser)
167
167
  scripts/dev.sh down
168
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
169
  scripts/demo-media.py # regenerate the README media from a scripted, fully isolated demo
package/README.zh-CN.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  ## 效果
17
17
 
18
- ![Herdr App 里的三个 DSH 会话:一个等待审批,一个正在工作,一个已完成;右侧是已完成会话的对话](docs/media/herdr-app.webp)
18
+ ![Herdr App 里的三个 DSH 会话:一个等待审批,一个正在工作,一个已完成;右侧是已完成会话的对话](https://raw.githubusercontent.com/Hariketsu/dsh-herdr-bridge/main/docs/media/herdr-app.webp)
19
19
 
20
20
  Herdr App 里的三个 DSH 会话,状态各不相同。右侧是其中一个会话在 hc 终端里的样子:提示、回复、带描述的工具调用和回合用时,格式和原生 TUI Agent 一样。
21
21
 
@@ -35,19 +35,14 @@ Desktop 和 `dsh web` 同一时间只能有一个接入 Herdr(见已知限制
35
35
 
36
36
  1. 装进你使用的 DSH profile。Desktop 用 `desktop`,要先完全退出 Desktop(⌘Q);Web 用 `web`,要先停掉 `dsh web`:
37
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 和图标
38
+ dsh plugin --profile desktop add dsh-herdr-bridge # 或 --profile web
45
39
  ```
40
+ 2. 重新打开 Desktop 或启动 `dsh web`。这就装好了:插件加载时会自己安装 hc template,Herdr Agent 就能用 `hc run dsh "任务"`;如果你用 Herdr App,还会在它那里加上 DSH 的名字和图标。
46
41
 
47
42
  **确认安装成功**:
48
43
 
49
44
  ```sh
50
- dsh-hc ls # 列出 DSH 里最近的会话(还没有会话时显示 no DSH sessions in that period)
45
+ hc templates # 能看到 dsh
51
46
  ```
52
47
 
53
48
  然后在 DSH 里随便发一条消息,`hc ls` 里应该出现 `dsh-<短 id>` 终端。
@@ -72,11 +67,13 @@ hc send --wait fix "继续,把边界情况也测了"
72
67
  **3. 接管一个已有会话。**
73
68
 
74
69
  ```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 # 先看到之前的对话,再看到新的
70
+ ~/.dsh/herdr-bridge/bin/dsh-hc ls --days 7 # 会话 id、标题、目录、是否已在 hc 里
71
+ hc run --bg --name login ~/.dsh/herdr-bridge/bin/dsh-hc attach 3b729fc9 # 用 id、id 前缀或标题片段
72
+ hc read login # 先看到之前的对话,再看到新的
78
73
  ```
79
74
 
75
+ `dsh-hc` 是插件生成的一个小命令,不需要加进 PATH。Herdr Agent 从 `dsh` template 的描述里(`hc templates`)就能看到它的完整路径。
76
+
80
77
  如果这个会话已经有自动映射的 `dsh-<短 id>` 终端,那个终端会退出,由接管的终端代替。
81
78
 
82
79
  **4. 需要时取消。** `hc interrupt fix` 取消当前回合,相当于在 DSH 里点停止。
@@ -85,7 +82,7 @@ hc read login # 先看到之前的对话
85
82
 
86
83
  会话等待审批或提问时,DSH 界面照常弹出对话框,hc 终端里也显示同一个问题,状态为 `blocked`。
87
84
 
88
- ![左边是 DSH Web 里的审批对话框,右边是 Herdr App 里同一个会话在等待同一个审批](docs/media/approval.webp)
85
+ ![左边是 DSH Web 里的审批对话框,右边是 Herdr App 里同一个会话在等待同一个审批](https://raw.githubusercontent.com/Hariketsu/dsh-herdr-bridge/main/docs/media/approval.webp)
89
86
 
90
87
  在 hc 侧回答必须加 `--force`:
91
88
 
@@ -114,26 +111,29 @@ hc run --bg --parent "$DSH_HC_TERMINAL" --name helper --cwd /repo claude "…完
114
111
 
115
112
  ## 配置
116
113
 
117
- 写在 `~/.dsh/profiles/<profile>/cordis.patch.yml` 的 `herdr-bridge` 条目下,全部可选:
114
+ 在 DSH 里打开 **插件 → Herdr 桥接**,就能在插件页上直接选择会话在 Herdr App 里放在哪里,改完不用重启就生效。所有设置也都可以写在 `~/.dsh/profiles/<profile>/cordis.patch.yml` 的 `herdr-bridge` 条目下,全部可选:
118
115
 
119
116
  ```yaml
120
117
  - id: herdr-bridge
121
118
  config:
119
+ herdrPlacement: folder # 在 DSH 里发起的会话放进 Herdr App 的哪个 space:folder(默认)、dsh 或 none
120
+ herdrSpace: sp123 # 固定的 Herdr App space id;填了就以它为准
122
121
  reconnectGraceSeconds: 30 # 插件断开后,终端等待重连的秒数,超时退出(默认 30)
123
- herdrSpace: s3 # 给终端打 herdr.space 标签,在 Herdr App 的这个 space 里显示
124
122
  hc: /path/to/hc # hc 可执行文件,默认是 PATH 上的 hc
125
123
  ```
126
124
 
125
+ Herdr App 只显示放进它某个 space 的 hc 终端。`folder`:在 DSH 里发起的会话,加入 Herdr App 里已经有终端在同一文件夹的那个 space;没有的话,Herdr App 为这个文件夹新开一个 space。`dsh`:所有会话共用一个名为 DSH 的 space。Herdr App 新建 space 时会用文件夹名命名,所以插件会通过 Herdr App 自己的命令行把它改名为 DSH;你自己改过的名字不会被覆盖。`none`:不放进 Herdr App,但 `hc ls` 和其他客户端照样能看到。Herdr Agent 用 `hc run dsh` 发起的会话,无论哪种设置,都显示在那个 Agent 下面。
126
+
127
127
  ## 故障排查
128
128
 
129
129
  先看日志 `~/.dsh/herdr-bridge/bridge.log`。
130
130
 
131
131
  | 现象 | 检查 |
132
132
  |---|---|
133
+ | 在 DSH 里发起的会话 `hc ls` 里有,但 Herdr App 里没有 | 检查 `herdrPlacement` 不是 `none`。Herdr App 只显示放进 space 的终端;旧版插件开出的终端会在重连时补上 |
133
134
  | 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` 条目 |
135
+ | `hc run dsh …` 提示找不到 template,或 `~/.dsh/herdr-bridge/bin/dsh-hc` 不存在 | 插件没加载,看日志。你自己写的 `dsh` template 不会被替换;删掉它就会换成插件的,或执行 `~/.dsh/herdr-bridge/bin/dsh-hc install-template` |
136
+ | Herdr App 里 DSH 会话显示通用的机器人图标 | 插件加载时 Herdr App 还从没启动过:重启 DSH,或执行 `~/.dsh/herdr-bridge/bin/dsh-hc install-template`。`programs.json` 里已有的 `dsh` 条目会保留 |
137
137
  | 终端显示“断开 → 重新连接” | 插件重载了(比如你在 DSH 里改了设置),正常现象,地址不变 |
138
138
  | `dsh-*` 终端突然退出 | DSH 退出或插件超过 `reconnectGraceSeconds` 没回来;重开 DSH 后,会话下次运行时会重新映射 |
139
139
  | 终端报协议版本不符 | 插件升级后旧终端还在跑旧代理;关掉终端重新 `attach` |
@@ -162,8 +162,8 @@ hc run --bg --parent "$DSH_HC_TERMINAL" --name helper --cwd /repo claude "…完
162
162
  ```sh
163
163
  scripts/dev.sh up # 隔离环境:模拟 LLM + 隔离 hc core + 隔离 dsh web(Desktop 替身)
164
164
  scripts/dev.sh reload # 热重载插件
165
- scripts/e2e.sh # 37 项 hc 侧回归
166
- scripts/e2e-ui.sh # 13 项 UI 侧回归(ego-browser)
165
+ scripts/e2e.sh # 39 项 hc 侧回归
166
+ scripts/e2e-ui.sh # 14 项 UI 侧回归(ego-browser)
167
167
  scripts/dev.sh down
168
168
  scripts/desktop.sh reload # 把改动热重载进真实 Desktop(只改 cordis.patch.yml 里 herdr-bridge 这一项,首次会备份)
169
169
  ```
package/client.js ADDED
@@ -0,0 +1,151 @@
1
+ // dsh-herdr-bridge, browser half: this plugin's settings, shown on its own page under Plugins.
2
+ // Hand-written in the client module system's lazy-CJS factory format (the format DSH's own tsdown
3
+ // preset emits), so the package needs no build. It edits the volatile fields of the plugin's
4
+ // Config (`herdrPlacement`, `herdrSpace`) through the Host's settings form for the `herdr-bridge`
5
+ // profile entry; the Host validates and persists them, and the plugin reads them live.
6
+ window.__ModuleLoader__.load({
7
+ id: "dsh-herdr-bridge",
8
+ factory: (require) => {
9
+ var module = { exports: {} };
10
+ var exports = module.exports;
11
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
12
+ const { createElement: h, useId } = require("react");
13
+ const { Button, SettingsForm, SettingsFormModel, SettingsValueField, Tag, settingsTextField } = require("@deepseek-ai/dsh-client-ui-primitives");
14
+
15
+ /** Dictionary namespace owned by this page. */
16
+ const NS = "herdrBridge";
17
+ /** Settings namespace: the plugin's profile entry id. */
18
+ const SETTINGS = "herdr-bridge";
19
+ /** Plugins page key of this bundle: its package name. */
20
+ const BUNDLE = "dsh-herdr-bridge";
21
+ const PLACEMENTS = ["folder", "dsh", "none"];
22
+
23
+ const en = {
24
+ placement: "Where Herdr App shows sessions started in DSH",
25
+ folder: "By folder",
26
+ folderHint: "A session joins the Herdr App space that already has a terminal in its folder, or opens a new space for that folder.",
27
+ dsh: "One space for all DSH sessions",
28
+ dshHint: "All sessions share one space, named DSH. Rename it in Herdr App if you like; the new name is kept.",
29
+ none: "Not in Herdr App",
30
+ noneHint: "Sessions stay out of Herdr App, but still show in hc and other Herdr clients.",
31
+ space: "Fixed space id",
32
+ spaceHint: "A Herdr App space id. When set, it replaces the choice above.",
33
+ applies: "Applies to terminals opened after you save; open ones stay where they are.",
34
+ overridden: "Custom",
35
+ reset: "Reset",
36
+ invalid: "Not a valid value",
37
+ unavailable: "This profile doesn't expose herdr-bridge settings.",
38
+ readOnly: "This profile's configuration is read-only.",
39
+ saveFailed: "Couldn't save. Try again.",
40
+ save: "Save",
41
+ saving: "Saving…",
42
+ };
43
+ const zh = {
44
+ placement: "在 DSH 里发起的会话放在 Herdr App 的哪里",
45
+ folder: "按文件夹归位",
46
+ folderHint: "加入 Herdr App 里已经有终端在同一文件夹的那个 space;没有的话,为这个文件夹新开一个 space。",
47
+ dsh: "统一放进一个 DSH space",
48
+ dshHint: "所有会话共用一个名为 DSH 的 space。你可以在 Herdr App 里给它改名,改过的名字会保留。",
49
+ none: "不放进 Herdr App",
50
+ noneHint: "会话不出现在 Herdr App 里,但 hc 和其他 Herdr 客户端照样能看到。",
51
+ space: "固定 space id",
52
+ spaceHint: "Herdr App 的 space id。填了就以它为准,忽略上面的选择。",
53
+ applies: "保存后新开的终端按新设置放置;已经开着的终端位置不变。",
54
+ overridden: "已自定义",
55
+ reset: "重置",
56
+ invalid: "不是有效的值",
57
+ unavailable: "当前 profile 没有暴露 herdr-bridge 的设置。",
58
+ readOnly: "当前 profile 的配置是只读的。",
59
+ saveFailed: "保存失败,请重试。",
60
+ save: "保存",
61
+ saving: "保存中…",
62
+ };
63
+
64
+ const css = {
65
+ field: { display: "flex", flexDirection: "column", gap: "6px", padding: "8px 0" },
66
+ toolbar: { display: "flex", alignItems: "center", gap: "8px" },
67
+ label: { fontSize: "13px", fontWeight: "600", color: "var(--dsw-alias-label-primary)" },
68
+ select: {
69
+ height: "34px", padding: "0 10px", maxWidth: "360px",
70
+ border: "0.5px solid var(--dsw-alias-border-l4)", borderRadius: "var(--dsw-radius-md)",
71
+ background: "var(--dsw-alias-bg-layer-3)", color: "var(--dsw-alias-label-primary)", font: "inherit", fontSize: "13px",
72
+ },
73
+ hint: { margin: 0, fontSize: "12px", lineHeight: "1.6", color: "var(--dsw-alias-label-secondary)" },
74
+ };
75
+
76
+ /** Staged edits of the two placement settings over the Host's form for this profile entry. */
77
+ function placementForm(scope) {
78
+ const form = new SettingsFormModel(scope, [{
79
+ ...settingsTextField("herdrPlacement"),
80
+ parse: (text) => {
81
+ const value = text.trim();
82
+ if (value === "") return { kind: "clear" };
83
+ return PLACEMENTS.includes(value) ? { kind: "set", value } : undefined;
84
+ },
85
+ }, settingsTextField("herdrSpace")]);
86
+ const store = form.bind(() => ({
87
+ ...form.shell(),
88
+ herdrPlacement: form.field("herdrPlacement"),
89
+ herdrSpace: form.field("herdrSpace"),
90
+ }));
91
+ return { face: { hooks: { herdrBridgeForm: store }, ...form.actions() }, dispose: () => { form.dispose(); } };
92
+ }
93
+
94
+ /** The page: placement choice, optional fixed space id, and the shared save footer. */
95
+ function SettingsPage(props) {
96
+ const t = props.t;
97
+ const state = props.useHerdrBridgeForm((snapshot) => snapshot);
98
+ const id = useId();
99
+ const disabled = !state.writable || state.saving;
100
+ const placement = PLACEMENTS.includes(state.herdrPlacement.text) ? state.herdrPlacement.text : "folder";
101
+ return h(SettingsForm, {
102
+ labels: { unavailable: t("unavailable"), readOnly: t("readOnly"), saveFailed: t("saveFailed"), save: t("save"), saving: t("saving") },
103
+ state,
104
+ onSave: props.save,
105
+ onDiscard: props.discard,
106
+ },
107
+ h("div", { style: css.field },
108
+ h("div", { style: css.toolbar },
109
+ h("label", { htmlFor: `${id}-placement`, style: css.label }, t("placement")),
110
+ state.herdrPlacement.overridden ? h(Tag, { tone: "neutral" }, t("overridden")) : null,
111
+ state.herdrPlacement.overridden
112
+ ? h(Button, { size: "sm", disabled, onClick: () => { props.resetField("herdrPlacement"); } }, t("reset"))
113
+ : null,
114
+ ),
115
+ h("select", {
116
+ id: `${id}-placement`, value: placement, disabled, style: css.select,
117
+ "aria-invalid": state.herdrPlacement.invalid || undefined,
118
+ onChange: (event) => { props.edit("herdrPlacement", event.target.value); },
119
+ }, PLACEMENTS.map((value) => h("option", { key: value, value }, t(value)))),
120
+ h("p", { style: css.hint }, t(`${placement}Hint`)),
121
+ ),
122
+ h(SettingsValueField, {
123
+ id: `${id}-space`, label: t("space"), hint: t("spaceHint"), ...state.herdrSpace,
124
+ overriddenLabel: t("overridden"), resetLabel: t("reset"), invalidLabel: t("invalid"), disabled,
125
+ onEdit: (text) => { props.edit("herdrSpace", text); },
126
+ onReset: () => { props.resetField("herdrSpace"); },
127
+ }),
128
+ h("p", { style: css.hint }, t("applies")),
129
+ );
130
+ }
131
+
132
+ const inject = ["slots", "locale", "configForms"];
133
+
134
+ /** Register the dictionaries and, while the Host serves the namespace, the settings on the bundle's page (between its detail and its components). */
135
+ function apply(ctx) {
136
+ ctx.effect(() => ctx.locale.register(NS, { en, zh }), "herdr-bridge: dictionaries");
137
+ const form = placementForm(ctx.configForms.get(SETTINGS));
138
+ ctx.effect(() => () => { form.dispose(); }, "herdr-bridge: settings form");
139
+ ctx.effect(() => ctx.configForms.whileServed([SETTINGS], () => ctx.slots.inject("plugins.bundle.config", () => ctx.slots.register({
140
+ name: "plugins.bundle.config",
141
+ key: BUNDLE,
142
+ locale: NS,
143
+ inject: () => form.face,
144
+ }, SettingsPage))), "herdr-bridge: settings page");
145
+ }
146
+
147
+ exports.apply = apply;
148
+ exports.inject = inject;
149
+ return module.exports;
150
+ }
151
+ });
package/docs/DETAIL.md ADDED
@@ -0,0 +1,3 @@
1
+ Once a DSH session runs, it's mirrored as an hc terminal automatically. Choose which Herdr App space it goes to below.
2
+
3
+ There's nothing else to set up. More in the [full guide](https://github.com/Hariketsu/dsh-herdr-bridge#readme).
@@ -0,0 +1,3 @@
1
+ DSH 会话运行后会自动映射为 hc 终端。你可以在下方设置它们在 Herdr App 中的空间归属。
2
+
3
+ 装好就能用,不需要别的准备。更多说明见[完整文档](https://github.com/Hariketsu/dsh-herdr-bridge/blob/main/README.zh-CN.md)。
package/index.js CHANGED
@@ -6,15 +6,28 @@
6
6
  // See README.md for the design.
7
7
  import net from 'node:net'
8
8
  import { execFile } from 'node:child_process'
9
- import { appendFileSync, chmodSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
9
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
10
10
  import { basename, join, sep } from 'node:path'
11
11
  import { homedir } from 'node:os'
12
12
  import { createHash, randomUUID } from 'node:crypto'
13
13
  import { fileURLToPath } from 'node:url'
14
14
  import { Renderer, crlf, oneLine, osc, style, windowTitle } from './render.js'
15
+ import { installHerdrApp, installTemplate } from './install.js'
15
16
 
16
17
  export const name = 'herdr-bridge'
17
18
 
19
+ // Settings DSH validates and edits. Schemastery ships with DSH but may not resolve from a linked
20
+ // checkout; without it the settings still work from cordis.patch.yml, just unvalidated and not live.
21
+ // The volatile fields are what the Plugins page form (client.js) edits; they apply without a reload.
22
+ const Schema = await import('@deepseek-ai/schemastery').then(m => m.default, () => null)
23
+ export const Config = Schema?.object({
24
+ herdrPlacement: Schema.union(['folder', 'dsh', 'none']).default('folder').volatile()
25
+ .description('Where Herdr App shows the terminals of sessions started in DSH: by folder, in one space, or not at all'),
26
+ herdrSpace: Schema.string().volatile().description('A fixed Herdr App space id; overrides herdrPlacement when set'),
27
+ reconnectGraceSeconds: Schema.natural().default(30).description('How long a terminal waits for the plugin to come back before it exits'),
28
+ hc: Schema.string().description('The hc executable (default: hc on PATH)'),
29
+ })
30
+
18
31
  const PROTOCOL = 1
19
32
  const PASTE_START = '\x1b[200~'
20
33
  const PASTE_END = '\x1b[201~'
@@ -101,7 +114,10 @@ This DSH session is mirrored into Herdr Core (hc) as an ordinary terminal, so yo
101
114
 
102
115
  export function apply(ctx, config = {}) {
103
116
  const hc = config.hc || 'hc'
104
- const herdrSpace = config.herdrSpace || ''
117
+ // Volatile settings are references read at each use; a linked checkout passes plain values.
118
+ const live = value => typeof value?.get === 'function' ? value.get() : value
119
+ const herdrSpace = () => live(config.herdrSpace) || ''
120
+ const herdrPlacement = () => live(config.herdrPlacement) || 'folder'
105
121
  const grace = Number(config.reconnectGraceSeconds ?? 30)
106
122
  const home = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'herdr-bridge')
107
123
  mkdirSync(home, { recursive: true })
@@ -127,10 +143,82 @@ export function apply(ctx, config = {}) {
127
143
  return file
128
144
  })()
129
145
 
146
+ // hc's `dsh` template and Herdr App's DSH entry, kept current so nobody runs an install step.
147
+ for (const install of [() => installTemplate(launcher), installHerdrApp]) {
148
+ try {
149
+ const { file, result } = install()
150
+ if (result !== 'unchanged' && result !== 'absent') log('install', file, result)
151
+ } catch (error) {
152
+ log('install failed:', error?.message ?? error)
153
+ }
154
+ }
155
+
130
156
  const runHc = (args, done) => execFile(hc, args, (error, stdout, stderr) => {
131
157
  if (error) log('hc', args[0], 'failed:', oneLine(stderr || error.message, 300))
132
158
  done?.(error, stdout)
133
159
  })
160
+ const hcJson = args => new Promise(resolve => runHc([...args, '--json'], (error, stdout) => {
161
+ try { resolve(error ? null : JSON.parse(stdout)) } catch { resolve(null) }
162
+ }))
163
+
164
+ // Herdr App names a space it doesn't know after its first terminal's folder, and no hc label can
165
+ // name one; only its own CLI (`herdr-app do space.rename`) can. So the shared space is named DSH
166
+ // through that CLI, once Herdr App has made it, while its name is still the automatic folder name
167
+ // (a name the person chose is left alone). Skipped when Herdr App isn't installed or running.
168
+ const DSH_SPACE = 'dsh'
169
+ const herdrAppCandidates = [process.env.HERDR_APP_BIN, '/Applications/Herdr.app/Contents/MacOS/Herdr',
170
+ join(homedir(), 'Applications/Herdr.app/Contents/MacOS/Herdr')].filter(file => file && existsSync(file))
171
+ const herdrApp = args => new Promise(resolve => execFile(herdrAppCandidates[0] ?? 'herdr-app', args, { timeout: 5000 },
172
+ (error, stdout) => resolve(error ? null : stdout)))
173
+ let naming = false
174
+ async function nameDshSpace(cwd) {
175
+ if (naming) return
176
+ naming = true
177
+ try {
178
+ // Herdr App places a newly labeled terminal on its next listing, so the space can take a moment to exist.
179
+ for (let attempt = 0; attempt < 15; attempt++) {
180
+ const listed = await herdrApp(['ls'])
181
+ if (listed === null) return
182
+ const space = JSON.parse(listed).spaces?.find(s => s.id === DSH_SPACE)
183
+ if (space) {
184
+ const automatic = [space.folder, cwd].filter(Boolean).map(folder => basename(folder))
185
+ if (space.name !== 'DSH' && automatic.includes(space.name)) {
186
+ const done = await herdrApp(['do', 'space.rename', JSON.stringify({ id: DSH_SPACE, name: 'DSH' })])
187
+ log('herdr app: named the shared space DSH', done === null ? '(failed)' : '')
188
+ }
189
+ return
190
+ }
191
+ await new Promise(resolve => setTimeout(resolve, 1000))
192
+ }
193
+ } catch (error) {
194
+ log('herdr app: naming the shared space failed:', error?.message ?? error)
195
+ } finally {
196
+ naming = false
197
+ }
198
+ }
199
+
200
+ /**
201
+ * The Herdr App space for a session started in DSH (`herdr.space` label; Herdr App only shows
202
+ * hc terminals that carry one, and makes a new space for an id it doesn't know).
203
+ * By folder: the space a live terminal in the same folder is already in (the person's own space
204
+ * first), else one per folder. `dsh`: one space for every session. `none`: not placed.
205
+ */
206
+ async function herdrSpaceFor(cwd) {
207
+ if (herdrSpace()) return herdrSpace()
208
+ if (herdrPlacement() === 'none') return ''
209
+ if (herdrPlacement() === 'dsh') return DSH_SPACE
210
+ const here = realpath(cwd || homedir())
211
+ const spaces = []
212
+ for (const t of (await hcJson(['ls']))?.terminals ?? []) {
213
+ if (!t.cwd || realpath(t.cwd) !== here) continue
214
+ const space = (await hcJson(['get', t.address]))?.labels?.['herdr.space']
215
+ if (space) spaces.push(space)
216
+ }
217
+ // The person's own space first; the plugin's (`dsh`, `dsh-…`) never count, so switching back
218
+ // from one shared space doesn't keep pulling sessions into it.
219
+ return spaces.find(s => s !== DSH_SPACE && !s.startsWith('dsh-'))
220
+ ?? `dsh-${createHash('sha1').update(here).digest('hex').slice(0, 12)}`
221
+ }
134
222
 
135
223
  /** Host session services, present only in hosts that have them (Desktop, `dsh web`). */
136
224
  let sessions = null
@@ -222,6 +310,7 @@ export function apply(ctx, config = {}) {
222
310
  const changed = this.terminal !== hello.term
223
311
  this.terminal = hello.term
224
312
  if (changed) runHc(['label', this.terminal, `dsh.session=${this.sessionId}`, `dsh.title=${this.title}`])
313
+ if (hello.auto || origin === 'desktop') this.place(hello.term)
225
314
  }
226
315
  if (hello.cols) this.renderer.cols = hello.cols
227
316
  socket.on('close', () => this.conns.delete(socket))
@@ -291,18 +380,28 @@ export function apply(ctx, config = {}) {
291
380
  }
292
381
 
293
382
  /** Mirror into hc: a terminal named dsh-<short id> running `dsh-hc attach`. */
294
- launch() {
383
+ async launch() {
295
384
  if (this.mirrored) return
296
385
  this.launching = true
297
386
  const args = ['run', '--bg', '--rm', '--no-parent', '--json', '--name', `dsh-${this.short}`,
298
387
  '--label', `dsh.session=${this.sessionId}`, '--label', `dsh.title=${this.title}`]
299
- if (herdrSpace) args.push('--label', `herdr.space=${herdrSpace}`)
388
+ const space = await herdrSpaceFor(this.cwd)
389
+ if (space) args.push('--label', `herdr.space=${space}`)
390
+ if (space === DSH_SPACE) nameDshSpace(this.cwd)
300
391
  if (this.cwd) args.push('--cwd', this.cwd)
301
392
  args.push('--', launcher, 'attach', '--session', this.sessionId, '--auto')
302
393
  // A failure is usually the name being taken: that terminal is still ours and reconnecting.
303
394
  runHc(args, error => { if (error) this.launching = false })
304
395
  }
305
396
 
397
+ /** Put an automatic mirror into a Herdr App space, unless it is in one already (placed by us before, or moved by the person). */
398
+ async place(term) {
399
+ if ((await hcJson(['get', term]))?.labels?.['herdr.space']) return
400
+ const space = await herdrSpaceFor(this.cwd)
401
+ if (space) runHc(['label', term, `herdr.space=${space}`])
402
+ if (space === DSH_SPACE) nameDshSpace(this.cwd)
403
+ }
404
+
306
405
  /** Close every terminal mirroring this session; `except` keeps one. */
307
406
  release(reason, except) {
308
407
  for (const socket of this.conns.keys()) {
@@ -545,6 +644,9 @@ export function apply(ctx, config = {}) {
545
644
  })
546
645
 
547
646
  // /herdr: mirror this session into Herdr now and show its hc address.
647
+ // The Plugins page shows this plugin's own settings page (client.js), not a generated one.
648
+ ctx.inject(['settings'], c => { c.effect(() => c.settings.configure({ auto: false }, ctx.fiber), 'herdr-bridge: own settings page') })
649
+
548
650
  ctx.inject(['commands'], c => {
549
651
  c.commands.register({
550
652
  name: 'herdr',
@@ -600,5 +702,5 @@ export function apply(ctx, config = {}) {
600
702
  }
601
703
  bridges.clear()
602
704
  }, 'herdr-bridge: drop connections')
603
- log('loaded')
705
+ log('loaded', Schema ? '(settings schema on)' : '(settings schema off: schemastery not found)', `herdrPlacement=${herdrPlacement()}`)
604
706
  }
package/install.js ADDED
@@ -0,0 +1,79 @@
1
+ // What hc and Herdr App need on disk to know about DSH: the hc template `dsh` and Herdr App's
2
+ // program entry. The plugin installs both whenever it loads; `dsh-hc install-template` runs the
3
+ // same code by hand. Both leave a file or entry the person made themselves alone.
4
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
5
+ import { homedir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+
9
+ /** Marks a template this package wrote (hc ignores unknown header fields). */
10
+ const GENERATOR = 'dsh-herdr-bridge'
11
+
12
+ function writeAtomic(file, text) {
13
+ writeFileSync(`${file}.tmp`, text)
14
+ renameSync(`${file}.tmp`, file)
15
+ }
16
+
17
+ function templateDir() {
18
+ if (process.platform === 'win32') return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'herdr-core', 'templates')
19
+ return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'herdr-core', 'templates')
20
+ }
21
+
22
+ /**
23
+ * Install the hc template `dsh`, so `hc run dsh "task"` starts a DSH session. Its description tells
24
+ * Herdr agents (`hc templates`) the launcher's full path for listing and taking over sessions, so
25
+ * nobody needs `dsh-hc` on PATH.
26
+ * @param launcher - absolute path of the dsh-hc launcher.
27
+ * @returns the template file and `written`, `unchanged`, or `kept` (a template the person made).
28
+ */
29
+ export function installTemplate(launcher) {
30
+ const quoted = /\s/.test(launcher) ? `"${launcher}"` : launcher
31
+ const dir = templateDir()
32
+ const file = join(dir, 'dsh.md')
33
+ const text = `---
34
+ description: A DeepSeek Harness (DSH) session in this folder, via dsh-herdr-bridge; reports status. List recent sessions with \`${quoted} ls\`; take one over with \`hc run --bg --name <name> ${quoted} attach <id|title>\`.
35
+ command: ${quoted} new
36
+ generator: ${GENERATOR}
37
+ ---
38
+ `
39
+ if (existsSync(file)) {
40
+ const current = readFileSync(file, 'utf8')
41
+ if (current === text) return { file, result: 'unchanged' }
42
+ if (!current.includes(GENERATOR)) return { file, result: 'kept' }
43
+ }
44
+ mkdirSync(dir, { recursive: true })
45
+ writeAtomic(file, text)
46
+ return { file, result: 'written' }
47
+ }
48
+
49
+ // Herdr App names and draws agents from its program table: the built-in one, with the person's
50
+ // programs.json on top, entry by entry. DSH isn't built in, so add an entry for 7501 app `dsh`.
51
+ // Same folder on every platform: $HERDR_APP_HOME, else $XDG_CONFIG_HOME/herdr-app, else ~/.config/herdr-app.
52
+ const HERDR_PROGRAM = { name: 'DSH', icon: 'icons/dsh-herdr-bridge.svg', tint: true, tile: '#4d6bfe', color: '#ffffff' }
53
+
54
+ /**
55
+ * Give DSH its name and icon in Herdr App, when Herdr App has been started here.
56
+ * @returns the programs.json file and `written`, `kept` (a dsh entry is already there), `absent`
57
+ * (no Herdr App), or `invalid` (not valid JSON; left alone).
58
+ */
59
+ export function installHerdrApp() {
60
+ const dir = process.env.HERDR_APP_HOME || join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'herdr-app')
61
+ const file = join(dir, 'programs.json')
62
+ if (!existsSync(dir)) return { file, result: 'absent' } // Herdr App creates the folder on first start
63
+ let table = {}
64
+ try {
65
+ if (existsSync(file)) table = JSON.parse(readFileSync(file, 'utf8'))
66
+ } catch {
67
+ return { file, result: 'invalid' }
68
+ }
69
+ const icon = join(dir, HERDR_PROGRAM.icon)
70
+ const ours = fileURLToPath(new URL('./assets/dsh.svg', import.meta.url))
71
+ if (!existsSync(icon) || readFileSync(icon, 'utf8') !== readFileSync(ours, 'utf8')) {
72
+ mkdirSync(join(dir, 'icons'), { recursive: true })
73
+ copyFileSync(ours, icon)
74
+ }
75
+ if (table.programs?.dsh) return { file, result: 'kept' }
76
+ table.programs = { ...table.programs, dsh: HERDR_PROGRAM }
77
+ writeAtomic(file, `${JSON.stringify(table, null, 2)}\n`)
78
+ return { file, result: 'written' }
79
+ }
package/locale/en.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "Herdr Bridge",
4
+ "description": "Brings DSH sessions into Herdr, with live status, control from Herdr agents, and approvals on either side.",
5
+ "detail": "docs/DETAIL.md"
6
+ }
7
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "Herdr 桥接",
4
+ "description": "将 DSH 会话接入 Herdr,支持状态同步、Agent 操控与双端审批。",
5
+ "detail": "docs/DETAIL.zh.md"
6
+ }
7
+ }
package/package.json CHANGED
@@ -1,19 +1,26 @@
1
1
  {
2
2
  "name": "dsh-herdr-bridge",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Bridge DeepSeek Harness (DSH) sessions into Herdr Core as hc terminals",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
8
- ".": "./index.js"
8
+ ".": "./index.js",
9
+ "./client": "./client.js",
10
+ "./locale/*.json": "./locale/*.json"
9
11
  },
10
12
  "files": [
11
13
  "index.js",
12
14
  "render.js",
15
+ "client.js",
13
16
  "proxy.js",
17
+ "install.js",
14
18
  "assets/dsh.svg",
15
19
  "cordis.patch.yml",
16
- "README.md"
20
+ "README.md",
21
+ "locale/*.json",
22
+ "docs/DETAIL.md",
23
+ "docs/DETAIL.zh.md"
17
24
  ],
18
25
  "engines": {
19
26
  "node": ">=24.9"
@@ -21,12 +28,20 @@
21
28
  "dsh": {
22
29
  "bundle": {
23
30
  "patch": "./cordis.patch.yml"
31
+ },
32
+ "client": {
33
+ "platform": "web",
34
+ "inject": [
35
+ "@deepseek-ai/dsh-client-locale",
36
+ "@deepseek-ai/dsh-client-ui-plugin-manager",
37
+ "@deepseek-ai/dsh-client-ui-settings"
38
+ ]
24
39
  }
25
40
  },
26
41
  "license": "MIT",
27
42
  "repository": {
28
43
  "type": "git",
29
- "url": "https://github.com/Hariketsu/dsh-herdr-bridge.git"
44
+ "url": "git+https://github.com/Hariketsu/dsh-herdr-bridge.git"
30
45
  },
31
46
  "keywords": [
32
47
  "dsh",
package/proxy.js CHANGED
@@ -6,16 +6,13 @@
6
6
  // dsh-hc new a new DSH session in the current folder (hc template `dsh`)
7
7
  // dsh-hc attach <session id | title> take over an existing DSH session
8
8
  // dsh-hc ls [--days N] [--json] recent DSH sessions
9
- // dsh-hc install-template install the hc template `dsh`, and DSH's name and icon in
10
- // Herdr App's program table when Herdr App is there
9
+ // dsh-hc install-template install the hc template `dsh` and DSH's Herdr App entry again
10
+ // (the plugin does this itself whenever it loads)
11
11
  //
12
12
  // Started through the launcher the plugin writes ($DSH_HOME/herdr-bridge/bin/dsh-hc), which sets
13
13
  // DSH_HERDR_SOCKET and runs this file with the Node that ships with DSH Desktop.
14
14
  import net from 'node:net'
15
- import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
16
- import { homedir } from 'node:os'
17
- import { join } from 'node:path'
18
- import { fileURLToPath } from 'node:url'
15
+ import { installHerdrApp, installTemplate } from './install.js'
19
16
 
20
17
  const PROTOCOL = 1
21
18
  const SOCKET = process.env.DSH_HERDR_SOCKET
@@ -146,50 +143,13 @@ function lastStatus(text) {
146
143
  return { state: fields.state, msg: fields.msg ? Buffer.from(fields.msg, 'base64').toString() : '' }
147
144
  }
148
145
 
149
- function templateDir() {
150
- if (process.platform === 'win32') return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'herdr-core', 'templates')
151
- return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'herdr-core', 'templates')
152
- }
153
-
154
- function installTemplate() {
146
+ function installAll() {
155
147
  if (!LAUNCHER) fail('DSH_HERDR_LAUNCHER is not set; run dsh-hc through its launcher')
156
- const quoted = /\s/.test(LAUNCHER) ? `"${LAUNCHER}"` : LAUNCHER
157
- const dir = templateDir()
158
- mkdirSync(dir, { recursive: true })
159
- const file = join(dir, 'dsh.md')
160
- writeFileSync(file, `---
161
- name: dsh
162
- 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>\`.
163
- command: ${quoted} new
164
- ---
165
- `)
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`)
148
+ for (const { file, result } of [installTemplate(LAUNCHER), installHerdrApp()]) {
149
+ const note = { written: '', unchanged: ' (already up to date)', kept: ' (kept: you made this one)',
150
+ absent: ' (skipped: Herdr App not started here yet)', invalid: ' (left alone: not valid JSON)' }[result]
151
+ process.stdout.write(`${file}${note}\n`)
184
152
  }
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`)
193
153
  }
194
154
 
195
155
  // Terminal columns a string takes: East Asian wide and full-width characters take two.
@@ -235,7 +195,7 @@ switch (command) {
235
195
  break
236
196
  }
237
197
  case 'install-template':
238
- installTemplate()
198
+ installAll()
239
199
  break
240
200
  default:
241
201
  fail('usage: dsh-hc new | attach <session id | title> | ls [--days N] [--json] | install-template', 2)