@bysir/herdr-web 0.2.0 → 0.3.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 +614 -506
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -4,795 +4,903 @@
|
|
|
4
4
|
<img src="assets/logo.png" alt="herdr-web" width="96" />
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
<p align="center">
|
|
8
|
+
<b>English</b> · <a href="README.zh-CN.md">简体中文</a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
A terminal in your browser, built for running [`herdr`](https://github.com/zbysir/herdr). One Go binary with the frontend baked in. Works on phones.
|
|
8
12
|
|
|
9
|
-
>
|
|
13
|
+
> **Voice compose** — dictate on a tablet, edit what you got wrong, then hand the whole paragraph to an agent pane — is the point of this project. See [Outbox](#outbox-voice-compose) below. Three companion documents (Chinese): design trade-offs behind the outbox in [OUTBOX.md](OUTBOX.md), the pitfalls of scraping an agent's input line in [COMPOSER.md](COMPOSER.md), and the herdr socket API semantics we verified by hand in [HERDR-API.md](HERDR-API.md).
|
|
10
14
|
|
|
11
|
-
##
|
|
15
|
+
## Install
|
|
12
16
|
|
|
13
17
|
```bash
|
|
14
|
-
npm install -g @bysir/herdr-web #
|
|
15
|
-
herdr-web #
|
|
18
|
+
npm install -g @bysir/herdr-web # easiest if you have node; upgrades come free
|
|
19
|
+
herdr-web # listens on 127.0.0.1 only
|
|
16
20
|
```
|
|
17
21
|
|
|
18
|
-
|
|
22
|
+
No node (common on servers):
|
|
19
23
|
|
|
20
24
|
```bash
|
|
21
25
|
curl -fsSL https://raw.githubusercontent.com/zbysir/herdr-web/master/install.sh | sh
|
|
22
26
|
```
|
|
23
27
|
|
|
24
|
-
|
|
28
|
+
Installs into `~/.local/bin`. To install somewhere else, the variable has to go to `sh`, **not** to `curl`:
|
|
25
29
|
|
|
26
30
|
```bash
|
|
27
|
-
curl -fsSL …/install.sh | HERDR_WEB_INSTALL_DIR=/opt/bin sh #
|
|
28
|
-
HERDR_WEB_INSTALL_DIR=/opt/bin curl -fsSL …/install.sh | sh #
|
|
31
|
+
curl -fsSL …/install.sh | HERDR_WEB_INSTALL_DIR=/opt/bin sh # right
|
|
32
|
+
HERDR_WEB_INSTALL_DIR=/opt/bin curl -fsSL …/install.sh | sh # wrong — curl gets it, the script never sees it
|
|
29
33
|
```
|
|
30
34
|
|
|
31
|
-
|
|
35
|
+
The wrong one **does not fail**; it quietly installs to the default directory. Same shape for `HERDR_WEB_INSTALL_VER=v0.1.0` to pin a version.
|
|
32
36
|
|
|
33
|
-
|
|
37
|
+
The installer **always verifies sha256** and refuses to install if neither `sha256sum` nor `shasum` exists — there is a login shell behind this thing.
|
|
34
38
|
|
|
35
|
-
|
|
39
|
+
Other ways in:
|
|
36
40
|
|
|
37
41
|
```bash
|
|
38
|
-
make build && ./herdr-web #
|
|
39
|
-
HERDR_WEB_HOST=0.0.0.0 ./herdr-web #
|
|
42
|
+
make build && ./herdr-web # from source (frontend → internal/webui/dist → go build)
|
|
43
|
+
HERDR_WEB_HOST=0.0.0.0 ./herdr-web # listen on the LAN, and print a QR code for your phone
|
|
40
44
|
```
|
|
41
45
|
|
|
42
|
-
`go install github.com/zbysir/herdr-web/cmd/herdr-web@latest`
|
|
43
|
-
前端产物是 `make build` 生成后 embed 进去的,不入版本库,所以 `go install` 拿不到。那样装出来的
|
|
44
|
-
只能配 `--web <目录>` 指一份自己构建的前端,或者干脆只用命令行子命令。要能开页面就走上面那三条。
|
|
46
|
+
`go install github.com/zbysir/herdr-web/cmd/herdr-web@latest` works too, but **what you get has no frontend**: the web assets are produced by `make build` and embedded, and they are not in the repo, so `go install` can't see them. That binary is only useful with `--web <dir>` pointing at a frontend you built yourself, or for the CLI subcommands. Use one of the three above if you want the page.
|
|
45
47
|
|
|
46
|
-
**Windows
|
|
48
|
+
**No native Windows build** — install it inside WSL. Not laziness: the terminal in the browser needs a real PTY (Go side uses `creack/pty`, whose Windows implementation is a `return nil, ErrUnsupported` stub) and herdr itself speaks over a unix socket. Inside WSL it is simply the Linux build, fully functional; the browser end was always cross-platform, so `http://localhost:7788/` on Windows works fine. On win32 the npm package prints that explanation instead of installing something that cannot run.
|
|
47
49
|
|
|
48
|
-
|
|
50
|
+
To run it as a service that starts at boot, see [Daemon](#daemon). To upgrade, see [Updating](#updating).
|
|
49
51
|
|
|
50
|
-
|
|
52
|
+
**Environment variables are the only source of configuration** (no config file; the only flag is `--web`). The full list, how to set it, and a few common setups are under [Configuration](#configuration). Subcommands: `herdr-web --help`.
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
On startup it prints the addresses you can reach it at. When listening on `0.0.0.0` it scores the interfaces and marks the one your phone can actually reach with `← use this one from your phone` (the pile of OrbStack / VPN virtual interfaces gets pushed to the bottom); that is the address encoded in the QR code.
|
|
53
55
|
|
|
54
|
-
|
|
56
|
+
**Pair each device once.** The startup banner carries a one-time pairing code (5 minutes, single use) and its QR code — scan it from your phone and you are in, zero typing. After that your bookmark holds no secret (the credential lives in an `HttpOnly` cookie), and changing Wi-Fi, changing subnets or rebooting costs you nothing. To pair another device, run `herdr-web pair` on the machine.
|
|
55
57
|
|
|
56
|
-
|
|
58
|
+
Two ways to scan:
|
|
57
59
|
|
|
58
|
-
-
|
|
59
|
-
-
|
|
60
|
+
- **Your camera app** (works everywhere): the code is just a link with `?pair=`; scanning opens it and you land already paired.
|
|
61
|
+
- **"Scan with camera" inside the pairing page**: opens the rear camera, points at the code on the host screen, pairs on recognition without navigating. This button **only appears when it can work** — it needs `BarcodeDetector` (the system decoder, which saves tens of KB of JS; macOS uses Vision, Android uses ML Kit, and **iOS Safari and Chrome on Linux do not have it**) plus a camera (only granted in a secure context — plain http on a LAN gets nothing). If either is missing the button is not rendered at all, rather than left there to fail on click.
|
|
60
62
|
|
|
61
|
-
|
|
63
|
+
If neither is convenient, type the 8-digit code into the pairing page; it submits itself once you have typed 8 characters. What the in-page scanner reads is reduced to the `pair=` part and goes through the same `POST /auth/pair`, so the security model is unchanged (only someone at the machine can produce a code).
|
|
62
64
|
|
|
63
65
|
```bash
|
|
64
|
-
herdr-web pair #
|
|
65
|
-
herdr-web devices #
|
|
66
|
-
herdr-web revoke <id> #
|
|
67
|
-
herdr-web unlock #
|
|
66
|
+
herdr-web pair # print a fresh one-time pairing code + QR
|
|
67
|
+
herdr-web devices # list paired devices (label / last seen / last IP / expiry)
|
|
68
|
+
herdr-web revoke <id> # kick one (all = everything); the next request gets 401
|
|
69
|
+
herdr-web unlock # clear the global "too many failures" circuit breaker
|
|
68
70
|
```
|
|
69
71
|
|
|
70
|
-
|
|
71
|
-
踢掉都要点两下才生效。**网页上不出配对码**(连已配对的设备也不行),理由见下面「安全」。
|
|
72
|
+
The ⚙ at the right end of the top bar is the **settings panel**; its "Devices" page shows who has paired, lets you **sign this device out**, and kick others. Kicking one and kicking everyone both take two clicks. **The web UI never issues a pairing code** (not even to an already-paired device) — see [Security](#security) for why.
|
|
72
73
|
|
|
73
|
-
|
|
74
|
+
**Out of codes? Go back to the machine and run `herdr-web pair`.** That is not laziness either — see below.
|
|
74
75
|
|
|
75
|
-
|
|
76
|
+
The old never-expiring `~/.herdr-web/token` is demoted to **bootstrap only**: an old bookmark exchanges it for a device credential on first open and scrubs the token out of the URL, after which you should `rm ~/.herdr-web/token`. Details and reasoning in [SECURITY.md](SECURITY.md) (Chinese).
|
|
76
77
|
|
|
77
|
-
|
|
78
|
+
Once connected it **types `herdr` for you**. To type something else, or nothing: `HERDR_WEB_ONCONNECT` (set it to an empty string to stay in the shell). Adding a path segment to the URL (`/work`) gives you **a different herdr session** — see [One URL, one session](#one-url-one-session-name). The old "run herdr" button in the top bar is gone: with autotyping it earns its place less than once a day, and the soft key bar ships a preset for it if you want one.
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
**The admin page is at `http://127.0.0.1:<port+1>/`** (also in the startup banner): certificate status, one-click issue/renew, generated DNS `.env` snippets, pairing codes, device kicking. It is **bound to loopback and does not exist on the public internet**, so it needs no login — anything that can reach it already has your shell. Why not "an authenticated page on the main server": authentication is a control that can fail, unreachability is a property; and the admin page must not depend on the very certificate it exists to fix (a broken certificate would lock you out of the page that repairs it).
|
|
80
81
|
|
|
81
|
-
##
|
|
82
|
+
## One URL, one session (`/{name}`)
|
|
82
83
|
|
|
83
|
-
|
|
84
|
+
A path segment is **a different herdr**:
|
|
84
85
|
|
|
85
86
|
```
|
|
86
|
-
https://herdr.bysir.top/
|
|
87
|
-
https://herdr.bysir.top/work
|
|
88
|
-
https://herdr.bysir.top/scratch
|
|
87
|
+
https://herdr.bysir.top/ default session (old behaviour, types `herdr`)
|
|
88
|
+
https://herdr.bysir.top/work types `herdr --session work` — created if it doesn't exist
|
|
89
|
+
https://herdr.bysir.top/scratch another one, unrelated to the above
|
|
89
90
|
```
|
|
90
91
|
|
|
91
|
-
`herdr --session <name>`
|
|
92
|
-
workspace / tab / pane。所以书签存成 `/work` 和 `/scratch`,两个标签页就是两套工作现场,
|
|
93
|
-
关掉浏览器再回来还在(session 是持久的,网页断开只是客户端断开)。
|
|
92
|
+
`herdr --session <name>` is herdr's own **named persistent session**: its own server process, its own workspaces / tabs / panes. So bookmark `/work` and `/scratch` and two browser tabs are two working contexts that survive closing the browser (the session is persistent; disconnecting the page only disconnects a client).
|
|
94
93
|
|
|
95
|
-
|
|
94
|
+
Things to know:
|
|
96
95
|
|
|
97
|
-
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
-
|
|
103
|
-
|
|
104
|
-
- **`HERDR_WEB_ONCONNECT` 不参与带 session 的 URL**(包括设成空串「什么都别敲」那种):
|
|
105
|
-
地址栏里点名要哪个 session,比一个全局默认具体。`/` 还是老规矩。
|
|
106
|
-
- **顶栏左边会显示当前 session 名**(设置 →「终端」页底下也有一行,连着那个 session 的 socket
|
|
107
|
-
路径)。默认 session 不显示这个标签 —— 没有标签就是默认那个。
|
|
108
|
-
- 一个进程最多同时盯 16 个 session(每个都带一条 agent 状态订阅)。超了会说,重启清空。
|
|
109
|
-
- 「添加到主屏幕」存的是 manifest 里的 `start_url`(`/`),所以从主屏图标进的是默认 session。
|
|
110
|
-
要一个直达 `/work` 的图标,用浏览器书签。
|
|
111
|
-
- `herdr session list` / `stop` / `delete` 在终端里管这些 session,herdr-web 这边只负责「开/接上」。
|
|
96
|
+
- **The outbox and the pane list follow the URL.** A named session has its own socket (`~/.config/herdr/sessions/<name>/herdr.sock`, the one `herdr session list --json` reports), so every request from the page carries the session name. This is the one genuinely dangerous corner of the feature — using the default session's socket to post into a pane picked on a `/work` page would **silently deliver your words into another herdr**, with nothing looking wrong on either screen. That is why the server **does not fall back** on an invalid name; it errors out instead of quietly using the default session.
|
|
97
|
+
- **Names are `[A-Za-z0-9._-]`, must start alphanumeric, 40 characters max** — the name is interpolated into a command line typed into a login shell, and into a socket path. Invalid names are reported on the page rather than silently redirected to some other session.
|
|
98
|
+
- **`HERDR_WEB_ONCONNECT` does not apply to session URLs** (including when set to the empty "type nothing" value): naming a session in the address bar is more specific than a global default. `/` keeps the old behaviour.
|
|
99
|
+
- **The current session name is shown at the left of the top bar** (and in Settings → Terminal, together with that session's socket path). The default session shows no label — no label means default.
|
|
100
|
+
- One process watches at most 16 sessions at a time (each carries an agent-status subscription). Beyond that it says so; a restart clears it.
|
|
101
|
+
- "Add to Home Screen" stores the manifest `start_url` (`/`), so the home screen icon opens the default session. For an icon that goes straight to `/work`, use a browser bookmark.
|
|
102
|
+
- `herdr session list` / `stop` / `delete` manage these sessions from a terminal; herdr-web only opens and attaches.
|
|
112
103
|
|
|
113
|
-
##
|
|
104
|
+
## Local shell only
|
|
114
105
|
|
|
115
|
-
|
|
106
|
+
To reach another machine, ssh from inside herdr — herdr does that itself, so this layer does not implement host management or key storage (which would drag in key files on disk, `ssh-keygen`, `~/.ssh` scanning, ssh_config import), and along with it the whole "the browser can touch your private keys" attack surface disappears.
|
|
116
107
|
|
|
117
|
-
##
|
|
108
|
+
## Outbox (voice compose)
|
|
118
109
|
|
|
119
|
-
|
|
110
|
+
The strip with a textarea at the bottom of the page is the outbox; the ✎ in the top bar toggles it and it is **on by default**. You dictate or type in there, fix what came out wrong, then hand the whole paragraph to one of herdr's panes. It shares **one dock** with the soft key bar (one border, one width — see [The bottom dock](#the-bottom-dock)).
|
|
120
111
|
|
|
121
|
-
|
|
112
|
+
Why a separate box instead of talking straight into the terminal: a terminal is a byte stream with no selection semantics, so an IME can only pour characters into it. "**Select the words you got wrong and say them again**" needs a real editable field — a text model, a selection, and an IME commit that replaces the selection. xterm.js's hidden textarea does not count; it only turns keys into bytes and sends them.
|
|
122
113
|
|
|
123
|
-
|
|
|
114
|
+
| Control | What it does |
|
|
124
115
|
|---|---|
|
|
125
|
-
|
|
|
126
|
-
|
|
|
127
|
-
|
|
|
128
|
-
|
|
|
129
|
-
|
|
|
130
|
-
|
|
|
131
|
-
| `↑` |
|
|
132
|
-
| `Esc` |
|
|
116
|
+
| **Target** | Defaults to "follow herdr's current pane" — nothing to pick, it goes to whatever you have focused in herdr. You can also pin one from the dropdown |
|
|
117
|
+
| **Post** `⌘↵` / `Ctrl↵` | Clears the remote input line first, then submits the whole thing. `Enter` inserts a newline and does not submit |
|
|
118
|
+
| **Pull back** | Grabs what is already in the remote input line into the textarea for editing (useful when the remote side has been Tab-completing) |
|
|
119
|
+
| **Auto pull** | Every 500ms by default. Switching panes swaps in the new pane's content; **never overwrites a local draft**, it just says so in the status line |
|
|
120
|
+
| **Two-way** | Local edits get pushed back into the remote input line (without Enter). Off by default — see the caveats below |
|
|
121
|
+
| **Image** | Upload an image; the path is inserted **at the cursor**. On a phone it offers camera / library; on a desktop just `⌘V` a screenshot into the box, or drop a file. You do not need the outbox open for this — bind `act:img` on the soft key bar, or paste anywhere on the page |
|
|
122
|
+
| `↑` | With an empty box, recalls the last thing you posted (30 kept locally) |
|
|
123
|
+
| `Esc` | **Forwarded to the terminal.** Esc means nothing inside a plain textarea, while the agent needs it constantly (overlays like `/usage` close with it); focus does not move, so you can press it repeatedly |
|
|
133
124
|
|
|
134
|
-
|
|
125
|
+
**How images actually work**: herdr's socket API has no concept of an image — text is all you can post. But claude and codex both read image files off disk (verified: both described a 320×200 test image, red left half, blue right half; codex even prints `Viewed Image`). So "upload" means: store it under `~/.herdr-web/uploads/` on the machine running herdr, then hand over the **absolute path** as text and let the agent open it.
|
|
135
126
|
|
|
136
|
-
|
|
127
|
+
**Uploading does not require the outbox**: bind a key to `act:img` (Settings → Soft keys, the "Web actions" preset group ships a 🖼 one; position and label are yours to change), and **the whole page accepts a paste** (`⌘V` or long-press paste — if the clipboard holds an image it is uploaded directly). Where the path lands depends on whether the outbox is open: open, it is appended to your draft (so you can keep dictating and post the lot); closed, it is **typed straight into the terminal**, i.e. into the current pane's input line for you. Most of the time you just want to throw a screenshot at the agent, and opening the outbox for that is not worth it.
|
|
137
128
|
|
|
138
|
-
|
|
129
|
+
The entry point lives on the soft key bar rather than the top bar: the top bar already carries eight buttons on a tablet, and the soft key bar is by definition "a row of actions you arrange yourself" — whether this key exists, where it sits and what it is called all belong to you.
|
|
139
130
|
|
|
140
|
-
|
|
131
|
+
Pasting is handled by a **capture-phase** listener on `window`: it has to beat xterm's hidden textarea, otherwise an image-only clipboard makes xterm paste an empty string into the terminal. A paste that lands inside the outbox textarea is let through and handled there (that is where it can be inserted at the cursor).
|
|
141
132
|
|
|
142
|
-
|
|
133
|
+
Phone photos are downscaled to a 2400px long edge in the browser first, and iPhone HEIC is converted to PNG/JPEG (agents cannot read HEIC). The server identifies the type by **magic number** and accepts only png / jpg / gif / webp, so renaming the extension or lying about content-type does not get through. 25 MB cap. Uploads are never garbage collected; clear `~/.herdr-web/uploads/` yourself when it piles up.
|
|
143
134
|
|
|
144
|
-
|
|
135
|
+
The status line always says where this paragraph is going; `⟳` means it is following focus, and hovering shows the polling interval in use.
|
|
145
136
|
|
|
146
|
-
###
|
|
137
|
+
### Polling, not push
|
|
147
138
|
|
|
148
|
-
herdr
|
|
139
|
+
herdr has an `events.subscribe` channel, but a working agent produces screen-refresh volumes of it, so this uses polling: every `HERDR_WEB_POLL_MS` (500ms default) it asks "which pane has focus, and what is in its input line".
|
|
149
140
|
|
|
150
|
-
|
|
141
|
+
**Measured latency from switching panes to the textarea updating** (same machine, 8 samples):
|
|
151
142
|
|
|
152
|
-
|
|
|
143
|
+
| Poll interval | Fastest | Median | Slowest |
|
|
153
144
|
|---|---|---|---|
|
|
154
145
|
| 200ms | 138ms | 318ms | 550ms |
|
|
155
|
-
| **500ms
|
|
146
|
+
| **500ms (default)** | ~300ms | ~500ms | ~800ms |
|
|
156
147
|
| 1200ms | 408ms | 794ms | 818ms |
|
|
157
148
|
|
|
158
|
-
|
|
149
|
+
The floor is the cost of one sync, because every herdr call can land on a ~100ms tick (see "the 100ms wall" in [HERDR-API.md](HERDR-API.md)). To try a different feel temporarily, add `?poll=200&push=400` to the URL; it overrides what the server hands down.
|
|
150
|
+
|
|
151
|
+
Things worth knowing:
|
|
152
|
+
|
|
153
|
+
- **The moment the box holds something you wrote, the target is pinned** to the pane you were aiming at; it goes back to following focus when the box is empty. herdr moves focus on its own when agent state changes, and without the lock "what you wrote for A gets posted into B". Auto-pulled content you have not touched does not count as a draft — switching panes there still follows along.
|
|
154
|
+
- **"Two-way" only makes sense for panes with a real input line** (claude / codex). An ordinary pane might be running vim or some picker, where characters are **commands**, not text. Also do not type into that pane by hand while it is on — the local→remote direction is essentially fighting a byte stream for the buffer.
|
|
155
|
+
- **Posting is refused while the remote side has a picker or confirmation open** (if it cannot be cleared it will not post, otherwise you get "leftovers + new text" submitted together). Press `Esc` in that pane and post again.
|
|
156
|
+
- **Nothing is posted when no input line can be recognised on an agent pane** either (a pager, an editor or some full-screen widget is up). "Pull back" also stays quiet then — unrecognised is unrecognised, and it will not fall back to "the last line on screen". Shell panes never have a readable input line and are unaffected; posting works as usual.
|
|
157
|
+
- The socket is on **the machine running the herdr server**. For now that is the local one (or whatever `HERDR_WEB_SOCKET` points at).
|
|
158
|
+
|
|
159
|
+
## Pane list (how you switch panes on a phone)
|
|
160
|
+
|
|
161
|
+
The ▦ in the top bar (or an `act:panes` key on the soft key bar) opens a list of panes grouped by workspace, one row each. **Tap a row and you are there, zoomed full screen.** Above the list you can filter (tab name / title / path / pane id), show only panes running an agent, and turn "zoom" off (a tap then means "move focus, leave zoom alone").
|
|
162
|
+
|
|
163
|
+
**Why it exists.** The soft key bar sends **keys**, and keys can only express **relative** navigation: next tab, one pane to the right. "Zoom `w5:p3`" is not expressible as a key — you have to decompose it into a walk, and every screen along that walk is exactly the unzoomed multi-pane layout that is unreadable on a phone. The scale measured here is 48 panes / 38 tabs / 4 workspaces; one trip is four blind legs of workspace → tab → pane → zoom.
|
|
164
|
+
|
|
165
|
+
herdr's socket layer is **addressed by pane_id**: `pane.zoom` with a `pane_id` crosses workspace + tab + pane in one call (focus follows across workspace and tab; no `workspace.focus` then `tab.focus` needed — verified). So the UI is simply a row you tap.
|
|
166
|
+
|
|
167
|
+
**It is an index, not a second interface.** After the tap you are looking at the same herdr terminal — the page is attached to the whole TUI, so when herdr moves focus the picture follows by itself, and every keyboard habit is unchanged. That is deliberate: "take over one pane on mobile and build a graphical pane manager" could be made flashier, at the cost of **two sets of habits** and a second source of truth. So this panel answers "where to", and does not create, rename or delete anything.
|
|
168
|
+
|
|
169
|
+
On a phone there is a third entrance, and it is the handiest one: **tap the `switch` button in herdr's own mobile top bar** and this list is what opens (on by default — see [Tapping herdr's switch opens ours](#tapping-herdrs-switch-opens-ours-on-by-default)).
|
|
170
|
+
|
|
171
|
+
### One tap has to jump
|
|
172
|
+
|
|
173
|
+
Tapping a row while the on-screen keyboard was up used to take **two taps: the first only dismissed the keyboard, the second jumped** (seen on a real phone). Two causes stack up:
|
|
174
|
+
|
|
175
|
+
- All three entrances deliberately **do not let the browser move focus** — the soft key bar calls `preventDefault` on mousedown, and the touch layer swallows `touchstart` entirely (otherwise a swipe turns into a text selection, see [Phones](#phones)). So when the panel floats up, the outbox / terminal input is still focused and the keyboard still owns half the screen.
|
|
176
|
+
- That one tap therefore **first** takes focus away: `--vvh` follows visualViewport, the panel reflows, and the row under your finger has moved — so the browser dispatches the click somewhere else, or not at all.
|
|
177
|
+
|
|
178
|
+
Both ends are plugged now: **opening the panel blurs the focused input** (the web has no "hide keyboard" API; blur *is* hiding the keyboard), and the filter box no longer autofocuses on touch — the test changed from "phone portrait (< 440px)" to "is there a fine pointer", because tablets used to autofocus too, which pops the keyboard right back up. And **a row commits on `pointerup`, not on `click`**: touch and pen pointer events have implicit capture, so whichever row got the `pointerdown` also gets the `pointerup`, however much the layout moved in between. Mouse and keyboard still go through `click` (on the desktop a click is never lost), and a finger that travels more than 10px counts as scrolling the list, not a tap.
|
|
179
|
+
|
|
180
|
+
The two file-browsing surfaces (directory panel / viewer) are opened by tapping in the terminal too, so they dismiss the keyboard on open as well.
|
|
181
|
+
|
|
182
|
+
### Sorting (switchable)
|
|
183
|
+
|
|
184
|
+
The button at the top cycles it; the choice is remembered locally:
|
|
185
|
+
|
|
186
|
+
| Order | Rule |
|
|
187
|
+
|---|---|
|
|
188
|
+
| **Priority** (default) | By how much it wants your eyes: **waiting on you > finished > running > idle > not an agent**, and within a tier by most recently changed |
|
|
189
|
+
| **Grouped** | By workspace, then the original tab / pane order — the same thing you see inside herdr |
|
|
190
|
+
|
|
191
|
+
Status dot colours: **red = waiting on you, green = finished, yellow = running**, grey for idle. Running is yellow rather than green to match herdr's own agents column; green is reserved for "finished" (the universal convention: good news). Only idle gets no colour — a column where every dot is coloured has no emphasis left.
|
|
192
|
+
|
|
193
|
+
`Running` gets its own tier. It started merged with `idle` (the reasoning: neither needs you, and there is no objective answer for which comes first, so let "recently changed" decide). In practice that was wrong: the one yellow dot that is actually running gets buried under a dozen idle ones, and it is precisely the row you most want to spot. `Waiting` and `Finished` additionally carry a small label on the row; the other states only have the dot — a label on every row is no emphasis at all.
|
|
194
|
+
|
|
195
|
+
**Within a tier the order comes from `state_change_seq`** (herdr's global counter, bumped on every agent state change), not from time. Because there is **not a single timestamp in herdr's API**: `agent.list` only gives that counter, and events carry no time either. The counter is always right, so the ordering is always right.
|
|
196
|
+
|
|
197
|
+
### The "3 minutes ago" column
|
|
198
|
+
|
|
199
|
+
Times are **stamped by herdr-web itself** as it watches state changes (`internal/agentwatch`: subscribe to `pane.agent_status_changed`, record `time.Now()` on arrival). Consequences:
|
|
200
|
+
|
|
201
|
+
- **The column is empty on a first run** and fills in as changes happen. Empty is the truth — that change happened before anyone was watching, and inventing a time would be much worse than leaving it blank. A line under the list says so.
|
|
202
|
+
- **Stamps are keyed by `terminal_id`** (`~/.herdr-web/agent-seen.json`), so restarting herdr-web (an upgrade, a config change) does not lose them. It cannot be keyed by `pane_id`: that is a positional number inside herdr, reassigned to someone else as soon as a pane opens or closes, and you would end up attributing one pane's history to another. After a herdr restart every terminal id is new, so old records simply do not match — and only currently-live terminals are written back, so the file never grows fat.
|
|
203
|
+
- **State is not persisted**, only time. Persisting state means that after a restart, comparing against the old state records "changed while we were down" as "changed just now".
|
|
204
|
+
- When the subscription is not connected (no herdr server running), a line under the list says so, otherwise an empty column looks broken.
|
|
205
|
+
- Display is compact (`3m` `2h` `4d`; under 45 seconds is "just now"), full timestamp in the `title`. That column is a few dozen pixels wide on a phone — "3 minutes ago" does not fit.
|
|
206
|
+
|
|
207
|
+
A few details:
|
|
208
|
+
|
|
209
|
+
- **Pane ids are shown on phones too.** When a tab is split into two panes, both rows carry the identical tab label and cwd (seen in the wild); the id is the only thing that tells them apart.
|
|
210
|
+
- The second line is the **agent's own session title** (Claude Code's "image recognition" and the like), falling back to cwd — a shell pane's title is just `user@host:path`, which is worth less than the path.
|
|
211
|
+
- **Focus is not stolen after a jump** on phones (that would pop the system keyboard, and you mostly jumped there to look); on wide screens it is.
|
|
212
|
+
- The post target takes care of itself: "follow herdr's current pane" moves along. With a local draft the target stays pinned to the original pane — words written for A should not go to B just because you looked at B.
|
|
213
|
+
- The "zoom" toggle is remembered locally. On by default on phones: a tiled multi-pane layout is unreadable there, so arriving without zooming is the same as not arriving.
|
|
214
|
+
- `zoomed` is a property of **the whole tab**, not of a pane (herdr always zooms the focused one). So "this tab only has one pane" comes back as `zoomed:false`, which is not a failure — the UI says so explicitly.
|
|
215
|
+
|
|
216
|
+
## Notices: a card when an agent changes state (top right + badge)
|
|
217
|
+
|
|
218
|
+
When an agent stops to wait for you (or has just finished), a card appears in the top right **carrying what it said**, and a badge with the count lights up on the ▦ in the top bar. Tapping the card jumps to that pane (zoomed — the same action and the same "zoom" toggle as tapping a row in the pane list).
|
|
219
|
+
|
|
220
|
+
**Why this exists.** Only one of herdr's panes is visible in the page (on a phone, only the zoomed one), while a dozen agents can be running at once. Finding "the one waiting for me" used to mean opening the pane list and scanning it — so an agent stuck on a y/n for half an hour was routine.
|
|
221
|
+
|
|
222
|
+
**The card has to carry the actual text**, not just "something changed": with only "something changed" you still have to jump over to find out whether it needs you, which is the same as no notice at all. herdr's API has no "what did the agent last say" field (same story as "no input line content field"), so this text is **scraped off the screen**.
|
|
223
|
+
|
|
224
|
+
### When it fires
|
|
225
|
+
|
|
226
|
+
| Change | Fires? | Why |
|
|
227
|
+
|---|---|---|
|
|
228
|
+
| → `blocked` (waiting on you) | **Yes**, and it does not auto-dismiss | It really is sitting there waiting. A card that floats away by itself puts you back to "no idea who is waiting" |
|
|
229
|
+
| → `done` (finished) | Yes, dismissed after 12s by default | An explicit "your turn" from herdr, whatever it came from |
|
|
230
|
+
| `working` → `idle` (finished) | Yes, same | `idle` is *resting*, so it only counts as "finished" when it came from `working` |
|
|
231
|
+
| → `working` | No | That is almost always what you just posted — an echo |
|
|
232
|
+
| `blocked` → `idle` | No | You just answered and it is about to start; reporting "finished" would be a lie |
|
|
233
|
+
|
|
234
|
+
**`done` fires no matter where it came from — requiring `working` first was a bug.** On a real device, poking an agent with a one-line "say hi" gives `idle → done` with **no `working` in between** (herdr's screen detection is conservative and a short task never registers as running). The first version required `working → done`, so short tasks produced no notice at all — every unit test stayed green and only the end-to-end poke found it.
|
|
159
235
|
|
|
160
|
-
|
|
236
|
+
**A state has to hold for 2.5 seconds to count.** claude / codex flicker back to idle while working, and without debouncing one long task produces a dozen false "finished" cards; `pane.read` snapshots also lag by a frame, so reading immediately catches the tail of the previous task. If the state changes again within those 2.5 seconds (`idle → blocked` is a common pair), the **last** one is reported, as a single card.
|
|
161
237
|
|
|
162
|
-
|
|
163
|
-
- **「双向」只对 claude / codex 这种有真输入框的 pane 生效。** 普通 pane 里跑的可能是 vim 或某个选择器,那里的字符是**命令**不是文本。开着的时候也别同时在那个 pane 里手敲字——本地→远端这个方向本质上是在跟字节流抢缓冲区。
|
|
164
|
-
- **远端正开着选择框 / 确认框时会拒绝投递**(清不空就不投,否则就是「残留 + 新文本」一起回车)。去那个 pane 按 `Esc` 收掉再投。
|
|
165
|
-
- **agent pane 上认不出输入框时也不投**(屏幕上正开着分页器 / 编辑器 / 某个全屏控件)。这时候「拉回」也不会往框里塞东西 —— 认不出就是认不出,不会退回屏幕最后一行。shell pane 天生读不到输入行,那边不受影响,投稿照常。
|
|
166
|
-
- socket 在**跑 herdr server 的那台机器**上。现在只连本机(或 `HERDR_WEB_SOCKET` 指到的路径)。
|
|
238
|
+
**State comes from polling with events as an accelerator — not from events alone.** Measured on a real device: herdr only pushes `pane.updated` for panes that are **visible**. Poke an agent sitting in a background workspace and `pane.get` reports `working` after 6s and `done` after 13s, while the event stream's **first event arrives 40 seconds later**:
|
|
167
239
|
|
|
168
|
-
|
|
240
|
+
```
|
|
241
|
+
0.1s [poll pane.get] done ← posted "say hi"
|
|
242
|
+
6.2s [poll pane.get] working
|
|
243
|
+
13.2s [poll pane.get] done ← finished after 13s
|
|
244
|
+
40.8s [event pane.updated] first event finally arrives
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Background panes are exactly the ones notices are for (the one you can see needs no notice), so a `pane.list` poll every 3 seconds is the floor and events only make the pane you are looking at near-instant. One `pane.list` is a single socket call of a few dozen KB — far lighter than the outbox's three calls per 500ms while it is open.
|
|
248
|
+
|
|
249
|
+
**Nothing is replayed when herdr comes back after being down.** Reconciling at that moment finds a screen full of panes whose state differs from before the outage, but those changes happened "sometime in the last half hour"; firing a burst of "just finished" would be inventing timestamps — the same reason the "3 minutes ago" column refuses to invent times for old changes. The cost is that a change inside the 800ms resubscribe window is missed.
|
|
250
|
+
|
|
251
|
+
### How the text is scraped
|
|
252
|
+
|
|
253
|
+
Two modes by state — the same screen of text, but "waiting on you" and "finished" live in completely different places:
|
|
254
|
+
|
|
255
|
+
- **Waiting on you**: the question is in the block at the **bottom** of the screen — a small heading like `☐ Install the service?`, or a `╭ … ╰` box. So it takes everything from the last `☐` / `╭` downwards, stopping at the key hints (`Enter to select · ↑/↓ …`). **Box drawing is stripped, options are kept** (the `❯` in `❯ 1. …` marks the current choice, which is information). When there is no dialog on screen (`agent_status` cannot tell — see [Limits](#limits)) it falls back to the "finished" scraper.
|
|
256
|
+
- **Finished**: take the last `⏺` block — but **skip the ones carrying `⎿`**. `⎿` marks tool output (`⏺ Searching for 1 pattern…` followed by a screen of `⎿ $ cd …`), which is the log of it working, not something it said to you. Caught on a real device: at the instant the state flipped to idle, the bottom of the screen happened to be a run of shell commands, and the first version pushed out `cd /private/tmp/…` as "the result". Walking up to the first non-tool block is what gets you "Go side done (tests pass). Writing the frontend now." It stops at the spinner (`✻ Baked for 20s`), the recap (`※ recap: …`) or the `────` / `❯` around the input line. Agents like codex with no `⏺` at all degrade to the last few lines.
|
|
257
|
+
|
|
258
|
+
Anything over 12 lines / 600 characters is truncated — keeping the beginning, since the first sentence of an answer carries the most — with a trailing `…`. **If nothing can be scraped it reports the state alone rather than manufacturing a sentence**; inventing content is far worse than leaving it out.
|
|
259
|
+
|
|
260
|
+
The scraper is adapted from `extractResult` in [herdr-sight](../herdr-sight) (which only has the "finished" case, because it is about collecting results from a finished task; here the case that most needs a notice is "waiting on you"). `internal/agentwatch/testdata/` holds **real captured screens**, and changing this code means running `go test ./internal/agentwatch/` — every rule was chosen against the actual shape of those screens, and changing one from imagination breaks the scrape silently (the symptom is a card containing a lone `❯` or a piece of the status bar, with nothing in the logs).
|
|
261
|
+
|
|
262
|
+
### The cards
|
|
263
|
+
|
|
264
|
+
- **Tap = jump there** (zoomed; same action and same "zoom" toggle as a pane list row), and the card dismisses itself afterwards — you already went, leaving it up only blocks the view.
|
|
265
|
+
- At most **3** stack in the corner; the rest collapse into one line: "N more · open the pane list".
|
|
266
|
+
- **How long they stay is a setting** (5s / 12s (default) / 30s / 1min / stay put). "Waiting on you" ignores it and always stays.
|
|
267
|
+
- **"Waiting on you" sorts to the top**, then by recency. Not purely by time, because the two kinds have different lifetimes: "finished" leaves after a dozen seconds while "waiting on you" stays, so time ordering lets a fresh "finished" push down the one that is actually waiting — the only one that needs you to do anything. When the stack is full, "finished" cards are dropped first.
|
|
268
|
+
- The whole stack gets out of the way while a panel is open: those overlays share the same corner.
|
|
269
|
+
|
|
270
|
+
### The badge (how many are still unread)
|
|
271
|
+
|
|
272
|
+
The ▦ in the top bar carries a **number**: how many notices you have not looked at. The `act:panes` key on the soft key bar carries the same one — on a phone the top bar collapses the moment the keyboard is up, which is exactly when you are talking to an agent and most need to know another one is waiting.
|
|
273
|
+
|
|
274
|
+
A number rather than a dot: a dot only says "something is there", while *how many* is actionable — two agents waiting and five agents waiting are different decisions. Over 9 it reads `9+`.
|
|
275
|
+
|
|
276
|
+
**What counts as seen:**
|
|
277
|
+
|
|
278
|
+
| Action | Badge |
|
|
279
|
+
|---|---|
|
|
280
|
+
| **Tap a card to jump** | Everything unread for that pane clears (the number drops). **Per pane, not per notice** — you are looking at that agent's current screen, which includes what it said earlier |
|
|
281
|
+
| Tap a system notification | Same |
|
|
282
|
+
| **Open the pane list** | Everything counts as seen (that is where these changes are meant to be read, in one scan) |
|
|
283
|
+
| Dismiss a single card (×) | **Not** seen — that only means it was in the way |
|
|
284
|
+
|
|
285
|
+
With two agents waiting, tapping into one takes the badge from 2 to 1 rather than clearing it; it goes away when you have been into both.
|
|
169
286
|
|
|
170
|
-
|
|
171
|
-
pane,**点一下就跳过去并铺满全屏**。列表上方能筛(tab 名 / 标题 / 路径 / pane id)、能只看
|
|
172
|
-
跑着 agent 的、能关掉「全屏」(那时候点一行是「切焦点 + 退出放大」)。
|
|
287
|
+
**A refresh does not lose it** (`localStorage`): on a phone, a badge that vanishes on reload makes the whole feature pointless. What is stored is a **watermark** (seq — everything below it is seen) plus **the handful above it you have already seen**: a watermark alone cannot express "read #7, not #6". Once nothing is unread the watermark moves up and that list is cleared, so it stays a few entries long. Named sessions track their own (that is a different herdr).
|
|
173
288
|
|
|
174
|
-
|
|
175
|
-
「让 `w5:p3` 全屏」这句话用按键说不出来,只能拆成一串相对动作走过去 —— 而中间每一步的屏幕,
|
|
176
|
-
正好都是「未放大的多 pane 布局」那个在手机上根本读不了的状态。本机实测的规模是 48 个 pane /
|
|
177
|
-
38 个 tab / 4 个 workspace,一趟是 workspace → tab → pane → zoom 四段盲走。
|
|
289
|
+
**It can be turned off** if it bothers you: Settings → Terminal, "badge on the panel icon". That only stops drawing the badge; the cards in the corner still appear. Stored locally, per device. To turn the whole feature off, that is `HERDR_WEB_NOTICE_MS=0` on the server side.
|
|
178
290
|
|
|
179
|
-
|
|
180
|
-
workspace + tab + pane(焦点连着 workspace 和 tab 一起切过去,不用先 `workspace.focus`
|
|
181
|
-
再 `tab.focus`,实测确认)。所以界面上就是点一行。
|
|
291
|
+
**Opening the page lights the badge but replays no cards**: those changes may be half an hour old, and showing them as if they just happened is inventing time.
|
|
182
292
|
|
|
183
|
-
|
|
184
|
-
herdr 那边一切焦点,画面自己就跟过来了;键盘那套操作一个字都没变,Mac 上照旧敲键盘。这条是
|
|
185
|
-
刻意的:手机端「只接管一个 pane + 做一套图形界面管面板」的做法能做得更花,代价是**两套使用
|
|
186
|
-
习惯**和第二个真相源。所以这个面板只做「去哪儿」,不做增删改。
|
|
293
|
+
### System notifications
|
|
187
294
|
|
|
188
|
-
|
|
295
|
+
Settings → Terminal → "System notifications" (**you have to tap it yourself** — browsers only hand out the permission prompt inside a user gesture). After that, new notices go out as browser notifications **while you are not looking at the page**; tapping one brings the page back to the front and jumps to that pane.
|
|
189
296
|
|
|
190
|
-
|
|
297
|
+
- **The test is "are you looking at this page", not `document.hidden`.** On macOS, switching to another app only unfocuses Chrome — the tab still counts as visible and `hidden` stays false, so a `hidden`-only test never fires in the most common case (that was the first version, and the report was "system notifications simply never show up"). The check is `document.hidden || !document.hasFocus()`. There is a "notify me even while I am looking at this page" switch for people who want both.
|
|
298
|
+
- **A "Test it" button** ignores both the switch and the focus test and fires one right away. Where it is stuck (permission? focus mode? iOS not installed to the home screen?) cannot be guessed — one tap answers it, and the reason comes back as a toast.
|
|
299
|
+
- **One notification per agent** (the tag is the `terminal_id`), replacing the previous one instead of piling up in the notification centre.
|
|
300
|
+
- **On phones**: Android Chrome works in an ordinary tab. **iPhone / iPad must "Add to Home Screen"** and open it from there (a page in a Safari tab cannot get notification permission; iOS 16.4+).
|
|
301
|
+
- Turning the switch on registers a `sw.js` (**and only then**). Both Android Chrome and iOS require `ServiceWorkerRegistration.showNotification()` — the `new Notification()` constructor is unavailable there. That worker **caches nothing** (it does not even listen for `fetch`); it only handles notification clicks. A worker that intercepts requests on a terminal page buys nothing and costs you "I changed it and nothing happened" debugging sessions.
|
|
302
|
+
- **Close the page and they stop.** Real "even with the browser closed" needs Web Push (VAPID keys, stored subscriptions, the server pushing) — a whole other stack, not built.
|
|
303
|
+
- Over plain http (not https, not localhost) browsers refuse the permission; the switch greys out and says why.
|
|
191
304
|
|
|
192
|
-
|
|
305
|
+
### Limits
|
|
306
|
+
|
|
307
|
+
- `agent_status` cannot tell that a dialog is open (measured: the same picker reported `idle` once and `blocked` another time, see [HERDR-API.md](HERDR-API.md)). So occasionally a question is announced as "finished" — the card still carries the question, only the state label is wrong; the jump is right.
|
|
308
|
+
- The scraping rules follow claude's current UI. After a redesign they may extract something odd; state and jumping still work, and the fix is a fresh capture in testdata plus a rule adjustment.
|
|
309
|
+
- To turn the whole thing off: `HERDR_WEB_NOTICE_MS=0` (the frontend stops polling and stops drawing the badge).
|
|
310
|
+
|
|
311
|
+
## File browsing (looking at what the agent generated)
|
|
312
|
+
|
|
313
|
+
The agent says "the plot is at `/tmp/plot-3.png`" — **tap that path and look at it**.
|
|
314
|
+
|
|
315
|
+
The 📁 in the top bar (or an `act:files` key on the soft key bar) is the fallback directory browser, but it is not the main entry point. The path in the terminal is.
|
|
316
|
+
|
|
317
|
+
### "The image is not under the current workspace"
|
|
318
|
+
|
|
319
|
+
Not solved, because it should never have been a problem. Three routes cover the ground:
|
|
320
|
+
|
|
321
|
+
| Situation | Route |
|
|
193
322
|
|---|---|
|
|
194
|
-
|
|
|
195
|
-
|
|
|
323
|
+
| The path is on screen (the vast majority) | **Tap it.** Absolute paths open directly; `./out/a.png` resolves against **that pane's cwd** (which `pane.list` provides) |
|
|
324
|
+
| The path is not visible, or you want to look around | The 📁 panel. It starts from "every pane's cwd + the upload directory + home + temp + recently visited", and `..` walks all the way to `/` |
|
|
325
|
+
| The file is somewhere nobody would guess (`/var/folders/xx/T/…`) | The box at the top of the panel: **paste an absolute path and open it** |
|
|
326
|
+
|
|
327
|
+
So **there is no boundary by default**: anyone who can open this page already has a login shell (`/pty`) and can `cat` anything — an allowlist would not stop them, it would only get in the way daily (agents write to `/tmp`, `/var/folders/…` and `~/Downloads` constantly). If you want a boundary, set `HERDR_WEB_FILE_ROOTS` (that one is a real jail); to remove the feature entirely, `HERDR_WEB_FILES=0`.
|
|
328
|
+
|
|
329
|
+
### What it can show
|
|
196
330
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
331
|
+
- **Images**: png / jpg / gif / webp, identified by **magic number** (a renamed extension does not fool it). Tap to toggle between "fit" and "actual size". The top right can copy the path, open the containing directory, open it in a new tab (where a long press saves it to the camera roll) and **hand it to the agent** (insert the absolute path into the outbox, or type it into the terminal — exactly the same model as uploading).
|
|
332
|
+
- **Text / code**: shown as-is, truncated past 512 KB with a note.
|
|
333
|
+
- **Anything else**: download only.
|
|
200
334
|
|
|
201
|
-
|
|
202
|
-
答案,交给「刚动过」去定),实际用起来不对:黄点那个正在跑的会被十几个闲着的埋掉,而列表里
|
|
203
|
-
最想一眼看到的恰恰是它。`等你` / `完成` 两档在行上额外挂一个小标签,别的状态只有那个点的
|
|
204
|
-
颜色 —— 每行都塞标签就没有重点了。
|
|
335
|
+
### The link route: `/_f/<ticket>`
|
|
205
336
|
|
|
206
|
-
|
|
207
|
-
不认时间。因为 herdr 的 API 里**一个时间戳都没有**:`agent.list` 只给这个计数,事件里也不带
|
|
208
|
-
时间。计数一直是对的,所以排序一直是对的。
|
|
337
|
+
Images are served over a short-lived link that carries **no cookie**. It has to be: cookie-authenticated requests on `/api/*` require a custom header (the third CSRF layer), and `<img src>`, "open in new tab" and iOS "long press to save" **cannot set headers** — through `/api` they would all be 403.
|
|
209
338
|
|
|
210
|
-
|
|
339
|
+
The ticket is a capability, not an identity: **bound to one absolute path**, expiring in 15 minutes, signed with a key generated at process start and **kept in memory only** (a restart invalidates every ticket, and no long-lived secret lands on disk). The trade-off, stated plainly: the ticket is in the URL, so it enters browser history and shows up in screenshots, and whoever holds that string can read that **one** file for 15 minutes.
|
|
211
340
|
|
|
212
|
-
|
|
213
|
-
`pane.agent_status_changed`,收到就记 `time.Now()`)。所以:
|
|
341
|
+
### Four hard rules on that route
|
|
214
342
|
|
|
215
|
-
|
|
216
|
-
开始盯之前,没法知道是什么时候,编一个时间比空着糟得多。列表底下会说明这一句。
|
|
217
|
-
- **按 `terminal_id` 存盘**(`~/.herdr-web/agent-seen.json`),所以 herdr-web 自己重启
|
|
218
|
-
(升级、改配置)不丢时间。不能按 `pane_id` 存:那是 herdr 里的位置编号,pane 一开一关就
|
|
219
|
-
重新分配给别人了,会张冠李戴。herdr 重启之后终端 id 全是新的,旧记录自然对不上 —— 存盘时
|
|
220
|
-
只写这会儿还在的终端,文件自己就不会长胖。
|
|
221
|
-
- **状态不存盘**,只存时间。存了状态的话,重启后拿旧状态一比就会把「停机期间变的」记成
|
|
222
|
-
「刚刚变的」。
|
|
223
|
-
- 订阅没连上(herdr server 没在跑)时,列表底下会说一句,免得空着的时间列看着像坏了。
|
|
224
|
-
- 显示用 `3m` `2h` `4d` 这种紧凑写法(不到 45 秒算「刚刚」),完整时间在 title 里。手机上这一列
|
|
225
|
-
只有几十像素,「3 分钟前」四个字放不下。
|
|
343
|
+
None is optional, because what comes out is **a file the agent wrote**:
|
|
226
344
|
|
|
227
|
-
|
|
345
|
+
1. **Never serve content as `text/html`.** Same-origin HTML is a springboard that can call `/api/herdr/say` (the cookie is HttpOnly, but it does not need to read it — the browser attaches it): the agent writes an html file, you open it, and herdr is theirs. Only the four image types confirmed by magic number are served inline; everything else is `application/octet-stream` + `attachment`. **SVG does not count as an image** (a scriptable "image") and lands in the attachment tier.
|
|
346
|
+
2. **Regular files only.** Opening `/dev/zero` is an infinite stream and `/dev/rdisk0` is worse. Devices / sockets / pipes are still listed in a directory (a listing should tell the truth), they just cannot be opened.
|
|
347
|
+
3. **A per-response CSP `sandbox`**, overriding the global one. If either of the first two ever breaks, this layer is still standing.
|
|
348
|
+
4. **With `FILE_ROOTS` set, the prefix check runs after `EvalSymlinks`** and compares against `root + separator`. Drop either half and it silently passes (a symlink can point outside, and `/home/user` would admit `/home/user2`).
|
|
228
349
|
|
|
229
|
-
|
|
230
|
-
一模一样(实拍见过),id 是唯一分得开的东西。
|
|
231
|
-
- 第二行给的是 **agent 自己写的会话标题**(Claude Code 那个「图片识别」之类),没有才退回
|
|
232
|
-
cwd —— shell pane 的标题只是 `user@host:path`,不如路径。
|
|
233
|
-
- **跳完不自动聚焦终端**(手机上那一下会把系统键盘顶出来,而刚跳过去多半是要看);宽屏上顺手聚上。
|
|
234
|
-
- 投稿目标不用管:默认那条「跟随 herdr 当前 pane」自己就跟过去了。本地有草稿时目标仍然锁在
|
|
235
|
-
原来那个 pane 上 —— 为 A 写的话不该因为你去 B 看了一眼就投给 B。
|
|
236
|
-
- 「全屏」开关记在本地(`localStorage`)。手机上默认开:多 pane 平铺读不了,去了不放大等于没去。
|
|
237
|
-
- `zoomed` 是**整个 tab** 的状态,不是某个 pane 的(herdr 放大的永远是当前焦点 pane)。
|
|
238
|
-
所以「这个 tab 只有一个 pane」会回 `zoomed:false`,那不是失败,界面上单独说一句。
|
|
350
|
+
### Path detection pitfalls (all in `web/src/term/paths.ts`)
|
|
239
351
|
|
|
240
|
-
|
|
352
|
+
- **Paths get broken by wrapping.** In an 80-column pane a long path spans two rows, so logical lines (the `isWrapped` chain) are reassembled before matching.
|
|
353
|
+
- **Reassembly cannot use `translateToString`.** With CJK text its character count does not match the cell count, so mapping an index back to coordinates shifts the whole line — the underline lands on half a word and tapping picks up a different span. Cells are read one by one instead.
|
|
354
|
+
- **CJK punctuation has to terminate a path.** Measured: the agent writes 「生成好了 /tmp/a.png。相对的 …」 with no space between the path and the text — without stopping at `。`, the whole `a.png。相对的` is taken as a path, which opens to "no such file" while the underline looks perfectly fine. CJK characters themselves are still allowed (`/tmp/图表.png` is a legal filename).
|
|
355
|
+
- **A bare relative path must carry an extension.** Counting slashes is not enough: `2026/08/21` has two, and "two segments is enough" would draw a link on it. Same for `and/or` and `100/200`. Rooted paths (`/usr/local/bin`) are exempt.
|
|
356
|
+
- **A path already truncated by `…` gets no link.** Unrecognisable is unrecognisable; do not guess a shorter one and then report "no such file".
|
|
241
357
|
|
|
242
|
-
|
|
358
|
+
## Settings panel
|
|
243
359
|
|
|
244
|
-
|
|
360
|
+
The ⚙ at the right end of the top bar holds everything, in three pages: **Terminal** (font size / light-dark, kitty protocol / Option as Meta / copy on select / synchronized output, tapping herdr's switch opens the pane list, the badge on the panel icon, system notifications, how long notice cards stay, plus a line of backend environment), **Soft keys** (next section), and **Devices** (who has paired, sign out, kick).
|
|
245
361
|
|
|
246
|
-
|
|
362
|
+
The font size / light-dark controls at the top of the Terminal page are the same actions as the three icons in the top bar, not a second copy of the state: a phone in portrait has no room for them in the top bar, so that is the only place to reach them.
|
|
247
363
|
|
|
248
|
-
|
|
364
|
+
This used to be three independent little panels with three icons in the top bar — which is crowded on a tablet, and "settings" split three ways means finding anything depends on remembering which icon is which. **The ⚙ that used to sit in the bottom right of the soft key bar (a shortcut to the Soft keys page) is gone too**: it lived permanently at the end of the key row competing for space (especially in portrait), while editing keys is a thing you do once — going through the top bar is not a burden.
|
|
249
365
|
|
|
250
|
-
|
|
366
|
+
## Soft key bar
|
|
251
367
|
|
|
252
|
-
|
|
368
|
+
Phones have no Ctrl key, and herdr's `ctrl+b` prefix depends on one. The keys live **on the server** (`~/.herdr-web/softkeys.json`), so phone / tablet / desktop share one set, edited in Settings → Soft keys.
|
|
253
369
|
|
|
254
|
-
|
|
370
|
+
**One row or two is a setting** (server-side, travelling with the config), not a guess based on "is the second row empty" — an empty second row and "I only want one row" are different things. The two rows **scroll horizontally independently**: a phone fits four or five keys per row, so put the common ones on the first row and the rest on the second; that beats a dozen keys in one long queue, because your finger knows which row it is on and scrolling one does not drag the other. Switching back to one row **appends the second row's keys to the end of the first** (the server computes it the same way; "stored but not shown" is the most annoying state there is).
|
|
255
371
|
|
|
256
|
-
|
|
257
|
-
- **`bar` = 软键条**:每行一串 **id**,指向「我的按键」里的定义。
|
|
372
|
+
The editor has two layers, and so does what gets persisted (`{rows, keys, bar}`):
|
|
258
373
|
|
|
259
|
-
|
|
374
|
+
- **`keys` = "My keys"**: the **definitions**, each with an `id`. Adding, renaming, editing the key spec / width / double-tap, and deleting for good all happen here.
|
|
375
|
+
- **`bar` = the bar**: each row is a list of **ids** pointing into "My keys".
|
|
260
376
|
|
|
261
|
-
|
|
262
|
-
- 改一处定义,条上所有引用一起变;
|
|
263
|
-
- ✕ 只是去掉一个引用,定义还在库里,随时再拖上去。删定义会把条上的引用一起清掉(顺手 toast 说清了几处 —— 不然就是「保存完少了个键」)。
|
|
377
|
+
The bar stores ids rather than whole definitions so that keys on the bar are **chosen from** your library rather than moved into it:
|
|
264
378
|
|
|
265
|
-
|
|
379
|
+
- the same key **can sit on both rows** (an Esc on row one and another on row two), and dragging one up leaves the library copy alone;
|
|
380
|
+
- edit a definition once and every reference on the bar changes with it;
|
|
381
|
+
- ✕ only removes a reference — the definition stays in the library, ready to be dragged up again. Deleting a definition also clears its references from the bar (with a toast saying how many, otherwise it reads as "a key went missing after saving").
|
|
266
382
|
|
|
267
|
-
|
|
383
|
+
**Built-in presets do not get their own shelf**: sixty-odd keys laid out is longer than the entire page, and they look editable while not being so. There is a single "Load presets" button that pours them all into "My keys" (deduplicated by name + what it does), after which every one of them is yours to edit. That is why the cap on "My keys" (120) is far higher than what fits on the bar — a definition is one line of JSON, it does not cost screen space.
|
|
268
384
|
|
|
269
|
-
|
|
385
|
+
Old configs (where "which row" lived on the key itself as `row` / `off`) are migrated to the two layers on first read, so a bar you already tuned is not lost to an upgrade.
|
|
270
386
|
|
|
271
|
-
-
|
|
272
|
-
- 支持 `ctrl+x` `alt+x` `shift+tab`、具名键(`esc tab enter space bs del ins up down left right home end pgup pgdn f1-f12`)、原样文本。
|
|
273
|
-
- 原样文本两种写法等价:`"herdr" enter` 和 `text:/new enter`(`text:` 是给平板手输准备的 —— 编辑器里本来就有 `sticky:` / `act:` 前缀,找引号反而麻烦;带空格的仍要引号:`text:"git status"`)。
|
|
274
|
-
- 预设分 8 组(前缀 / 标签 / Pane / 工作区 / 终端按键 / 文本 / Claude 命令 / 网页端动作),「载入预设」一下全进「我的按键」。herdr 那几组抄的是 `herdr --default-config` 的 `[keys]` 默认值,改过 keybinding 的人自己改;「Claude 命令」是 `/new` `/clear` `/compact` `/usage` `/context` `/model` `/resume` `/cost`,都带回车,一下点完。
|
|
275
|
-
- 每个键有个**「两下」**勾选框:勾上的键要点两次才真发出去 —— 第一下只是举起来(键变红,文字不变,免得按键变宽把手指底下的键挪走),3 秒不点、或者点了别的键就放下。软键条上键挨得近,关 pane / 关标签这种误触没法撤销。预设里 `关 pane` `关标签` `关工作区` `断开` `/clear` 默认就带。
|
|
276
|
-
- `Ctrl` / `Alt` 是**粘滞**的:点一下亮起,再敲一个字母就发出对应组合键,然后自动灭掉。手机虚拟键盘的 keydown 不可靠,所以这层是在数据流上做的,不依赖按键事件。写法是 `sticky:ctrl` / `sticky:alt`。
|
|
277
|
-
- `act:` 是**网页端自己处理**的动作,不发任何字节:`act:kbd` 呼出 / 收起系统键盘,`act:img` 传图(弹相机 / 相册,路径按「发件箱开没开」决定去草稿还是直接敲进终端),`act:panes` 开「面板一览」(上面那节),`act:clip` 把机器上的剪贴板取到手机剪贴板,`act:paste` 把手机剪贴板粘进终端(后两个见「[手机上怎么复制 / 粘贴](#手机上怎么复制--粘贴)」——**手机上这两条只能是点出来的**,浏览器不给定时器碰剪贴板)。服务端只认白名单里这几个,写错了保存时就报错,不会下发一个点了没反应的键。
|
|
278
|
-
`act:panes` 放在软键条上是有讲究的:手机上键盘一弹起来顶栏整段就收掉了,那时候顶栏那个入口点不到,而软键条正好在拇指底下。
|
|
279
|
-
- 按键谱在**服务端**解析成字节再下发,前端只管照发;写错了保存时会告诉你是第几个按键、哪里不认。回包里 `send` 是**解析好的字节**、`spec` 是你写的谱 —— 编辑器回传时两个字段都在,服务端**认 `spec`**。拿 `send` 当谱重解一次的话,Tab 的 `"\t"` 去掉空白就是空串,报「按键谱是空的」而用户什么都没改(踩过)。
|
|
387
|
+
Dragging requires **a 250ms hold** on touch (a mouse only needs 6px of travel). This page has to scroll vertically, and the keys themselves are the drag handles — a finger that lands on a key and moves down is either scrolling the page or dragging that key, and only "did you hold" separates the two. Hard-coding `touch-action: none` on the keys stops the page scrolling (the keys cover it); `pan-y` eats "drag down to the second row" as a scroll. Once picked up, `preventDefault` on `touchmove` blocks scrolling — the finger has not moved during the hold, the browser has not started scrolling yet, so it is still interceptable.
|
|
280
388
|
|
|
281
|
-
|
|
389
|
+
- The "Keys" field takes a **key spec**; space-separated entries fire in sequence — `ctrl+b c` is herdr's prefix plus c, one tap.
|
|
390
|
+
- Supports `ctrl+x` `alt+x` `shift+tab`, named keys (`esc tab enter space bs del ins up down left right home end pgup pgdn f1-f12`) and literal text.
|
|
391
|
+
- Two equivalent ways to write literal text: `"herdr" enter` and `text:/new enter` (`text:` exists for typing on a tablet — the editor already has `sticky:` / `act:` prefixes, and hunting for quote characters is worse; text with spaces still needs quotes: `text:"git status"`).
|
|
392
|
+
- Presets come in 8 groups (Prefix / Tabs / Pane / Workspace / Terminal keys / Text / Claude commands / Web actions); "Load presets" drops all of them into "My keys". The herdr groups are copied from the `[keys]` defaults of `herdr --default-config` — if you changed your keybindings, change these too. "Claude commands" is `/new` `/clear` `/compact` `/usage` `/context` `/model` `/resume` `/cost`, all with Enter, one tap each.
|
|
393
|
+
- Every key has a **"double-tap"** checkbox: those keys only fire on the second tap — the first only arms it (the key turns red, the label does not change, so the key does not get wider and shove its neighbours out from under your finger), and it disarms after 3 seconds or when you tap something else. Keys sit close together on this bar, and misfiring "close pane" or "close tab" cannot be undone. `Close pane`, `Close tab`, `Close workspace`, `Detach` and `/clear` ship with it on.
|
|
394
|
+
- `Ctrl` / `Alt` are **sticky**: tap once to light it up, then type a letter and the combination is sent, after which it turns itself off. Mobile virtual keyboards produce unreliable `keydown`, so this layer works on the data stream rather than on key events. The spec is `sticky:ctrl` / `sticky:alt`.
|
|
395
|
+
- `act:` actions are **handled in the browser** and send no bytes: `act:kbd` shows/hides the system keyboard, `act:img` uploads an image (camera / library; the path goes to your draft or straight into the terminal depending on whether the outbox is open), `act:panes` opens the pane list (previous section), `act:clip` fetches the machine's clipboard into your phone's clipboard, and `act:paste` pastes your phone's clipboard into the terminal (the last two are explained in [Copy and paste on a phone](#copy-and-paste-on-a-phone) — **on a phone these two can only be tapped**, browsers do not let a timer touch the clipboard). The server only accepts this whitelist; a typo is rejected at save time rather than shipped as a key that does nothing when tapped.
|
|
396
|
+
`act:panes` on the bar is deliberate: on a phone the whole top bar collapses the moment the keyboard comes up, so the top-bar entry is unreachable exactly when the soft key bar is right under your thumb.
|
|
397
|
+
- Key specs are parsed into bytes **on the server** and handed down; the frontend just sends them. A bad spec is reported at save time, telling you which key and where it stopped making sense. In the response `send` is **the parsed bytes** and `spec` is what you wrote — the editor sends both back and the server **trusts `spec`**. Re-parsing `send` as a spec would turn Tab's `"\t"` into an empty string after trimming and report "the key spec is empty" while the user changed nothing (been there).
|
|
282
398
|
|
|
283
|
-
|
|
399
|
+
## Phones
|
|
284
400
|
|
|
285
|
-
|
|
401
|
+
xterm.js's touch support is essentially "tap to focus the hidden textarea"; the rest is ours. When a program has mouse reporting on (herdr does), touch gestures are taken over entirely by this project:
|
|
402
|
+
|
|
403
|
+
| Gesture | Behaviour |
|
|
286
404
|
|---|---|
|
|
287
|
-
|
|
|
288
|
-
|
|
|
289
|
-
|
|
|
290
|
-
|
|
|
405
|
+
| One-finger vertical swipe | Converted to SGR wheel reports by line height — `CSI < 64/65 ; col ; row M` — and sent to the program; with mouse reporting off it scrolls the local scrollback |
|
|
406
|
+
| Tap | With mouse reporting, sends `CSI < 0 ; col ; row M/m` (clicking panes and tabs both work) and **does not pop the system keyboard**; without it, focuses the hidden textarea (a tap there does mean "I want to type") |
|
|
407
|
+
| Long press (≈380ms) | **Grab**: press the left button and hold, plus `CSI < 32` motion reports, so moving afterwards is a drag — this is how you resize herdr's pane borders on a phone. Releasing sends the matching `m` |
|
|
408
|
+
| Double tap | Show / hide the system keyboard |
|
|
409
|
+
|
|
410
|
+
Grabbing **only happens on a long press**. "Grab immediately when the finger lands near a border" was tried and failed: agents draw their own boxes (Claude Code puts a rounded frame around each pane) whose vertical edges also run the full height, indistinguishable from herdr's pane borders at the character level — so a swipe along a frame turned into dragging the mouse inside the agent: the finger wanted to scroll, the screen was selecting text. **A swipe is always a swipe**; changing gear requires holding first.
|
|
411
|
+
|
|
412
|
+
After the hold it **snaps by pixels** to a nearby full-length line (`SNAP_PX = 24`, about the error of one fingertip), not by cells. Also learned the hard way: it used to allow one cell of error, but a tablet 211 columns wide has ~6px cells, so being a dozen pixels off dropped the press inside the pane — the agent got a drag, the screen did nothing, and it felt like "you simply cannot drag panes on a phone".
|
|
413
|
+
|
|
414
|
+
Only **full-length lines** count (box-drawing characters covering 70% or more of that column / row, at least 6 cells), which keeps out the short rules agents draw (message separators, "2 new messages"). What it cannot keep out is the agent's own outer frame, but since the effect is "the press moves at most 24px", guessing wrong just means this one drag lands on a frame. The cost: in a 2×2 layout the horizontal divider is only half the screen wide, so it does not snap and you have to be accurate.
|
|
415
|
+
|
|
416
|
+
The finger is allowed to drift 16px during the hold (`HOLD_SLOP`). It was 8px, which was too strict — a finger holding still drifts a dozen pixels anyway, and any drift cancelled the long press, which reads as "long press does nothing". With mouse reporting off (a plain shell) there is no grabbing, and a long press keeps doing nothing.
|
|
291
417
|
|
|
292
|
-
|
|
418
|
+
Verified end to end: in a separate herdr session with a vertical split, long-pressing **3 cells to the right** of the divider and dragging moved it from column 45/46 to 40/41; swiping vertically right on the divider still emitted nothing but wheel reports.
|
|
293
419
|
|
|
294
|
-
|
|
420
|
+
### Tapping herdr's switch opens ours (on by default)
|
|
295
421
|
|
|
296
|
-
|
|
422
|
+
herdr has a mobile layout of its own: once the terminal gets narrow enough (its `ui.mobile_width_threshold`, 64 columns by default) it collapses to a single column with a two-row status bar, and **flush against the right edge** sits a `switch` button — which opens herdr's own switcher (spaces / tabs / menu, drilled into one level at a time). By default a touch on it is **no longer forwarded to herdr**; it opens the [pane list](#pane-list-how-you-switch-panes-on-a-phone) instead: sorted by state, filterable, one tap crosses workspaces and zooms. Turn it off under ⚙ → Terminal and the button goes back to being herdr's.
|
|
297
423
|
|
|
298
|
-
|
|
424
|
+
The button is located by **flooding outwards from the word along the background colour** (`web/src/term/mobilebar.ts`), not by hard-coded coordinates — at 50 columns the block is columns 41–50 × rows 1–2, at 64 columns it is 55–64; the width follows the layout. Three things measured on a live herdr:
|
|
299
425
|
|
|
300
|
-
|
|
426
|
+
- **The hit area has to be the whole block, not just the six letters.** herdr's own hit area is the whole block: a tap on the row *above* the word (where there is no text at all) opens its panel just the same. Match only the letters and half the area still opens herdr's panel — one button, two behaviours.
|
|
427
|
+
- **Once claimed, the mouse report must not go out**, or both panels end up open and you have to dismiss herdr's after jumping.
|
|
428
|
+
- **herdr's own switcher has `switch` as its title**, so a block that starts at column 1 is never treated as the button — that title row is one continuous background (with a separate `close` block at the right). Without that guard, "close herdr's panel" would be claimed by us.
|
|
301
429
|
|
|
302
|
-
|
|
430
|
+
The cost, stated plainly: "+ new workspace / + new tab / settings / keybinds / detach" in herdr's panel become unreachable (ours only answers "where to" — the trade-off is in [Pane list](#pane-list-how-you-switch-panes-on-a-phone)). Turn the setting off to get them back, or reach them with herdr's prefix keys from the soft key bar.
|
|
303
431
|
|
|
304
|
-
|
|
305
|
-
**📋 取** 和 **📥 粘**,拖到条上)。原因是下面两条,一条比一条反直觉。
|
|
432
|
+
Verified end to end on a real herdr narrowed to 64 columns: with the setting off, tapping `switch` brings up herdr's panel (spaces / tabs / menu); with it on, the pane list comes up and **not one byte goes to herdr**; while herdr's own panel is open, tapping its title is not claimed (the block found there starts at column 1, so it is rejected).
|
|
306
433
|
|
|
307
|
-
|
|
434
|
+
### Copy and paste on a phone
|
|
308
435
|
|
|
309
|
-
|
|
310
|
-
一次复制),herdr 弹「copied 84 chars to clipboard」——**那 84 个字进的是 Mac 的剪贴板**
|
|
311
|
-
(`pbpaste` 读出来一字不差),浏览器一无所知,手机上哪儿都粘不出来。看着像「复制失败」,
|
|
312
|
-
其实是复制成功了、只是落在另一台设备上。
|
|
436
|
+
The conclusion first: on a phone you want **two soft keys** (Settings → Soft keys → Load presets; **📋 Fetch** and **📥 Paste** in the "Web actions" group, dragged onto the bar). The two reasons below get progressively less intuitive.
|
|
313
437
|
|
|
314
|
-
|
|
315
|
-
`wl-paste` / `xclip`,见 `internal/clip`)交给网页,网页写进手机剪贴板。之后在手机上随便哪儿
|
|
316
|
-
长按粘贴都是它。
|
|
438
|
+
**First: herdr copies to the clipboard of the machine running herdr, not your phone's.**
|
|
317
439
|
|
|
318
|
-
|
|
319
|
-
当成一行一次回车)。触屏上没法 `⌘V`,也没法长按呼出终端的粘贴菜单(单指手势被接管了),
|
|
320
|
-
这个键是唯一的入口。
|
|
440
|
+
Long-press and drag to select on a phone (that gets translated into a mouse drag for herdr, and with herdr's own `copy_on_select` it is a copy), herdr says "copied 84 chars to clipboard" — and **those 84 characters went into the Mac's clipboard** (`pbpaste` reads them back verbatim). The browser knows nothing about it and there is nowhere on the phone to paste it. It looks like copying failed; it succeeded, just onto another device.
|
|
321
441
|
|
|
322
|
-
|
|
323
|
-
一律被拒 —— 而且是静默的。所以两个方向各要一次点击,这一下是浏览器的硬要求,不是没做。
|
|
442
|
+
Hence **📋 Fetch** (`act:clip`): tap it, the server reads the machine's clipboard (`pbpaste` / `wl-paste` / `xclip`, see `internal/clip`) and hands it to the page, which writes it into the phone's clipboard. From there, long-press-paste anywhere on the phone gets you that text.
|
|
324
443
|
|
|
325
|
-
|
|
326
|
-
COPY 模式**:`ctrl+b` 前缀进去,`hjkl` 选、`y` 复制。它走 **OSC 52** —— 终端里的程序把文本推给
|
|
327
|
-
网页,网页再写系统剪贴板。
|
|
444
|
+
The other direction is **📥 Paste** (`act:paste`): tap it to read the phone's clipboard and send it into the terminal as a **bracketed paste** (so several lines are not treated as one line plus an Enter). Touch has no `⌘V`, and it cannot long-press to raise the terminal's own paste menu either (single-finger gestures are taken over), so this key is the only way in.
|
|
328
445
|
|
|
329
|
-
|
|
330
|
-
安全上下文里存在(局域网 http 上压根没这个对象),而且手机浏览器要求这一次写发生在**用户
|
|
331
|
-
手势**里。COPY 模式和「选中即复制」的触发点都不是点击,于是在手机上被拒 —— 屏幕上选区好好的、
|
|
332
|
-
一句提示都没有,剪贴板里还是上一次的东西。
|
|
446
|
+
**Why this cannot be an automatic sync**: browsers only grant clipboard access inside **a user gesture**, and a timer trying to do it quietly is denied — silently. So each direction costs one tap. That tap is a browser requirement, not a missing feature.
|
|
333
447
|
|
|
334
|
-
|
|
335
|
-
连 `execCommand` 也被拒的话,它把文本摊在一个**已经全选好**的框里,长按 → 「拷贝」。
|
|
448
|
+
Touch **cannot select text at all** (single-finger gestures are entirely taken over), so the other copy route is **herdr's own COPY mode**: `ctrl+b` prefix to enter, `hjkl` to select, `y` to copy. That goes through **OSC 52** — the program inside the terminal pushes text to the page, and the page writes the system clipboard.
|
|
336
449
|
|
|
337
|
-
|
|
338
|
-
(实测 26 秒既不 resolve 也不 reject,剪贴板也确实没变)。光 `await` 的话「写不进去」永远发现
|
|
339
|
-
不了,所以那一步有 1.2 秒上限,超时就当失败、往后面两条路走。
|
|
450
|
+
**But the browser may refuse to write, and it used to fail silently.** Two constraints stack: `navigator.clipboard` only exists in a secure context (over plain http on a LAN the object is simply absent), and mobile browsers require the write to happen inside **a user gesture**. Neither COPY mode nor "copy on select" is triggered by a click, so on a phone it was denied — a perfectly good selection on screen, not a word of feedback, and the clipboard still holding whatever it held before.
|
|
340
451
|
|
|
341
|
-
|
|
452
|
+
Now, when the write fails, a **"tap to copy"** strip appears at the bottom: that tap is itself the gesture, and one press puts it in the clipboard. If even `execCommand` is denied, it lays the text out in a box that is **already fully selected**, ready for long-press → "Copy".
|
|
342
453
|
|
|
343
|
-
|
|
344
|
-
- 想在电脑上看那条提示长什么样:URL 上加 **`?nocopy=1`**,两条写剪贴板的路都当成失败
|
|
345
|
-
(和 `?poll=` / `?push=` 一样是调试参数)。
|
|
454
|
+
There is one more that **bites on the desktop too**: **while the tab is not visible, Chrome leaves that `writeText` promise pending forever** (measured: 26 seconds, neither resolved nor rejected, and the clipboard really had not changed). A plain `await` there means "it failed to write" is never discovered, so that step has a 1.2 second cap and treats a timeout as failure, falling through to the two routes above.
|
|
346
455
|
|
|
347
|
-
|
|
456
|
+
Two related notes:
|
|
348
457
|
|
|
349
|
-
|
|
458
|
+
- **"Copy on select" is a mouse-era setting**; touch has no selection, so turning it on does nothing on a phone.
|
|
459
|
+
- To see what that strip looks like on a desktop, add **`?nocopy=1`** to the URL: both clipboard-write routes are forced to fail (a debug parameter, like `?poll=` / `?push=`).
|
|
350
460
|
|
|
351
|
-
|
|
461
|
+
Why all this ceremony: xterm.js only translates `wheel` into mouse reports and ignores touch entirely, so a program like herdr — sitting on the alternate screen (no local scrollback to scroll) with mouse reporting on — responds to neither on a phone and simply cannot be scrolled. Meanwhile taps and long presses land on the hidden textarea, and the browser helpfully pops the keyboard — while in a TUI, nine times out of ten you were only trying to tap a pane.
|
|
352
462
|
|
|
353
|
-
|
|
463
|
+
The approach is to `preventDefault` **unconditionally** on `touchstart` (single-finger gestures), which kills focusing, the long-press bubble, double-tap zoom and the browser's synthesized mouse events in one go, and then classify the gesture ourselves in `touchend` by distance and duration.
|
|
354
464
|
|
|
355
|
-
|
|
356
|
-
(见上面那节)。按键那条通道只能表达「下一个 tab」这种相对导航,而中间每一步的屏幕正好都是
|
|
357
|
-
手机上读不了的那个平铺状态。
|
|
465
|
+
**Why swallow it even when there is no mouse reporting**: otherwise the browser synthesizes mouse events, xterm reads them as "press + drag-select", and a swipe turns into selected text with a motionless terminal (guaranteed to happen before herdr is attached, or when the pane runs something that ignores the mouse). Wanting to scroll is far more common than wanting to select on touch, so scrolling wins. The cost: touch can no longer drag-select (use a desktop mouse, or herdr's COPY mode), and a tap no longer focuses the textarea for free — we focus it ourselves in `touchend`.
|
|
358
466
|
|
|
359
|
-
|
|
467
|
+
**Gesture duration comes from the event's own timestamp** (`e.timeStamp`), not `Date.now()` in the handler. When the terminal is busy repainting, both timers and event dispatch get pushed back: a 60ms tap measured 994ms inside the handler and was rejected by "over 500ms is a long press, do nothing" — which reads as "when there is a lot of output, nothing responds anywhere". The event timestamp is taken when the event is created and is immune to handler delay.
|
|
360
468
|
|
|
361
|
-
|
|
469
|
+
**Switching panes does not have to be blind**: ▦ in the top bar or `act:panes` on the soft key bar opens the pane list, and a tap takes you there zoomed (see that section). The key channel can only express relative navigation like "next tab", and every screen along the way is exactly the tiled state you cannot read on a phone.
|
|
362
470
|
|
|
363
|
-
|
|
471
|
+
Focus is not stolen on connect on touch devices, otherwise the keyboard pops up the moment you arrive. To type: double-tap the terminal, or tap the ⌨ at the left end of the soft key bar. Keyboard state follows the textarea's focus/blur, so dismissing the keyboard yourself also unlights the button.
|
|
364
472
|
|
|
365
|
-
|
|
473
|
+
**The top bar collapses to one line on phones**: status keeps only the coloured dot (full text moves into `title`), "Connect" disappears once connected, and font size `A−/A+` plus light-dark `◐` move into Settings → Terminal. Seven icons do not fit in 393px, and wrapping to two lines burns ~36px (about three terminal rows) for three things you adjust once.
|
|
366
474
|
|
|
367
|
-
|
|
475
|
+
**When the keyboard comes up, the whole top bar collapses** to an 8px sliver (tap to bring it back). Visible height is down to ~430px at that moment, and nothing in the top bar is of any use then — someone who is typing wants the soft key bar and the outbox; "Connect" was a before-you-connected concern. The collapse is temporary: opening it by hand is for this once, and it returns to normal when the keyboard goes away, with no state left behind (otherwise whether the top bar is there next time you type is a coin flip).
|
|
368
476
|
|
|
369
|
-
|
|
477
|
+
Whether the keyboard is up is decided by **how much visualViewport shrank** (`hooks/useKeyboardUp.ts`, threshold 0.8), not by "is xterm's hidden textarea focused": the most common posture in this project is **dictating into the outbox**, where focus is on the outbox and the terminal knows nothing, while the keyboard still eats half the screen. Both signals are used together (typing into the terminal moves the second, not the first). Failing to detect it costs no correctness — it degrades to "the top bar does not collapse".
|
|
370
478
|
|
|
371
|
-
|
|
479
|
+
**The virtual keyboard no longer covers content**: page height follows `visualViewport`, and the viewport meta carries `interactive-widget=resizes-content`. Reflowing the terminal alone is not enough — iOS **never** shrinks the layout viewport for the keyboard, `height:100%` refers to the unshrunk one, and the keyboard would simply cover the soft key bar and the outbox.
|
|
372
480
|
|
|
373
|
-
|
|
481
|
+
**But not every browser honours this** (measured: in some browsers the page height does not budge and half the outbox stays buried), so the dock down there can be dragged by hand and does not depend on browser behaviour.
|
|
374
482
|
|
|
375
|
-
|
|
483
|
+
### The bottom dock
|
|
376
484
|
|
|
377
|
-
|
|
378
|
-
- **键那一区上边缘的三个把手是两轴的**:上下拖 = 软键条多高(**最多半屏**,放不下的部分上下滚),左右拖 = 左边那个动左边界、右边那个动右边界、中间那个整条平移(宽度不变)。每个方向都有 3px 死区,只想横着拖不会顺手把高度锁成定高。
|
|
379
|
-
- 软键条按键**换行**排(不再是一条横向滚动的长龙)。没拖过高度是自动的、封顶两排(不留空白);拖过之后是定高 —— 用户明确要求「更高」就别自作聪明缩回内容高度。任意把手**双击复位**(宽度和高度一起)。
|
|
380
|
-
- 面板里的东西按**面板自己的宽度**折行(`@container` + `@max-3xl:`),不是按视口宽度:缩到半屏之后视口还是那么宽,按视口算的话发件箱那排控件会挤成一团。
|
|
381
|
-
- 左右留白换了个存储键(`dockInset`,旧的 `softkeysInset` 不再读):以前那份只缩键那一条,现在缩的是整块,语义不一样 —— 直接套过来的话升级后一开页面会发现发件箱莫名其妙只剩半屏宽。
|
|
485
|
+
The outbox and the soft key bar are **one dock** (`web/src/components/Dock.tsx`): one border, one width, adjusted once.
|
|
382
486
|
|
|
383
|
-
|
|
487
|
+
They used to be independent — the outbox could be torn off the bottom by its ⠿ handle into a floating panel (its own position / size / border) and the soft key bar had its own insets and height. Stacked, that is two borders, two widths and two sets of handles, looking like two misaligned layers, and "move this stuff off the IME" had to be done twice. Now the whole block shrinks together:
|
|
384
488
|
|
|
385
|
-
-
|
|
386
|
-
-
|
|
387
|
-
-
|
|
388
|
-
-
|
|
489
|
+
- **The left and right edges** drag horizontally to move that side's boundary (width). An IME plus its toolbar routinely covers half the screen; shrinking the whole dock into the space that is left beats keeping it full width under the keyboard.
|
|
490
|
+
- **The three handles on the top edge of the key area are two-axis**: dragging vertically sets the soft key bar's height (**half the screen max**, overflow scrolls), dragging horizontally moves the left boundary (left handle), the right boundary (right handle) or the whole dock (middle, width unchanged). Each direction has a 3px dead zone, so a purely horizontal drag does not accidentally pin the height. **Double-tap any handle to reset** (width and height together).
|
|
491
|
+
- Soft keys **wrap** rather than forming one long horizontally-scrolling queue. Untouched, the height is automatic and capped at two rows (no empty space); once dragged, it is fixed — when the user explicitly asked for "taller", do not helpfully shrink back to content height.
|
|
492
|
+
- Content inside the dock wraps by **the dock's own width** (`@container` + `@max-3xl:`), not the viewport's: after shrinking to half the screen the viewport is still as wide as ever, and viewport-based breakpoints would cram the outbox controls together.
|
|
493
|
+
- The insets moved to a new storage key (`dockInset`; the old `softkeysInset` is no longer read): the old one only shrank the key row, the new one shrinks the whole dock, and the semantics differ — reusing it would mean opening the page after an upgrade to find the outbox mysteriously half a screen wide.
|
|
389
494
|
|
|
390
|
-
|
|
495
|
+
**Phones in portrait (< 440px) are a different tier**: no handles at all, the dock spans the full width, and the soft key bar becomes **one horizontally-scrolling row** with smaller keys (13→11.5px, 35→28px high).
|
|
391
496
|
|
|
392
|
-
|
|
497
|
+
- On a screen that narrow the handles are a net loss: three of them add 24px (about two terminal rows), and there is no free space at the sides to give away anyway — at that size the IME covers the full width, not half.
|
|
498
|
+
- Wrapping keys costs more: every extra row is one less terminal row. Scrolling costs one swipe, and the keys you use are at the front anyway (you ordered them). If you want two rows, turn them on explicitly (see [Soft key bar](#soft-key-bar)); each row scrolls on its own.
|
|
499
|
+
- The breakpoint is written in two places and both must change together: `--breakpoint-phone` in `index.css` (for Tailwind's `max-phone:` variant) and `PHONE_MAX` in `hooks/usePhone.ts` (for inline styles and for "render the handles at all" — CSS cannot override those).
|
|
500
|
+
- Cross the width threshold (rotate, tablet, desktop) and the handles plus your stored sizes come back on their own. The two tiers do not affect each other.
|
|
393
501
|
|
|
394
|
-
|
|
502
|
+
**The floating outbox is gone.** Moving it away from the IME now has exactly one route: shrink the whole dock horizontally (which is the route actually in use — that tablet's IME covers half the screen). If moving it vertically ever becomes necessary, bring `useFloatBox` back — but do not let it grow its own border again.
|
|
395
503
|
|
|
396
|
-
|
|
504
|
+
**Handles must not touch the screen edge** (`EDGE_SAFE = 14`). Android gesture navigation claims a strip along each side for back/forward; the system takes it first and the page does not even receive `touchstart` — a handle on the edge simply cannot be dragged (measured). So handles inset themselves when they would come closer than that, and the dock's contents take the same padding so controls do not slide under a handle; a dock already shrunk inward needs no inset. The number came from a real device: insetting by the nominal 24dp left a strip that looked misaligned, and the effective swipe region is narrower than the nominal one. The day a side handle stops dragging again, suspect this number first.
|
|
397
505
|
|
|
398
|
-
|
|
506
|
+
**One set for landscape, one for portrait** (`web/src/lib/oriented.ts`). The same dock wants entirely different sizes and positions in the two orientations; sharing one set means re-arranging it on every rotation and clobbering the other one. So the dock's height and insets are stored per orientation, and rotating swaps in that set and then clamps it (**reading** that set, not nudging the current one). Orientation is decided by aspect ratio rather than `screen.orientation` — with desktop windows and tablet split-screen, the aspect ratio is what actually decides the layout. Anything stored by an older version (without the orientation suffix) is migrated to the current orientation on first read, so a dock you already tuned survives the upgrade.
|
|
399
507
|
|
|
400
|
-
|
|
508
|
+
Landscape is far more usable than portrait (enough columns); font size is on the top bar's `A− / A+`; the top bar also has a **fullscreen** button, and losing the address bar and toolbars is worth several terminal rows. iOS Safari does not grant fullscreen to web pages (only to video), so there it suggests "Add to Home Screen" instead — opening from the home screen has no address bar either.
|
|
401
509
|
|
|
402
|
-
|
|
510
|
+
## Verdict: herdr is genuinely usable in a browser
|
|
511
|
+
|
|
512
|
+
herdr requests these terminal capabilities at startup (captured off a PTY), against what is implemented here:
|
|
513
|
+
|
|
514
|
+
| Sequence | Purpose | Status |
|
|
403
515
|
|---|---|---|
|
|
404
|
-
| `CSI ? 1049 h` |
|
|
405
|
-
| `CSI ? 1000/1002/1003 h` + `1006` |
|
|
406
|
-
| `CSI ? 2004 h` |
|
|
407
|
-
| `CSI ? 1004 h` |
|
|
408
|
-
| `CSI ? 2026 h` |
|
|
409
|
-
| `OSC 8` |
|
|
410
|
-
| `OSC 52` |
|
|
411
|
-
| `OSC 10;? / 11;?` |
|
|
412
|
-
| `CSI ? 2031 h` |
|
|
413
|
-
| `CSI > 7 u` | kitty
|
|
516
|
+
| `CSI ? 1049 h` | Alternate screen | native to xterm.js |
|
|
517
|
+
| `CSI ? 1000/1002/1003 h` + `1006` | Mouse click/drag/motion + SGR coordinates | native |
|
|
518
|
+
| `CSI ? 2004 h` | Bracketed paste | native |
|
|
519
|
+
| `CSI ? 1004 h` | Focus in/out reporting | native |
|
|
520
|
+
| `CSI ? 2026 h` | Synchronized output (no tearing) | native, plus a repaint watchdog (below) |
|
|
521
|
+
| `OSC 8` | Terminal hyperlinks | native; clicks open in a new tab |
|
|
522
|
+
| `OSC 52` | Program writes the system clipboard | ClipboardAddon |
|
|
523
|
+
| `OSC 10;? / 11;?` | Query foreground/background colour (to detect light/dark) | xterm.js does not answer — **this project does** |
|
|
524
|
+
| `CSI ? 2031 h` | Theme change notification | unsupported by xterm.js — **this project emits** `CSI ? 997 ; 1/2 n` |
|
|
525
|
+
| `CSI > 7 u` | kitty keyboard protocol | unsupported by xterm.js — **this project implements the disambiguate subset** |
|
|
414
526
|
|
|
415
|
-
|
|
527
|
+
Those switches live under ⚙ → Terminal. **The "capabilities the program requested" list was removed**: it was a debugging view from the days of implementing the protocols and nobody reads it day to day (the capabilities are still tracked — `DEC 2031` theme notifications need them).
|
|
416
528
|
|
|
417
|
-
##
|
|
529
|
+
## Keyboard
|
|
418
530
|
|
|
419
|
-
herdr
|
|
531
|
+
herdr's shortcuts are almost all `ctrl+b` plus an ordinary key, which legacy encoding can express, so they do not depend on the kitty protocol. What kitty adds is the combinations legacy cannot express; it is on by default (turn it off in Settings → Terminal): `Ctrl+Shift+letter` → `CSI code;6u`, `Ctrl+digit` → `CSI code;5u`, `Ctrl+Enter` / `Shift+Enter` / `Ctrl+Tab`.
|
|
420
532
|
|
|
421
|
-
|
|
533
|
+
**Each herdr session has its own socket**: the default one is `~/.config/herdr/herdr.sock`, and `herdr --session x` is `~/.config/herdr/sessions/x/herdr.sock`. The outbox connects to whichever `HERDR_WEB_SOCKET` names, so using the outbox against a non-default session means pointing that variable at it.
|
|
422
534
|
|
|
423
|
-
**`Esc`
|
|
535
|
+
**`Esc` is in there too, and it is the important one**: once a program declares kitty's disambiguate flag (`CSI > 1 u` — herdr and Claude Code both do), Esc must be encoded as `CSI 27 u`. A bare `0x1b` is the prefix of **every** escape sequence, so a program receiving it cannot tell immediately whether this is a real Esc or the start of a sequence; it has to wait for a timeout or drop it — which shows up as "Esc does nothing on the web page" and overlays like `/usage` that will not close. The soft key bar's `Esc` and the one forwarded from the outbox use the same encoding (bytes parsed on the server do not know whether kitty is on, so a lone ESC is re-encoded on the frontend according to the current mode).
|
|
424
536
|
|
|
425
|
-
|
|
537
|
+
Keys the browser keeps for itself: on macOS `⌘W` `⌘T` `⌘N` `Ctrl+Tab`; on Windows/Linux also `Ctrl+W` `Ctrl+T` `Ctrl+N` `Ctrl+Shift+I/J/C`. Installing the page as a PWA gets some of them back.
|
|
426
538
|
|
|
427
|
-
|
|
539
|
+
Copy `⌘C` (or `Ctrl+Shift+C`) · paste `⌘V` · clear `⌘K` · `Option` is Meta by default.
|
|
428
540
|
|
|
429
|
-
##
|
|
541
|
+
## Code layout
|
|
430
542
|
|
|
431
543
|
```
|
|
432
|
-
cmd/herdr-web/ main
|
|
544
|
+
cmd/herdr-web/ main: flags, subcommands, listeners, startup banner, interface scoring
|
|
433
545
|
internal/
|
|
434
|
-
config/
|
|
435
|
-
auth/
|
|
436
|
-
acme/ DNS-01
|
|
437
|
-
tlsgen/
|
|
438
|
-
ctl/ ~/.herdr-web/ctl.sock
|
|
439
|
-
herdr/ herdr socket
|
|
440
|
-
composer/
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
testdata
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
.
|
|
473
|
-
|
|
546
|
+
config/ env vars (viper, env only), paths, deployment shape (TLS tier / exposure / allowlist)
|
|
547
|
+
auth/ pairing codes + device credentials (hashes only) + rate limiting (gate.go)
|
|
548
|
+
acme/ DNS-01 issuance and renewal (only imports the providers in use, see package doc)
|
|
549
|
+
tlsgen/ local CA + short-lived leaf, or a real certificate you supply; both hot-reload
|
|
550
|
+
ctl/ ~/.herdr-web/ctl.sock: the channel between subcommands and the running service
|
|
551
|
+
herdr/ herdr socket client (one connection per call)
|
|
552
|
+
composer/ per-agent input-line scraping + real captured screens in testdata
|
|
553
|
+
agentwatch/ watches agent state changes: stamps times (the pane list's "3 minutes ago")
|
|
554
|
+
and queues notices (notice.go debounces, extract.go scrapes the screen;
|
|
555
|
+
testdata holds real captures)
|
|
556
|
+
outbox/ list targets / pull back / clear / post / push draft
|
|
557
|
+
softkeys/ soft key config + key spec parsing (data.go is generated from the old JS version,
|
|
558
|
+
not retyped; testdata/js-snapshot.json holds that snapshot and the test diffs
|
|
559
|
+
the first 6 groups against it)
|
|
560
|
+
uploads/ image storage (type by magic number)
|
|
561
|
+
files/ file browsing: starting points / directory listing / type by magic number /
|
|
562
|
+
short-lived signed links (sign.go). No boundary by default — FILE_ROOTS is
|
|
563
|
+
the jail; why, and the four "never text/html" rules, are in the package doc
|
|
564
|
+
clip/ read this machine's clipboard (pbpaste / wl-paste / xclip) — herdr's copy lands
|
|
565
|
+
on **the machine running herdr**, so the phone can only get it from this side
|
|
566
|
+
server/ HTTP routes + PTY/WebSocket + static assets
|
|
567
|
+
guard.go is the doorman (Host allowlist / Origin / security headers)
|
|
568
|
+
authapi.go is the pairing and device management endpoint
|
|
569
|
+
session.go dispatches "one URL, one herdr session" (per session: a socket,
|
|
570
|
+
an outbox, a status subscription)
|
|
571
|
+
filesapi.go is the file browsing endpoint plus /_f/, the **cookie-less**
|
|
572
|
+
byte-serving route
|
|
573
|
+
webui/ embedded frontend build (dist is copied in by make build)
|
|
574
|
+
qr/ draws the QR code in the terminal at startup
|
|
575
|
+
version/ the single source of the version number (injected by goreleaser ldflags)
|
|
576
|
+
selfupdate/ query GitHub Releases + cache + download verification + in-place binary swap
|
|
577
|
+
service/ install as a launchd / systemd service (plist / unit generation + env snapshot)
|
|
578
|
+
assets/ icons (herdr's sheep, caged in a browser window). **Do not hand-edit the svg** —
|
|
579
|
+
edit assets/make-logo.py and rerun it: the sheep silhouette is an 1800+ character
|
|
580
|
+
traced path reused from herdr, and one shape has to produce rounded / square
|
|
581
|
+
variants plus three pngs
|
|
582
|
+
web/ Vite + React + TS + Tailwind v4 + shadcn-style components
|
|
583
|
+
public/ icons and manifest (copied verbatim into dist by Vite, served from /)
|
|
584
|
+
src/term/ xterm.js glue: protocol gap-filling, touch gestures, repaint watchdog
|
|
585
|
+
(imperative, deliberately not wrapped in React)
|
|
586
|
+
paths.ts turns file paths in the terminal into tappable links (reassembling
|
|
587
|
+
wrapped lines, terminating on CJK punctuation, refusing truncated ones —
|
|
588
|
+
every rule learned the hard way)
|
|
589
|
+
mobilebar.ts spots the switch button in herdr's mobile top bar
|
|
590
|
+
(flood-fill by background colour) — the test behind "tap it, get our pane list"
|
|
591
|
+
src/hooks/ useCompose (outbox state machine), useNotices (notice polling + unread badge),
|
|
592
|
+
useViewportHeight
|
|
593
|
+
src/components/ Dock.tsx is the bottom dock shell (border / width / height shared by the
|
|
594
|
+
outbox and the soft key bar)
|
|
595
|
+
Notices.tsx is the stack of cards in the top right
|
|
596
|
+
FilesPanel.tsx is file browsing (starting points + directories + the paste box)
|
|
597
|
+
FileViewer.tsx shows one file (image / text), full screen
|
|
598
|
+
Pairing.tsx is the pairing page (the only thing rendered when unpaired)
|
|
599
|
+
SettingsPanel.tsx is the settings panel; the soft key editor and device
|
|
600
|
+
management are two of its pages
|
|
601
|
+
QrScan.tsx is the in-page scanner (BarcodeDetector + rear camera)
|
|
602
|
+
reference/ the original Python prototype; "verified" in the three companion docs means
|
|
603
|
+
verified against it
|
|
604
|
+
npm/herdr-web/ the npm root package @bysir/herdr-web: a JS shim that finds the right binary
|
|
605
|
+
scripts/npm-*.mjs turn goreleaser output into npm packages / publish them in order
|
|
606
|
+
install.sh the no-node install path (download + mandatory sha256 verification)
|
|
607
|
+
.goreleaser.yaml cross-compile + archive + checksums (darwin / linux only)
|
|
608
|
+
.github/workflows/ ci.yml runs on every push; release.yml publishes to GitHub + npm on a tag
|
|
474
609
|
```
|
|
475
610
|
|
|
476
|
-
|
|
611
|
+
The CLI is [cobra](https://github.com/spf13/cobra) (`cmd/herdr-web/main.go`): the root command starts the server, and `pair` / `devices` / `revoke` / `unlock` / `version` / `update` / `service` are subcommands, with `--help` and completion scripts for free. **There is exactly one flag**, `-w, --web` (point at a frontend directory during development); everything else is an environment variable — two entry points for one setting means having to specify which one wins, and it is not worth it.
|
|
477
612
|
|
|
478
|
-
`make test`
|
|
613
|
+
`make test` runs the Go tests plus a frontend typecheck. `make dev` gives frontend hot reload (run the backend separately with `go run ./cmd/herdr-web`; vite proxies `/api` and `/pty` to it).
|
|
479
614
|
|
|
480
|
-
###
|
|
615
|
+
### Releasing
|
|
481
616
|
|
|
482
617
|
```bash
|
|
483
|
-
make release-dry #
|
|
484
|
-
make release V=v0.1.0 #
|
|
618
|
+
make release-dry # run the whole chain locally: cross-compile → archive → npm packages → npm publish --dry-run
|
|
619
|
+
make release V=v0.1.0 # tag and push; GitHub Actions does the rest
|
|
485
620
|
```
|
|
486
621
|
|
|
487
|
-
|
|
622
|
+
Once the tag lands, `release.yml` runs `make test` → goreleaser (cross-compile 4 platforms, produce archives and `checksums.txt`, create the GitHub Release) → turn the archives into npm packages → **publish the 4 platform packages first and the root package last**. In the other order there is a window where `npm install` produces a shim with no binary.
|
|
488
623
|
|
|
489
|
-
**Release
|
|
624
|
+
**Release created but the npm step failed** (happened once) — rerun the same workflow to publish without recompiling:
|
|
490
625
|
|
|
491
626
|
```bash
|
|
492
627
|
gh workflow run release.yml -f tag=v0.1.0
|
|
493
628
|
```
|
|
494
629
|
|
|
495
|
-
|
|
630
|
+
It downloads the archives that were already published, so the re-published binaries are **byte-identical** to the ones in the Release.
|
|
496
631
|
|
|
497
|
-
|
|
498
|
-
workflow 文件名,绑的就是 `release.yml`;再开一个会发包的 workflow,从它发就对不上 OIDC。
|
|
632
|
+
**There can only be one publishing workflow; do not split it up.** npm's Trusted Publisher (OIDC) binds one package to one workflow filename, and that filename is `release.yml`; a second workflow that publishes would not match the OIDC claim.
|
|
499
633
|
|
|
500
|
-
|
|
501
|
-
验证码,CI 里没人输)。配了 Trusted Publisher 之后可以去掉它,但**先发一版确认 OIDC 真的生效**再删。
|
|
634
|
+
One repository secret is needed: `NPM_TOKEN` (**Automation** type — the other two kinds ask for an interactive 2FA code when publishing from an account with 2FA on, and CI has nobody to type it). Once Trusted Publisher is configured you can drop it, but **publish one release first to confirm OIDC actually works** before deleting it.
|
|
502
635
|
|
|
503
|
-
Trusted Publisher
|
|
504
|
-
Environment name **留空**(我们的 workflow 没声明 environment,填了任何值 OIDC 都会对不上)。
|
|
505
|
-
少配一个的表现是下次发版在「发 npm」那步中途失败。
|
|
636
|
+
Trusted Publisher is configured **per package**, so all 5 (root + 4 platform packages) need it, each pointing at `release.yml` with the Environment name **left empty** (our workflow declares no environment; any value there makes the OIDC claim mismatch). Missing one shows up as the next release failing halfway through the npm step.
|
|
506
637
|
|
|
507
|
-
**tag
|
|
508
|
-
是自建 git,`github` 才是 GitHub),所以 `make release` **不写死 origin** —— 它按 push URL 里的
|
|
509
|
-
`github.com` 认,认不出来就拒绝发版。推错远端是最难查的一种:tag 打上去了、命令也成功了,
|
|
510
|
-
Actions 那边一直没动静,而「没动静」和「还在排队」长得一模一样。要覆盖:
|
|
511
|
-
`make release V=vX.Y.Z RELEASE_REMOTE=xxx`。
|
|
638
|
+
**Push the tag to the remote that holds `release.yml`** — that is GitHub. This repository has two remotes (`origin` is a self-hosted git; `github` is GitHub), so `make release` **does not hardcode origin**: it recognises the remote by `github.com` in the push URL and refuses to release if it cannot find one. Pushing to the wrong remote is the worst kind to debug: the tag lands, the command succeeds, and Actions simply never starts — and "never started" looks exactly like "still queued". To override: `make release V=vX.Y.Z RELEASE_REMOTE=xxx`.
|
|
512
639
|
|
|
513
|
-
|
|
640
|
+
Three names must agree, and changing one means changing the other two: `name_template` in `.goreleaser.yaml`, `internal/selfupdate.AssetName` (used by self-update downloads), and `scripts/npm-build.mjs`. A mismatch shows up as `herdr-web update` downloading a 404.
|
|
514
641
|
|
|
515
|
-
`make release-dry`
|
|
516
|
-
`npm/herdr-web/package.json`(干跑时是 `0.1.1-next` 这种快照号)。不还的话紧接着
|
|
517
|
-
`make release` 会说「工作区不干净」而你什么都没改,或者那个 `-next` 版本号被顺手提交进去。
|
|
642
|
+
`make release-dry` **restores the working tree** when it finishes: `npm-build.mjs` writes the version into the committed `npm/herdr-web/package.json` (a snapshot number like `0.1.1-next` during a dry run). Without the restore, the `make release` right after it says "the working tree is dirty" when you changed nothing — or that `-next` version gets committed by accident.
|
|
518
643
|
|
|
519
|
-
|
|
644
|
+
Three release-path traps already hit and fixed (all **silent** failures):
|
|
520
645
|
|
|
521
|
-
- `web/tsconfig.tsbuildinfo`
|
|
522
|
-
|
|
523
|
-
-
|
|
524
|
-
承重的:空目录上 `go:embed all:dist` 报 `cannot embed directory dist: contains no embeddable
|
|
525
|
-
files`,新 clone 连 `go build` 都过不了。所以 `web` 和 `clean` 两个目标都会把它写回来。
|
|
526
|
-
- 首发之后有几分钟,npm 的 packument 读路径还没物化(`version` 端点和 search 都查得到,packument
|
|
527
|
-
却 404)。这时候 `npm i` 拿到 404 会**静默跳过** optional 依赖,装出一个没有二进制的壳。
|
|
528
|
-
等几分钟重装就好,壳里那段报错会提示重装。
|
|
646
|
+
- `web/tsconfig.tsbuildinfo` used to be committed. It is `tsc -b`'s incremental cache, rewritten by every `make test` run, after which goreleaser declares `git is in a dirty state` and refuses to release. Build caches never get committed.
|
|
647
|
+
- `rm -rf $(WEBDIST)` in `make web` deletes the committed `internal/webui/dist/.gitkeep`. That file is load-bearing: on an empty directory `go:embed all:dist` fails with `cannot embed directory dist: contains no embeddable files`, and a fresh clone cannot even `go build`. So both the `web` and `clean` targets write it back.
|
|
648
|
+
- For a few minutes after a first publish, npm's packument read path has not materialized yet (the `version` endpoint and search both find it while the packument 404s). An `npm i` that gets a 404 **silently skips** the optional dependency and installs a shim with no binary. Reinstall a few minutes later; the error message inside the shim tells you to.
|
|
529
649
|
|
|
530
|
-
|
|
650
|
+
**Why the terminal layer is not a React component**: it touches xterm's parser directly, consumes the WebSocket byte by byte, and repaints on rAF — React's render cycle would only be in the way. React holds a ref to mount it and subscribes to a few state callbacks.
|
|
531
651
|
|
|
532
|
-
###
|
|
652
|
+
### Colours (read this before touching the UI)
|
|
533
653
|
|
|
534
|
-
|
|
654
|
+
All tokens live in `@theme` in `web/src/index.css` (one set for dark, one for light). Components **never write a literal colour**, only these names:
|
|
535
655
|
|
|
536
|
-
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
-
|
|
540
|
-
|
|
541
|
-
涂满的话整条栏全是色块,什么都不突出。饱和填充只留给一屏一个的主操作(投稿 / 保存 / 配对)和粘滞
|
|
542
|
-
修饰键那种「按下去了必须一眼看见」的状态。
|
|
543
|
-
- 圆角两档:控件 `rounded-md`(6px)、浮层 `rounded-card`(12px)。字号:正文 13px,次要一律 `text-xs`,
|
|
544
|
-
别再写 `text-[11.5px]` 这种一次性数值。
|
|
545
|
-
- 终端只有**灰阶和光标**跟着 token 走(`src/term/themes.ts`):底色 = `bg`、光标 = 品牌绿、选区是半透明的绿。
|
|
546
|
-
红黄蓝品青那六个色相一个都没动 —— 那是别人程序的输出颜色,diff 的红绿、agent 的高亮全靠它们。
|
|
547
|
-
- `accent` 是旧名字(原来那个亮蓝),现在留成 `brand` 的别名防止漏改,新代码别用它。
|
|
656
|
+
- Four greys: `bg` (canvas / terminal) → `bar` (top bar, dock, overlays) → `ctl` (controls) → `ctl-hi` (control hover); dividers `line` / `line-hi`; text `fg` / `muted` / `faint`. All **pure grey** (S=0) — the old blue-ish slate looked dirty stacked against the terminal's coloured output.
|
|
657
|
+
- Green is only an accent: `brand` for text / icons / outlines, and `brand-bg` + `brand-line` + `brand-fg` for the filled primary button. **On / selected states are "pale green fill + green border + green text", not a solid block** — five or six icons in the top bar can be on at once, and solid fills turn the whole bar into colour blocks with nothing standing out. Saturated fills are reserved for the one primary action on screen (post / save / pair) and for sticky modifiers, where "you pressed it" must be unmissable.
|
|
658
|
+
- Two radii: controls `rounded-md` (6px), overlays `rounded-card` (12px). Type: 13px body, `text-xs` for everything secondary; stop writing one-off values like `text-[11.5px]`.
|
|
659
|
+
- In the terminal only **the greys and the cursor** follow the tokens (`src/term/themes.ts`): background = `bg`, cursor = brand green, selection = translucent green. The six hues (red, yellow, blue, magenta, cyan) are untouched — those are other programs' output colours, and diff red/green and agent highlighting depend on them.
|
|
660
|
+
- `accent` is the old name (the original bright blue), kept as an alias of `brand` so nothing silently breaks. Do not use it in new code.
|
|
548
661
|
|
|
549
|
-
##
|
|
662
|
+
## Traps (already handled; noted so nobody walks back into them)
|
|
550
663
|
|
|
551
|
-
- **WebSocket
|
|
552
|
-
`panic: concurrent write to websocket connection`,而这个 panic 发生在 handler 自己起的
|
|
553
|
-
goroutine 里 —— net/http 只兜得住 handler 本身那一层,所以**进程直接退出,所有人的终端
|
|
554
|
-
一起断**。一条 PTY 连接上有三个写者:PTY 数据、25 秒一次的 ping、退出时的 exit + close。
|
|
555
|
-
线上炸过一次,是 ping 正好撞上一批二进制帧(和「开了几个浏览器」无关,每条连接各有自己的
|
|
556
|
-
conn;但连接越多、重连越频繁越容易撞)。现在全部收口到 `wsWriter`,`ws_test.go` 里那个
|
|
557
|
-
并发测试去掉锁就会复现同一条 panic。顺带两件:写入加了 10 秒超时(手机断网时 TCP 缓冲
|
|
558
|
-
填满会让 `WriteMessage` 一直阻塞、把锁也占着,那样 PTY 读循环都推不动了),ping 的
|
|
559
|
-
goroutine 改成 select 到 done 上(`Ticker.Stop()` 不关 channel,光 Stop 那个 goroutine
|
|
560
|
-
会永远卡在接收上,连着 conn 一起泄漏 —— 手机频繁重连时一条一个地攒)。
|
|
664
|
+
- **A WebSocket cannot be written concurrently, and a bad write takes the whole process down.** gorilla/websocket panics with `panic: concurrent write to websocket connection`, and that panic happens on a goroutine the handler started — net/http only recovers the handler's own frame, so **the process exits and everybody's terminal drops at once**. A PTY connection has three writers: PTY data, a ping every 25 seconds, and the exit + close on teardown. It blew up in production once, a ping landing on a batch of binary frames (unrelated to "how many browsers are open" — each connection has its own conn; but more connections and more reconnects make a collision likelier). Everything now funnels through `wsWriter`, and the concurrency test in `ws_test.go` reproduces the same panic if you remove the lock. Two things came along: writes got a 10 second timeout (when a phone loses signal, a full TCP buffer leaves `WriteMessage` blocked forever while holding the lock, which stalls the PTY read loop), and the ping goroutine now selects on a done channel (`Ticker.Stop()` does not close the channel, so a stopped goroutine parks on the receive forever and leaks along with its conn — one per reconnect, which a phone produces plenty of).
|
|
561
665
|
|
|
562
|
-
- **`HERDR_*`
|
|
563
|
-
- **xterm.js 6.0
|
|
564
|
-
-
|
|
565
|
-
- **herdr
|
|
566
|
-
-
|
|
567
|
-
-
|
|
568
|
-
-
|
|
666
|
+
- **`HERDR_*` makes herdr refuse to start.** If this service was started from inside a herdr pane, the child inherits them and reports `nested herdr is disabled by default`. `dropEnv` in `internal/server/pty.go` strips `HERDR_* / TMUX / ZELLIJ / ITERM_* / CLAUDECODE`.
|
|
667
|
+
- **xterm.js 6.0 will "accept a repaint request and not paint"**: with DEC 2026 synchronized output on it accumulates ranges waiting for ESU, and painting happens in rAF, which does not run at all in a background tab. herdr keeps 2026 on permanently and a single frame of a few KB gets split across several writes, so one dropped accumulation leaves a blank patch on screen. The buffer is fine, so the fix is only a repaint: 180ms after the data stream stops, force one; if 2026 is stuck, emit an ESU ourselves. If it happens often, turn synchronized output off in Settings → Terminal.
|
|
668
|
+
- **Resizing flashes black, and a "freeze frame" has to cover it.** Most visible when the IME comes up (`visualViewport` changes and everything reflows). The causes stack: xterm's WebGL renderer clears the drawing buffer as soon as `canvas.width` changes, `FitAddon.fit()` actively calls `renderService.clear()` before resizing, and the repaint cannot happen before the next rAF at the earliest (later still with 2026 waiting for ESU); then herdr receives SIGWINCH and clears and redraws on its own. Tens of milliseconds all told. xterm offers no synchronous repaint, so none of that latency can be removed — instead, before resizing, the canvas layers inside `.xterm-screen` are composited into one image laid over the terminal, and it fades out 120ms after the new frame arrives (`onRender`). Two prerequisites: WebGL needs `preserveDrawingBuffer` (or `drawImage` gets an empty picture after compositing), and **if the snapshot comes back empty the freeze frame must be abandoned** (in a background tab rAF never ran and the canvas was never painted; pasting an empty image over the terminal is worse than the flash). Also, if rows and columns did not change, xterm is not touched at all: `visualViewport` fires several times during the keyboard animation, and a pointless resize is a pointless flash.
|
|
669
|
+
- **herdr's theme does not follow the browser** unless `[theme] auto_switch = true` in `~/.config/herdr/config.toml`. With it on, toggling light/dark on the page switches herdr's colours too.
|
|
670
|
+
- **Never set `HERDR_WEB_SETTLE_MS` to 0** — see [Configuration](#configuration).
|
|
671
|
+
- **A reconnect must reset the terminal first.** One WebSocket is one PTY, and the server kills the PTY on disconnect, so every "connect" is **a brand-new login shell** — but the xterm instance is reused and still carries the private modes the previous herdr turned on. The symptom is not just a broken screen after reconnecting but garbage typed into the command line: mouse motion reporting (1003+1006) is still on, so any pointer or stylus movement emits `ESC [ < 35;120;36 M`, zsh's ZLE swallows the unrecognised `ESC [ <` prefix and self-inserts the rest, and the screen fills with `35;120;36M35;115;37M…` (reproduced: `➜ ~ 35;16;5M35;26;8M`). kitty keyboard flags linger the same way, so Esc gets encoded as `CSI 27 u` and shows up as `[27u` in the new shell. `connect()` now calls `term.reset()` before connecting, and clears the kitty flags / capability list / sticky modifiers we track ourselves.
|
|
672
|
+
- **The "Connect" button is always clickable, so connecting must tear down the old connection first.** If it does not: the server starts a second login shell, two shells pour output into one xterm, the screen is instantly garbage, and the old PTY stays alive as long as its connection does. The old connection's callbacks have to be detached too — close is asynchronous, and the old connection's `onclose` would set the new connection's state to "disconnected".
|
|
569
673
|
|
|
570
|
-
##
|
|
674
|
+
## Configuration
|
|
571
675
|
|
|
572
|
-
|
|
676
|
+
**Environment variables are the only source of configuration.** There is no config file, and the only flag is `--web` (point at a frontend directory during development). It is funnelled through [viper](https://github.com/spf13/viper) in `internal/config/` (`SetEnvPrefix("HERDR_WEB")` + `AutomaticEnv()`), so settings and variable names map one to one.
|
|
573
677
|
|
|
574
|
-
|
|
678
|
+
Not reading a config file is deliberate: there is a login shell behind this port, so "which configuration is actually in effect" has to be visible at a glance — environment variables are right there in `ps`, in the systemd unit, in the launchd plist. Add "there might also be a yaml in some directory" and the first half day of any incident goes into finding out which one won. Same reason there is no "flags override environment": one setting with two entry points means having to specify precedence.
|
|
575
679
|
|
|
576
|
-
###
|
|
680
|
+
### How to set it
|
|
577
681
|
|
|
578
682
|
```bash
|
|
579
|
-
#
|
|
683
|
+
# Try something: prefix the command, applies to this run only
|
|
580
684
|
HERDR_WEB_PORT=8000 HERDR_WEB_ONCONNECT= ./herdr-web
|
|
581
685
|
|
|
582
|
-
#
|
|
686
|
+
# Permanent: in ~/.zshrc (when you start it by hand in a terminal)
|
|
583
687
|
export HERDR_WEB_HOST=0.0.0.0
|
|
584
688
|
export HERDR_WEB_TLS=auto
|
|
585
689
|
|
|
586
|
-
#
|
|
587
|
-
# systemd
|
|
690
|
+
# Permanent: launchd (macOS) in the plist's EnvironmentVariables;
|
|
691
|
+
# systemd in the unit's Environment= / EnvironmentFile=
|
|
588
692
|
```
|
|
589
693
|
|
|
590
|
-
|
|
694
|
+
Three rules, all about not guessing:
|
|
591
695
|
|
|
592
|
-
-
|
|
593
|
-
-
|
|
594
|
-
-
|
|
696
|
+
- **An explicit empty string counts.** `HERDR_WEB_ONCONNECT=` means "type nothing on connect"; it does not fall back to the default `herdr`. Every switch with a default depends on this to be turnable off.
|
|
697
|
+
- **A malformed integer is treated as unset** (falls back to the default) rather than silently becoming 0; below-minimum values are clamped. `HERDR_WEB_DEVICE_TTL_DAYS=9O` (letter O) will not turn device credentials into "never expires".
|
|
698
|
+
- **Booleans accept `1` / `true`** (any case); anything else is off.
|
|
595
699
|
|
|
596
|
-
|
|
700
|
+
Changes take effect on restart — configuration is read once at startup. To confirm what was read, look at the startup banner: shell, data directory, herdr socket, TLS tier and paired device count are all printed there.
|
|
597
701
|
|
|
598
|
-
###
|
|
702
|
+
### Basics
|
|
599
703
|
|
|
600
|
-
|
|
|
704
|
+
| Variable | Default | Meaning |
|
|
601
705
|
|---|---|---|
|
|
602
|
-
| `HERDR_WEB_PORT` | `7788` |
|
|
603
|
-
| `HERDR_WEB_HOST` | `127.0.0.1` |
|
|
604
|
-
| `HERDR_WEB_TOKEN` |
|
|
605
|
-
| `HERDR_WEB_SHELL` | `$SHELL` |
|
|
606
|
-
| `HERDR_WEB_ONCONNECT` | `herdr` |
|
|
607
|
-
| `HERDR_WEB_ONCONNECT_MS` | `250` |
|
|
608
|
-
| `HERDR_WEB_DIR` | `~/.herdr-web` |
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
706
|
+
| `HERDR_WEB_PORT` | `7788` | Port |
|
|
707
|
+
| `HERDR_WEB_HOST` | `127.0.0.1` | Listen address; `0.0.0.0` opens it to the LAN |
|
|
708
|
+
| `HERDR_WEB_TOKEN` | reads `~/.herdr-web/token` | **Legacy**; only good for bootstrapping once (exchanged for a device credential). Not generated on new installs |
|
|
709
|
+
| `HERDR_WEB_SHELL` | `$SHELL` | The shell run inside the PTY |
|
|
710
|
+
| `HERDR_WEB_ONCONNECT` | `herdr` | Typed into the PTY on connect (Enter included). **Set it to an empty string to type nothing.** **Session URLs ignore this** (`/work` always types `herdr --session work`, see [One URL, one session](#one-url-one-session-name)) — to always land in a session, bookmark the URL rather than setting this |
|
|
711
|
+
| `HERDR_WEB_ONCONNECT_MS` | `250` | How long to wait before typing that line. The wait starts **after the shell's first output** — an rc file touching `stty`, or a completion plugin initialising, **silently swallows** characters typed too early. If the auto-typed line does not land, raise it |
|
|
712
|
+
| `HERDR_WEB_DIR` | `~/.herdr-web` | Data directory, in two layers: configuration and files (`softkeys.json` / `tls/` / `uploads/`) at the root, **internal data** (device credentials, passkey public keys) under `data/` — those two are not meant to be hand-edited, and tampering is reported in the terminal. **Keep the path short**: a unix socket (`ctl.sock`) is opened inside it, and beyond ~100 bytes it cannot bind, which breaks the subcommands |
|
|
713
|
+
| `HERDR_WEB_FILES` | on | `=0` turns file browsing off: `/api/files/*` and `/_f/` all 404, and the 📁 in the top bar is not drawn (an entry point that opens onto a wall of 404s is worse than no entry point) |
|
|
714
|
+
| `HERDR_WEB_FILE_ROOTS` | empty | Comma-separated directories. Set, this is **a real allowlist** (a jail) and only those trees are visible. **Empty means no boundary** — the reasoning is in [File browsing](#file-browsing-looking-at-what-the-agent-generated). `~` is expanded; non-absolute entries are discarded (relative to what? keeping them only makes the prefix check pass somewhere surprising) |
|
|
715
|
+
|
|
716
|
+
### Outbox / talking to herdr
|
|
717
|
+
|
|
718
|
+
| Variable | Default | Meaning |
|
|
613
719
|
|---|---|---|
|
|
614
|
-
| `HERDR_WEB_SOCKET` | `$HERDR_SOCKET_PATH`
|
|
615
|
-
| `HERDR_WEB_POLL_MS` | `500` |
|
|
616
|
-
| `HERDR_WEB_PUSH_MS` | `700` |
|
|
617
|
-
| `
|
|
720
|
+
| `HERDR_WEB_SOCKET` | `$HERDR_SOCKET_PATH` or `~/.config/herdr/herdr.sock` | The herdr socket the outbox connects to. **Do not rely on `HERDR_SOCKET_PATH`**: `dropEnv` strips `HERDR_*`, and this process may not have been started from a herdr pane at all |
|
|
721
|
+
| `HERDR_WEB_POLL_MS` | `500` | How often the outbox checks "where is focus, what is in the input line". Minimum 200 |
|
|
722
|
+
| `HERDR_WEB_PUSH_MS` | `700` | With "two-way" on, how long after you stop typing the draft is pushed. Minimum 100 |
|
|
723
|
+
| `HERDR_WEB_NOTICE_MS` | `4000` | How often notices (the cards and the unread badge) ask "anything new". **`0` turns the whole notice feature off** and the frontend stops polling. Anything under 1000 is treated as 1000 — this tick only reads memory on the server (it does not touch the herdr socket), but a notice is inherently 2.5 seconds behind the state change (debounce), so polling harder cannot beat that |
|
|
724
|
+
| `HERDR_WEB_SETTLE_MS` | `120` | How long to wait between two `pane.read` calls (to defeat the one-frame snapshot lag). **Never 0**: herdr sometimes answers in 1-2ms, both reads land on the same frame, and the clear loop misreads that as "cannot be cleared". The clear path has its own 120ms floor |
|
|
618
725
|
|
|
619
|
-
###
|
|
726
|
+
### Exposure / TLS / credentials
|
|
620
727
|
|
|
621
|
-
|
|
728
|
+
Details in [SECURITY.md](SECURITY.md) (Chinese).
|
|
622
729
|
|
|
623
|
-
|
|
|
730
|
+
| Variable | Default | Meaning |
|
|
624
731
|
|---|---|---|
|
|
625
|
-
| `HERDR_WEB_EXPOSED` |
|
|
626
|
-
| `HERDR_WEB_TLS_CERT` / `_KEY` |
|
|
627
|
-
| `HERDR_WEB_ACME_DNS` |
|
|
628
|
-
| `HERDR_WEB_ACME_EMAIL` |
|
|
629
|
-
| `HERDR_WEB_ACME_STAGING` |
|
|
630
|
-
| `HERDR_WEB_TLS` |
|
|
631
|
-
| `HERDR_WEB_HOSTNAME` |
|
|
632
|
-
| `HERDR_WEB_PUBLIC_URL` |
|
|
633
|
-
| `HERDR_WEB_DEVICE_TTL_DAYS` | `90` |
|
|
634
|
-
| `HERDR_WEB_RPID` |
|
|
635
|
-
| `HERDR_WEB_REAUTH_HOURS` | `24` |
|
|
636
|
-
| `HERDR_WEB_LEGACY_TOKEN` | `on` | `on` / `loopback
|
|
637
|
-
| `HERDR_WEB_TRUST_LOOPBACK` |
|
|
638
|
-
| `HERDR_WEB_TRUST_PROXY` |
|
|
639
|
-
| `HERDR_WEB_INSECURE` |
|
|
640
|
-
| `HERDR_WEB_UPDATE_CHECK` |
|
|
641
|
-
|
|
642
|
-
###
|
|
643
|
-
|
|
644
|
-
|
|
|
732
|
+
| `HERDR_WEB_EXPOSED` | off | `=1` **declares that this port is reachable from the internet** (frp / port forwarding / tunnels). Behind frp the process usually listens on 127.0.0.1 and every request also comes from 127.0.0.1, so "is the listen address local" tells you nothing; it cannot be detected, only declared. Once declared: TLS is mandatory and loopback-without-pairing is turned off |
|
|
733
|
+
| `HERDR_WEB_TLS_CERT` / `_KEY` | empty | Use the certificate you supply. If you own a domain and got a real certificate via DNS-01, take this route — zero browser warnings, no profiles to install, least friction |
|
|
734
|
+
| `HERDR_WEB_ACME_DNS` | empty | Let herdr-web **get its own certificate**; the value is the DNS provider: `cloudflare` / `alidns` / `tencentcloud` / `route53` / `digitalocean` / `huaweicloud`. It uses DNS-01, so nothing has to reach you from outside — behind NAT, or with the domain pointed at a LAN address, it still works. **Where to get each provider's token and what scope it needs: [DNS.md](DNS.md)** (Chinese) |
|
|
735
|
+
| `HERDR_WEB_ACME_EMAIL` | empty | ACME account email. Can be empty, but then you get no expiry reminders either |
|
|
736
|
+
| `HERDR_WEB_ACME_STAGING` | off | `=1` uses Let's Encrypt staging. **Turn it on while debugging**: production allows 5 certificates per domain set per week, and a few attempts lock you out for a week |
|
|
737
|
+
| `HERDR_WEB_TLS` | see notes | `auto` self-signed (local CA + 397-day leaf, re-issued automatically when the IP changes) / `off` plaintext / `proxy` something in front already terminated TLS. Default: exposed or listening on the LAN → `auto`, purely local → `off` |
|
|
738
|
+
| `HERDR_WEB_HOSTNAME` | empty | Domains allowed in the `Host` header, comma separated. **IPs always pass, domains must be listed** — this is the only defence against DNS rebinding, and anything else gets a 421 |
|
|
739
|
+
| `HERDR_WEB_PUBLIC_URL` | empty | The address you **actually visit** (`https://herdr.example.com:17788`). With frp the public port is often not the local one, and without this the QR code in the banner is useless. The domain in it is allowlisted automatically |
|
|
740
|
+
| `HERDR_WEB_DEVICE_TTL_DAYS` | `90` | How long a device credential survives without use (renewed on every use). `0` = **never expires** |
|
|
741
|
+
| `HERDR_WEB_RPID` | derived | The domain a passkey is bound to. Defaults to the first `HERDR_WEB_HOSTNAME`, or `localhost` when purely local. **A bare IP is not a valid value** — such deployments cannot use passkeys |
|
|
742
|
+
| `HERDR_WEB_REAUTH_HOURS` | `24` | Once a passkey is registered, how long a session credential remains valid after the last biometric check. `0` = no re-verification (passkeys serve only as the login / new-device path). **Does nothing at all while no passkey is registered** |
|
|
743
|
+
| `HERDR_WEB_LEGACY_TOKEN` | `on` | `on` / `loopback` (the old token only works locally) / `off`. Once migrated, just delete the token file |
|
|
744
|
+
| `HERDR_WEB_TRUST_LOOPBACK` | off | `=1` exempts requests from 127.0.0.1 from pairing. **Never turn this on behind frp or a reverse proxy** — there, public requests also arrive from 127.0.0.1, i.e. everyone is "local". When on, it additionally requires `Host` to be a loopback literal |
|
|
745
|
+
| `HERDR_WEB_TRUST_PROXY` | off | `=1` is required to read `X-Forwarded-For`. With no trusted proxy in front, leaving it on lets an attacker forge the source IP with a header and walk around per-IP rate limiting |
|
|
746
|
+
| `HERDR_WEB_INSECURE` | off | `=1` permits "exposed but no TLS". No legitimate use beyond temporary debugging |
|
|
747
|
+
| `HERDR_WEB_UPDATE_CHECK` | on | `=0` disables automatic update checks. With it off the process makes **no outbound requests at all** — a hard requirement in the kind of environment where an internal machine must not dial out. Only the automatic check is disabled; `herdr-web update --check` still works |
|
|
748
|
+
|
|
749
|
+
### Troubleshooting
|
|
750
|
+
|
|
751
|
+
| Variable | Default | Meaning |
|
|
645
752
|
|---|---|---|
|
|
646
|
-
| `HERDR_WEB_DEBUG_INPUT` |
|
|
753
|
+
| `HERDR_WEB_DEBUG_INPUT` | off | `=1` logs every batch of bytes written into the PTY as hex (including the auto-typed line, prefixed `onconnect`). The only way to answer "what exactly did that key send" — guessing does not work |
|
|
647
754
|
|
|
648
|
-
###
|
|
755
|
+
### Read but not prefixed with `HERDR_WEB_`
|
|
649
756
|
|
|
650
|
-
|
|
|
757
|
+
| Variable | When it matters |
|
|
651
758
|
|---|---|
|
|
652
|
-
| `SHELL` | `HERDR_WEB_SHELL`
|
|
653
|
-
| `HERDR_SOCKET_PATH` | `HERDR_WEB_SOCKET`
|
|
759
|
+
| `SHELL` | The shell run inside the PTY when `HERDR_WEB_SHELL` is unset (falling back to `/bin/zsh`) |
|
|
760
|
+
| `HERDR_SOCKET_PATH` | Fallback herdr socket when `HERDR_WEB_SOCKET` is unset. **Do not count on it being there**: `dropEnv` strips `HERDR_*` from child processes (to prevent nesting), and this process may not have started from a herdr pane |
|
|
654
761
|
|
|
655
|
-
###
|
|
762
|
+
### A few common setups
|
|
656
763
|
|
|
657
764
|
```bash
|
|
658
|
-
# 1.
|
|
765
|
+
# 1. Purely local (default): plain http, since loopback is a secure context anyway
|
|
659
766
|
./herdr-web
|
|
660
767
|
|
|
661
|
-
# 2.
|
|
768
|
+
# 2. Phone / tablet on the LAN: self-signed TLS, pair by scanning the banner QR
|
|
662
769
|
HERDR_WEB_HOST=0.0.0.0 ./herdr-web
|
|
663
770
|
|
|
664
|
-
# 3.
|
|
665
|
-
#
|
|
771
|
+
# 3. Exposed through frp / a tunnel: EXPOSED must be declared (the process only
|
|
772
|
+
# listens on 127.0.0.1 and cannot tell whether anyone outside can reach it),
|
|
773
|
+
# PUBLIC_URL decides which address the QR code encodes
|
|
666
774
|
HERDR_WEB_EXPOSED=1 HERDR_WEB_TLS=proxy \
|
|
667
775
|
HERDR_WEB_PUBLIC_URL=https://herdr.example.com \
|
|
668
776
|
HERDR_WEB_HOSTNAME=herdr.example.com ./herdr-web
|
|
669
777
|
|
|
670
|
-
# 4.
|
|
778
|
+
# 4. Your own domain + a real certificate (zero browser warnings, least friction)
|
|
671
779
|
HERDR_WEB_HOST=0.0.0.0 HERDR_WEB_HOSTNAME=herdr.example.com \
|
|
672
780
|
HERDR_WEB_TLS_CERT=/etc/ssl/herdr/fullchain.pem \
|
|
673
781
|
HERDR_WEB_TLS_KEY=/etc/ssl/herdr/privkey.pem ./herdr-web
|
|
674
782
|
|
|
675
|
-
# 5.
|
|
783
|
+
# 5. Do not drop into herdr on connect (stay in the shell)
|
|
676
784
|
HERDR_WEB_ONCONNECT= ./herdr-web
|
|
677
785
|
```
|
|
678
786
|
|
|
679
|
-
##
|
|
787
|
+
## Daemon
|
|
680
788
|
|
|
681
|
-
|
|
789
|
+
Install it as a user-level service that starts on boot:
|
|
682
790
|
|
|
683
791
|
```bash
|
|
684
|
-
herdr-web service install # macOS → launchd LaunchAgent
|
|
685
|
-
herdr-web service status #
|
|
686
|
-
herdr-web service logs # tail -f
|
|
687
|
-
herdr-web service restart #
|
|
688
|
-
herdr-web service uninstall #
|
|
792
|
+
herdr-web service install # macOS → launchd LaunchAgent; Linux → systemd user unit
|
|
793
|
+
herdr-web service status # installed? running? PID? where are the logs?
|
|
794
|
+
herdr-web service logs # tail -f the log
|
|
795
|
+
herdr-web service restart # needed after replacing the binary
|
|
796
|
+
herdr-web service uninstall # stop and remove (data and logs untouched)
|
|
689
797
|
```
|
|
690
798
|
|
|
691
|
-
|
|
799
|
+
**Configuration is copied out of the current shell at install time.** So the order is "get the environment right, then install"; changing configuration means installing again (it is idempotent — overwrite and restart). To read it from a file:
|
|
692
800
|
|
|
693
801
|
```bash
|
|
694
802
|
herdr-web service install --env-file .env
|
|
695
803
|
```
|
|
696
804
|
|
|
697
|
-
|
|
805
|
+
What gets copied is every `HERDR_WEB_*`, plus `PATH` / `SHELL` / `HOME` / `USER` / `LOGNAME` / `LANG` / `LC_ALL` / `TERM` / `HERDR_SOCKET_PATH`. `install` prints the whole list — from then on, "which configuration is this machine's service actually using" can only be answered by the plist / unit, so it is cheapest to read it at install time.
|
|
698
806
|
|
|
699
|
-
|
|
807
|
+
**Certificate issuance (tiers C / D) requires `--env-file`.** DNS provider credentials (`CLOUDFLARE_DNS_API_TOKEN`, `ALICLOUD_ACCESS_KEY` and friends) neither carry the `HERDR_WEB_` prefix nor appear in the allowlist above, so **they are not copied from the shell**: however correctly you exported them in `.zshrc`, the installed service still cannot get a certificate — and it only blows up at the first issuance. Keys in `--env-file` go in **wholesale** (and override the current environment), which makes it the only way to hand the token to the service. The file is read at `install` time only and never touched again.
|
|
700
808
|
|
|
701
|
-
plist / unit
|
|
809
|
+
The plist / unit is **0600** — its contents are exactly that environment in plaintext.
|
|
702
810
|
|
|
703
|
-
|
|
811
|
+
**Copying `PATH` is mandatory, and it is the most common failure after installing as a service**: launchd's default `PATH` is only `/usr/bin:/bin:/usr/sbin:/sbin`, so `HERDR_WEB_ONCONNECT=herdr` turns into `herdr: command not found` while the page just shows an empty shell with no clue why.
|
|
704
812
|
|
|
705
|
-
|
|
813
|
+
Why user-level rather than system-level: this process opens **your** shell. Running it as a root system service means the terminal in the browser is root's, permissions jump straight to maximum, and `~/.herdr-web` and `~/.config/herdr/herdr.sock` all point at somebody else's home.
|
|
706
814
|
|
|
707
|
-
|
|
815
|
+
Platform-specific traps:
|
|
708
816
|
|
|
709
|
-
| |
|
|
817
|
+
| | File | Note |
|
|
710
818
|
|---|---|---|
|
|
711
|
-
| macOS | `~/Library/LaunchAgents/io.github.zbysir.herdr-web.plist` | LaunchAgent
|
|
712
|
-
| Linux | `~/.config/systemd/user/herdr-web.service` | `install`
|
|
819
|
+
| macOS | `~/Library/LaunchAgents/io.github.zbysir.herdr-web.plist` | A LaunchAgent starts **at login**, not at boot. On a machine with automatic login the two are equivalent; otherwise you have to log in once. "Start with nobody logged in" would require a system-level daemon in `/Library/LaunchDaemons`, which makes the shell root's — this project does not do that. |
|
|
820
|
+
| Linux | `~/.config/systemd/user/herdr-web.service` | `install` also runs `loginctl enable-linger`. **Without linger the service is stopped when you log out of ssh** — for a machine you want to reach at any time, that is the same as not running at all. If it fails it tells you to run `sudo loginctl enable-linger $USER`. |
|
|
713
821
|
|
|
714
|
-
|
|
822
|
+
Logs are at `~/.herdr-web/logs/herdr-web.log` on both platforms (deliberately identical, so the docs and `service logs` have one answer). On Linux `journalctl --user -u herdr-web` works as well.
|
|
715
823
|
|
|
716
|
-
`service status`
|
|
824
|
+
`service status` reporting "installed but not running" means **it crashes on start**, and the reason is only in the log — launchd and systemd both keep retrying with a few seconds of backoff, so without looking you would assume it is running.
|
|
717
825
|
|
|
718
|
-
Windows
|
|
826
|
+
Windows, and Linux without systemd (containers, WSL1), are told clearly that this cannot work and what to do instead, rather than being given something that will not run. On WSL2, add `[boot] systemd=true` to `/etc/wsl.conf` and `wsl --shutdown` to restart, and it works.
|
|
719
827
|
|
|
720
|
-
##
|
|
828
|
+
## Updating
|
|
721
829
|
|
|
722
830
|
```bash
|
|
723
|
-
herdr-web update #
|
|
724
|
-
herdr-web update --check #
|
|
725
|
-
herdr-web update --restart #
|
|
726
|
-
herdr-web version #
|
|
831
|
+
herdr-web update # check and upgrade
|
|
832
|
+
herdr-web update --check # check only, change nothing
|
|
833
|
+
herdr-web update --restart # upgrade, then restart the service
|
|
834
|
+
herdr-web version # current version + how it was installed
|
|
727
835
|
```
|
|
728
836
|
|
|
729
|
-
|
|
837
|
+
**How it upgrades depends on how it was installed**, and `update` works that out itself (from the executable's path, resolving symlinks first):
|
|
730
838
|
|
|
731
|
-
|
|
|
839
|
+
| Installed via | Upgrade action |
|
|
732
840
|
|---|---|
|
|
733
|
-
| npm |
|
|
734
|
-
| homebrew |
|
|
735
|
-
| `go install` |
|
|
736
|
-
| release archive / install.sh |
|
|
841
|
+
| npm | runs `npm install -g @bysir/herdr-web@latest` |
|
|
842
|
+
| homebrew | runs `brew upgrade herdr-web` |
|
|
843
|
+
| `go install` | runs `go install …@latest` |
|
|
844
|
+
| release archive / install.sh | **does it itself**: download → verify sha256 → write a temp file in the same directory → atomic `rename` |
|
|
737
845
|
|
|
738
|
-
|
|
846
|
+
Package-manager installs are not touched directly because editing things inside `node_modules` / `Cellar` gets overwritten the next time that package manager runs — wasted effort.
|
|
739
847
|
|
|
740
|
-
|
|
848
|
+
Three things about the self-managed path are deliberate: **verify before landing** (a `checksums.txt` mismatch aborts everything), **the temp file must be in the same directory** (a cross-directory `rename` gives EXDEV), and **the old file is not deleted** (on unix, renaming over a running executable is allowed, the old inode is still held by the process, so the current process runs safely until it exits).
|
|
741
849
|
|
|
742
|
-
|
|
850
|
+
**Replacing the file is not the same as replacing the running process.** Only a restart takes effect, and a restart kills every terminal session in use — so it is not done by default, only with `--restart`.
|
|
743
851
|
|
|
744
|
-
|
|
852
|
+
New-version notices appear in three places:
|
|
745
853
|
|
|
746
|
-
-
|
|
747
|
-
-
|
|
748
|
-
-
|
|
854
|
+
- the last line of the **startup banner** (from cache, so no request is made on the startup path — on a slow network that would turn into "startup hangs for ten seconds");
|
|
855
|
+
- a strip at the top of the **admin page**, with the current version, the command to run and a link to the release notes;
|
|
856
|
+
- while the service is running, a daily background check writes one line to the **log** when a new version appears (once per version, not daily nagging).
|
|
749
857
|
|
|
750
|
-
|
|
858
|
+
Checks go to GitHub Releases' anonymous API, with results cached in `~/.herdr-web/update.json` (on disk, so frequent restarts do not mean checking every time; failures are stamped too, so a machine with no connectivity does not eat a timeout on every start). `HERDR_WEB_UPDATE_CHECK=0` disables the automatic check entirely — with it off, this process makes no outbound requests at all. Local builds (where `version` reports `dev`) neither check nor nag.
|
|
751
859
|
|
|
752
|
-
##
|
|
860
|
+
## Security
|
|
753
861
|
|
|
754
|
-
|
|
862
|
+
**This thing amounts to a shell over HTTP** (the outbox alone can make an agent run commands, even without a PTY), so the door is designed on that premise. The design document and threat model are in [SECURITY.md](SECURITY.md) (Chinese); what follows is only what is **already implemented**:
|
|
755
863
|
|
|
756
|
-
-
|
|
757
|
-
-
|
|
758
|
-
- **
|
|
759
|
-
-
|
|
760
|
-
-
|
|
761
|
-
-
|
|
762
|
-
- **Host
|
|
763
|
-
-
|
|
764
|
-
-
|
|
765
|
-
-
|
|
864
|
+
- **Pair each device once.** A one-time code (40 bits, 5 minutes, single use, memory only) is exchanged for a per-device credential in an `HttpOnly; SameSite=Strict` cookie. The server's `~/.herdr-web/devices.json` **stores only sha256** — the agents on this machine read untrusted content all day, so "the credential file gets read by prompt injection" is a daily risk here, not a theoretical one.
|
|
865
|
+
- **Credentials are bound to a device, not an IP.** Remembering trust by IP loses both ways: DHCP hands an address you approved to somebody else (a guest joins the Wi-Fi and is in your shell), and changing your own Wi-Fi means pairing again.
|
|
866
|
+
- **No secrets in URLs.** `?pair=` and the legacy `?token=` are exchanged for a cookie and scrubbed with a 302, so browser history, bookmark sync and screenshots stop being leak channels.
|
|
867
|
+
- **Revocable.** `herdr-web devices` / `revoke` from the CLI, or Settings → Devices → "Sign out" / "Kick" on the web; the next request gets 401.
|
|
868
|
+
- **Only someone at the machine can produce a pairing code** (`herdr-web pair` or the startup banner). No path on the web issues one, not even to an already-paired device. Two reasons: ① a code creates an independent credential that **is not revoked along with its creator** — someone borrows your phone once, pairs their own device, and after you kick the phone theirs is still in: persistence that bypasses revocation; ② the code is printed into a terminal, and that terminal is often a herdr pane, where an agent in the same session can `pane.read` it — so if an outsider could remotely trigger "print a code", "trigger the print + let an injected agent read it" is a complete remote pairing chain with nobody ever touching the machine. Until the second factor at L2 exists, "can see that terminal" is **the only out-of-band factor in the system** and must not be weakened.
|
|
869
|
+
- **Refuses to start when exposed without TLS** (it used to be a warning line, and warnings go unread). Self-signed uses a local CA + a 397-day leaf; a changed IP re-issues automatically, but devices trust the CA, so nobody has to click "proceed" again.
|
|
870
|
+
- **A Host allowlist** blocks DNS rebinding (IPs always pass, domains must be in `HERDR_WEB_HOSTNAME` or get a 421); **Origin checks** plus `SameSite=Strict` plus a custom header make three layers against CSRF; a cookie-bearing request to `/pty` with no Origin is rejected outright.
|
|
871
|
+
- **Rate limiting and lockout**: the first two wrong pairing codes are free, then exponential backoff; 10 failures within 15 minutes bans that IP for 15 minutes (doubling for repeat offenders, capped at 24 hours), and distributed attempts from rotating source IPs trip a global breaker (which only refuses new pairings and leaves existing sessions alone) with a warning printed in the terminal. Only **short-credential guessing** counts as failure — an unrecognised cookie does not, otherwise revoking an old phone would immediately lock you out. **Loopback is never banned by default** (the unlock path is behind the same door), but **declaring `EXPOSED` turns that exemption off automatically** — see the frp section below: everything tunnelled in has source IP 127.0.0.1, and leaving the exemption on would make the entire rate-limiting layer a no-op.
|
|
872
|
+
- Security headers (CSP / nosniff / no-referrer / DENY), a cap of 8 concurrent PTYs, and OSC 8 links restricted to `http/https/mailto` (what a terminal displays is up to the program).
|
|
873
|
+
- **HSTS is deliberately not sent**: with a self-signed certificate, HSTS would weld the "proceed anyway" route shut, and it cannot be cleared.
|
|
766
874
|
|
|
767
|
-
- **
|
|
875
|
+
- **Passkeys** (second factor). Settings → Devices → passkey → add. The server **stores only the public key**, so an agent on the same machine reading the credential file gains nothing (TOTP's shared secret cannot offer that, which is the main reason for choosing this). Once added: moving to a new device does not require going back to the machine (a synced passkey is on all of your devices), and session credential lifetime can drop from three months to one day. It requires a domain — a bare IP is not a valid WebAuthn identity.
|
|
768
876
|
|
|
769
|
-
|
|
877
|
+
Not built yet (in order of value): audit logging, token rotation with reuse detection, a `panic` disconnect-everything button.
|
|
770
878
|
|
|
771
|
-
###
|
|
879
|
+
### Reaching it from the internet (frp / tunnels)
|
|
772
880
|
|
|
773
|
-
>
|
|
881
|
+
> The complete public-access design, the simplification tiers, and the operational traps hit in a real deployment: [DEPLOY.md](DEPLOY.md) (Chinese).
|
|
774
882
|
|
|
775
|
-
|
|
883
|
+
The recommendation is **frp's `type = tcp` plus herdr-web holding a real certificate**: TLS end to end, and the VPS running frps only ever sees ciphertext. frp's https mode decrypts on the VPS, which means that machine can watch your entire terminal.
|
|
776
884
|
|
|
777
885
|
```bash
|
|
778
886
|
HERDR_WEB_EXPOSED=1 HERDR_WEB_TLS_CERT=~/certs/herdr.example.com/fullchain.pem HERDR_WEB_TLS_KEY=~/certs/herdr.example.com/privkey.pem HERDR_WEB_HOSTNAME=herdr.example.com HERDR_WEB_PUBLIC_URL=https://herdr.example.com:17788 ./herdr-web
|
|
779
887
|
```
|
|
780
888
|
|
|
781
|
-
|
|
889
|
+
Get the certificate via DNS-01 (`lego` or `certbot`): **nothing needs to reach you, you only need to edit one TXT record**, so even with the A record pointing at a LAN address you get a certificate browsers trust by default — zero warnings on the phone, a secure context (so the clipboard and `OSC 52` behave), and the passkey domain requirement solved in one move.
|
|
782
890
|
|
|
783
|
-
⚠️ **`HERDR_WEB_EXPOSED=1`
|
|
891
|
+
⚠️ **`HERDR_WEB_EXPOSED=1` has to be declared by you**: behind frp, frpc connects from localhost, so herdr-web sees a listen address of `127.0.0.1` and a source address of `127.0.0.1` on every request — "is this local" is useless. Without the declaration, "exposed but undetectable" and "loopback-without-pairing lets public requests in" are both open at once. The variable covers the first; the second is now **off by default**.
|
|
784
892
|
|
|
785
|
-
⚠️ **frp
|
|
893
|
+
⚠️ **frp's tcp mode cannot see the client's real IP**: as far as herdr-web is concerned every request comes from `127.0.0.1` (same when frpc runs in a container). Two consequences:
|
|
786
894
|
|
|
787
|
-
1.
|
|
788
|
-
2.
|
|
895
|
+
1. Per-IP rate limiting counts everyone as the same person. Lockout only blocks new pairings and leaves existing sessions alone, so the worst case is "you cannot pair a new device for fifteen minutes"; `herdr-web unlock` clears it.
|
|
896
|
+
2. **The "never ban loopback" exemption must be turned off**, otherwise the whole rate-limiting layer is a no-op — configured, and never once effective. `HERDR_WEB_EXPOSED=1` turns it off automatically, which is the other reason that variable must be declared.
|
|
789
897
|
|
|
790
|
-
|
|
898
|
+
If you want real IPs, enable `transport.proxyProtocolVersion = "v2"` on frpc (herdr-web would also need to parse the PROXY header, which it does not yet), or use http mode so frps adds `X-Forwarded-For` (only then set `HERDR_WEB_TRUST_PROXY=1`). **Never set that variable without a trusted proxy in front** — an attacker just brings their own header.
|
|
791
899
|
|
|
792
|
-
###
|
|
900
|
+
### Odds and ends
|
|
793
901
|
|
|
794
|
-
http
|
|
902
|
+
http is not a secure context, so `navigator.clipboard` does not exist: on a phone `OSC 52` stops working and `⌘C` falls back to `execCommand('copy')`. Over HTTPS everything behaves.
|
|
795
903
|
|
|
796
|
-
|
|
904
|
+
Cookies **do not distinguish ports**: another web service on a different port of the same host also receives this cookie (`HttpOnly` only stops JS from reading it, not the browser from sending it). There is no fix — do not run untrusted web services on the same machine.
|
|
797
905
|
|
|
798
|
-
|
|
906
|
+
**The pairing code is printed into a terminal, and if that terminal is a herdr pane, other agents in the same session can `pane.read` it** (this project's own outbox reads panes exactly that way). The window is 5 minutes and single use, and it only exists when you actively ask for a code (which is why remotely triggering one was removed). If that still bothers you, run `herdr-web pair` in a terminal outside herdr.
|