@bysir/herdr-web 0.3.0 → 0.5.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 +182 -594
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -8,9 +8,16 @@
|
|
|
8
8
|
<b>English</b> · <a href="README.zh-CN.md">简体中文</a>
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
|
-
A terminal in your browser, built for running [`herdr`](https://github.com/zbysir/herdr).
|
|
11
|
+
A terminal in your browser, built for running [`herdr`](https://github.com/zbysir/herdr).
|
|
12
|
+
One Go binary with the frontend baked in. Works on phones.
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
**Voice compose** is the point of this project: dictate on a tablet, select the words that came out
|
|
15
|
+
wrong and say them again, then hand the whole paragraph to an agent's input line. A phone is enough
|
|
16
|
+
to get by; a tablet in landscape gives you 211 columns — that is a workstation.
|
|
17
|
+
|
|
18
|
+
This document covers **installing, using and configuring** it. Why each thing works the way it does,
|
|
19
|
+
and what it cost to learn, lives in the [documents listed at the end](#documents) — those are the
|
|
20
|
+
real substance of this project. They are in Chinese.
|
|
14
21
|
|
|
15
22
|
## Install
|
|
16
23
|
|
|
@@ -73,43 +80,59 @@ The ⚙ at the right end of the top bar is the **settings panel**; its "Devices"
|
|
|
73
80
|
|
|
74
81
|
**Out of codes? Go back to the machine and run `herdr-web pair`.** That is not laziness either — see below.
|
|
75
82
|
|
|
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).
|
|
83
|
+
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](docs/dev/SECURITY.md) (Chinese).
|
|
77
84
|
|
|
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 [
|
|
85
|
+
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 [First run](#first-run). 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.
|
|
79
86
|
|
|
80
87
|
**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).
|
|
81
88
|
|
|
82
|
-
##
|
|
89
|
+
## First run
|
|
83
90
|
|
|
84
|
-
|
|
91
|
+
On startup it prints the addresses you can reach it at. When listening on `0.0.0.0` it scores the
|
|
92
|
+
interfaces and marks the one your phone can actually reach with `← use this one from your phone`;
|
|
93
|
+
that is the address encoded in the QR code.
|
|
85
94
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
95
|
+
**Pair each device once.** The startup banner carries a one-time pairing code (5 minutes, single use)
|
|
96
|
+
and its QR code — scan it from your phone and you are in, zero typing. After that your bookmark holds
|
|
97
|
+
no secret (the credential lives in an `HttpOnly` cookie), and changing Wi-Fi, changing subnets or
|
|
98
|
+
rebooting costs you nothing. Three ways to scan: your camera app (the code is a link with `?pair=`),
|
|
99
|
+
"scan with camera" inside the pairing page (only shown when it can work — it needs `BarcodeDetector`
|
|
100
|
+
and a camera, which requires a secure context), or typing the 8-digit code into the pairing page.
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
herdr-web pair # print a fresh one-time pairing code + QR
|
|
104
|
+
herdr-web devices # list paired devices (label / last seen / last IP / expiry)
|
|
105
|
+
herdr-web revoke <id> # kick one (all = everything); the next request gets 401
|
|
106
|
+
herdr-web unlock # clear the global "too many failures" circuit breaker
|
|
90
107
|
```
|
|
91
108
|
|
|
92
|
-
|
|
109
|
+
**The web UI never issues a pairing code** (not even to an already-paired device) — see
|
|
110
|
+
[Security](#security). Out of codes? Go back to the machine and run `herdr-web pair`.
|
|
93
111
|
|
|
94
|
-
|
|
112
|
+
Once connected it **types `herdr` for you**. To type something else, or nothing:
|
|
113
|
+
`HERDR_WEB_ONCONNECT` (an empty string means stay in the shell).
|
|
95
114
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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.
|
|
115
|
+
**A path segment in the URL is a different herdr session**: `/work` types
|
|
116
|
+
`herdr --session work` and creates it if needed; `/scratch` is another one. Two bookmarks are two
|
|
117
|
+
working contexts that survive closing the browser. Names are `[A-Za-z0-9._-]`, 40 characters max;
|
|
118
|
+
an invalid one is an error rather than a silent fallback to the default session — using the wrong
|
|
119
|
+
socket would **silently deliver your words into another herdr**.
|
|
103
120
|
|
|
104
|
-
|
|
121
|
+
**The admin page is at `http://127.0.0.1:<port+1>/`**: certificate status, one-click issue/renew,
|
|
122
|
+
generated DNS `.env` snippets, pairing codes, device kicking. It is bound to loopback and does not
|
|
123
|
+
exist on the public internet, so it needs no login — anything that can reach it already has your shell.
|
|
105
124
|
|
|
106
|
-
To reach another machine, ssh from inside herdr — herdr does that itself, so
|
|
125
|
+
**Local shell only.** To reach another machine, ssh from inside herdr — herdr does that itself, so
|
|
126
|
+
this layer implements no host management and no key storage, and the "the browser can touch your
|
|
127
|
+
private keys" attack surface never exists.
|
|
107
128
|
|
|
108
|
-
##
|
|
129
|
+
## What you get
|
|
109
130
|
|
|
110
|
-
|
|
131
|
+
### Outbox (voice compose)
|
|
111
132
|
|
|
112
|
-
|
|
133
|
+
The strip with a textarea at the bottom of the page is the outbox; the ✎ in the top bar toggles it
|
|
134
|
+
and it is **on by default**. You dictate or type in there, fix what came out wrong, then hand the
|
|
135
|
+
whole paragraph to one of herdr's panes.
|
|
113
136
|
|
|
114
137
|
| Control | What it does |
|
|
115
138
|
|---|---|
|
|
@@ -122,554 +145,117 @@ Why a separate box instead of talking straight into the terminal: a terminal is
|
|
|
122
145
|
| `↑` | With an empty box, recalls the last thing you posted (30 kept locally) |
|
|
123
146
|
| `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 |
|
|
124
147
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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.
|
|
130
|
-
|
|
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).
|
|
132
|
-
|
|
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.
|
|
134
|
-
|
|
135
|
-
The status line always says where this paragraph is going; `⟳` means it is following focus, and hovering shows the polling interval in use.
|
|
136
|
-
|
|
137
|
-
### Polling, not push
|
|
138
|
-
|
|
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".
|
|
140
|
-
|
|
141
|
-
**Measured latency from switching panes to the textarea updating** (same machine, 8 samples):
|
|
142
|
-
|
|
143
|
-
| Poll interval | Fastest | Median | Slowest |
|
|
144
|
-
|---|---|---|---|
|
|
145
|
-
| 200ms | 138ms | 318ms | 550ms |
|
|
146
|
-
| **500ms (default)** | ~300ms | ~500ms | ~800ms |
|
|
147
|
-
| 1200ms | 408ms | 794ms | 818ms |
|
|
148
|
-
|
|
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.
|
|
235
|
-
|
|
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.
|
|
237
|
-
|
|
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**:
|
|
239
|
-
|
|
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.
|
|
286
|
-
|
|
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).
|
|
288
|
-
|
|
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.
|
|
290
|
-
|
|
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.
|
|
292
|
-
|
|
293
|
-
### System notifications
|
|
294
|
-
|
|
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.
|
|
296
|
-
|
|
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.
|
|
304
|
-
|
|
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 |
|
|
322
|
-
|---|---|
|
|
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
|
|
330
|
-
|
|
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.
|
|
334
|
-
|
|
335
|
-
### The link route: `/_f/<ticket>`
|
|
336
|
-
|
|
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.
|
|
338
|
-
|
|
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.
|
|
340
|
-
|
|
341
|
-
### Four hard rules on that route
|
|
342
|
-
|
|
343
|
-
None is optional, because what comes out is **a file the agent wrote**:
|
|
344
|
-
|
|
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`).
|
|
349
|
-
|
|
350
|
-
### Path detection pitfalls (all in `web/src/term/paths.ts`)
|
|
148
|
+
Uploading does not need the outbox open: bind `act:img` on the soft key bar, or **paste anywhere on
|
|
149
|
+
the page** (an image in the clipboard is uploaded directly). Where the path lands depends on whether
|
|
150
|
+
the outbox is open — appended to your draft, or typed straight into the terminal.
|
|
351
151
|
|
|
352
|
-
|
|
353
|
-
|
|
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".
|
|
152
|
+
→ Why a separate box at all, how images actually work, the two-way caveats, measured polling
|
|
153
|
+
latency: [OUTBOX.md](docs/dev/OUTBOX.md)
|
|
357
154
|
|
|
358
|
-
|
|
155
|
+
### Soft key bar
|
|
359
156
|
|
|
360
|
-
|
|
157
|
+
Phones have no Ctrl key, and herdr's `ctrl+b` prefix depends on one. The keys live **on the server**
|
|
158
|
+
(`~/.herdr-web/softkeys.json`), so phone / tablet / desktop share one set of definitions, edited in
|
|
159
|
+
Settings → Soft keys.
|
|
361
160
|
|
|
362
|
-
|
|
161
|
+
- The "Keys" field takes a **key spec**; space-separated entries fire in sequence — `ctrl+b c` is the
|
|
162
|
+
prefix plus c, one tap.
|
|
163
|
+
- Supports `ctrl+x` `alt+x` `shift+tab`, named keys (`esc tab enter space bs del ins up down left
|
|
164
|
+
right home end pgup pgdn f1-f12`) and literal text (`text:/new`; quote it if it has spaces).
|
|
165
|
+
- `sticky:ctrl` / `sticky:alt` are **sticky** modifiers: tap once to light it up, then a letter sends
|
|
166
|
+
the combination.
|
|
167
|
+
- `act:` actions run in the browser and send no bytes: `act:kbd` (system keyboard), `act:img`
|
|
168
|
+
(upload), `act:panes` (pane list), `act:files` (file browsing), `act:clip` / `act:paste`
|
|
169
|
+
([copy and paste on a phone](docs/dev/MOBILE.md#手机上怎么复制--粘贴)).
|
|
170
|
+
- Every key has a **"double-tap"** checkbox; close pane / close tab / `/clear` ship with it on —
|
|
171
|
+
keys sit close together and a misfire cannot be undone.
|
|
172
|
+
- "Load presets" pours sixty-odd keys into "My keys", after which every one of them is yours to edit.
|
|
363
173
|
|
|
364
|
-
|
|
174
|
+
Key specs are parsed into bytes **on the server**, so a typo is reported at save time — telling you
|
|
175
|
+
which key and where it stopped making sense — rather than shipped as a key that does nothing.
|
|
365
176
|
|
|
366
|
-
|
|
177
|
+
### Pane list · notices
|
|
367
178
|
|
|
368
|
-
|
|
179
|
+
The ▦ in the top bar (or `act:panes` on the soft key bar; on a phone you can also tap herdr's own
|
|
180
|
+
`switch`) opens a list of panes, one per row — **tap one and you are there, zoomed full screen**.
|
|
181
|
+
You can filter (tab / title / path / pane id) and show only panes running an agent. The list
|
|
182
|
+
refreshes itself every 4 seconds.
|
|
369
183
|
|
|
370
|
-
|
|
184
|
+
When an agent stops to wait for you (or has just finished), **a card appears in the top right
|
|
185
|
+
carrying what it said**, and a badge lights up on the ▦. Tapping the card jumps there. Opening the
|
|
186
|
+
pane list is what marks them read.
|
|
371
187
|
|
|
372
|
-
|
|
188
|
+
It is an index, not a second interface: after the tap you are looking at the same herdr terminal, and
|
|
189
|
+
every keyboard habit is unchanged.
|
|
373
190
|
|
|
374
|
-
|
|
375
|
-
|
|
191
|
+
→ Sort order, the "3 minutes ago" column, when a notice fires, how the badge counts, system
|
|
192
|
+
notifications: [MOBILE.md](docs/dev/MOBILE.md)
|
|
193
|
+
How that text is scraped off the screen: [COMPOSER.md](docs/dev/COMPOSER.md)
|
|
376
194
|
|
|
377
|
-
|
|
195
|
+
### File browsing
|
|
378
196
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
197
|
+
The agent says "the plot is at `/tmp/plot-3.png`" — **tap that path and look at it**. Absolute paths
|
|
198
|
+
open directly; `./out/a.png` resolves against that pane's cwd. The 📁 in the top bar (or `act:files`)
|
|
199
|
+
is the fallback: it starts from every pane's cwd + the upload directory + home + temp, `..` walks all
|
|
200
|
+
the way to `/`, and you can paste an absolute path to open it.
|
|
382
201
|
|
|
383
|
-
|
|
202
|
+
Images (png / jpg / gif / webp, identified by magic number) are shown, text is shown as-is, anything
|
|
203
|
+
else downloads. From the viewer you can **hand the file to the agent** in one tap (its absolute path
|
|
204
|
+
goes into the outbox).
|
|
384
205
|
|
|
385
|
-
|
|
206
|
+
**There is no boundary by default** — anyone who can open this page already has a login shell, so an
|
|
207
|
+
allowlist would not stop them and would only get in the way daily. If you want one, set
|
|
208
|
+
`HERDR_WEB_FILE_ROOTS` (that is a real jail); to remove the feature, `HERDR_WEB_FILES=0`.
|
|
386
209
|
|
|
387
|
-
|
|
210
|
+
→ The short-lived link route and the four hard rules on it (never `text/html`, why SVG is safe to
|
|
211
|
+
render): [SECURITY.md](docs/dev/SECURITY.md)
|
|
388
212
|
|
|
389
|
-
|
|
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).
|
|
213
|
+
### Phones and tablets
|
|
398
214
|
|
|
399
|
-
|
|
400
|
-
|
|
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:
|
|
215
|
+
When a program has mouse reporting on (herdr does), touch gestures are taken over entirely:
|
|
402
216
|
|
|
403
217
|
| Gesture | Behaviour |
|
|
404
218
|
|---|---|
|
|
405
219
|
| 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") |
|
|
220
|
+
| 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"). It goes out immediately, with no delay |
|
|
407
221
|
| 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.
|
|
417
|
-
|
|
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.
|
|
419
|
-
|
|
420
|
-
### Tapping herdr's switch opens ours (on by default)
|
|
421
|
-
|
|
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.
|
|
423
|
-
|
|
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:
|
|
425
|
-
|
|
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.
|
|
429
|
-
|
|
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.
|
|
431
|
-
|
|
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).
|
|
433
|
-
|
|
434
|
-
### Copy and paste on a phone
|
|
435
|
-
|
|
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.
|
|
437
|
-
|
|
438
|
-
**First: herdr copies to the clipboard of the machine running herdr, not your phone's.**
|
|
439
|
-
|
|
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.
|
|
441
|
-
|
|
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.
|
|
443
|
-
|
|
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.
|
|
445
|
-
|
|
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.
|
|
447
|
-
|
|
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.
|
|
449
|
-
|
|
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.
|
|
451
|
-
|
|
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".
|
|
453
|
-
|
|
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.
|
|
455
|
-
|
|
456
|
-
Two related notes:
|
|
457
|
-
|
|
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=`).
|
|
460
222
|
|
|
461
|
-
|
|
223
|
+
**There is no double tap.** It used to be the "show / hide the system keyboard" gesture, and it is gone — one gesture was costing the feel of every other tap. To tell "this is a tap" from "this is the first half of a double tap", every tap has to sit out the double-tap window (320ms) before it can be sent, so clicking panes and clicking things inside Claude all lag a beat; and without the wait, that first tap **leaks into the program in the pane** — Claude Code has its own clickable UI (expanding a block, **picking an option**), and a leaked tap picks the option for you. Paying "every click is imprecise and might answer for you" for one keyboard shortcut is not worth it.
|
|
462
224
|
|
|
463
|
-
The
|
|
225
|
+
The keyboard now comes from **buttons** only: the ⌨ soft key (`act:kbd` — the first key in the factory set, and the soft key bar is on by default on phones) and the "system keyboard" button in the top bar (drag it on under Settings → Top bar). A button press means you want the keyboard — nothing to guess, and no delay.
|
|
464
226
|
|
|
465
|
-
**
|
|
227
|
+
**The outbox and the soft key bar are one dock**: drag either side edge to change its width (when an
|
|
228
|
+
IME covers half the screen, shrink the whole dock into what is left), and the three handles on the
|
|
229
|
+
top edge of the key area set the height and the boundaries; double-tap any handle to reset. A phone
|
|
230
|
+
in portrait (< 440px) switches to another tier: no handles, full width, one horizontally-scrolling
|
|
231
|
+
row of keys. **Landscape and portrait keep separate sets**, swapped on rotation.
|
|
466
232
|
|
|
467
|
-
**
|
|
233
|
+
**The top bar is yours to arrange**, and **layouts are stored per kind of device**: the six keys you
|
|
234
|
+
arranged on a phone do not follow you to the desktop, while the definitions stay shared.
|
|
468
235
|
|
|
469
|
-
|
|
236
|
+
→ Why the gestures are split this way, how the keyboard is handled, copy and paste on a phone, the
|
|
237
|
+
details of the dock and the top bar: [MOBILE.md](docs/dev/MOBILE.md)
|
|
470
238
|
|
|
471
|
-
|
|
239
|
+
### Settings panel
|
|
472
240
|
|
|
473
|
-
|
|
241
|
+
The ⚙ at the right end of the top bar, in four pages: **Terminal** (font size / light-dark, kitty
|
|
242
|
+
protocol / Option as Meta / copy on select / synchronized output, herdr's switch opening our pane
|
|
243
|
+
list, the badge on the panel icon), **Top bar**, **Soft keys**, **Devices**. Above the tabs there is
|
|
244
|
+
one more row: which layout profile this device uses. The three overlays (pane list / files /
|
|
245
|
+
settings) are mutually exclusive.
|
|
474
246
|
|
|
475
|
-
|
|
247
|
+
### Keyboard
|
|
476
248
|
|
|
477
|
-
|
|
249
|
+
herdr's shortcuts are almost all `ctrl+b` plus an ordinary key, which legacy encoding can express.
|
|
250
|
+
The kitty protocol covers what legacy cannot and is on by default (Settings → Terminal):
|
|
251
|
+
`Ctrl+Shift+letter`, `Ctrl+digit`, `Ctrl+Enter` / `Shift+Enter` / `Ctrl+Tab`.
|
|
478
252
|
|
|
479
|
-
|
|
253
|
+
Keys the browser keeps for itself: on macOS `⌘W` `⌘T` `⌘N` `Ctrl+Tab`; on Windows/Linux also
|
|
254
|
+
`Ctrl+W` `Ctrl+T` `Ctrl+N` `Ctrl+Shift+I/J/C`. Installing as a PWA gets some of them back.
|
|
480
255
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
The outbox and the soft key bar are **one dock** (`web/src/components/Dock.tsx`): one border, one width, adjusted once.
|
|
486
|
-
|
|
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:
|
|
488
|
-
|
|
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.
|
|
494
|
-
|
|
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).
|
|
496
|
-
|
|
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.
|
|
501
|
-
|
|
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.
|
|
503
|
-
|
|
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.
|
|
505
|
-
|
|
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.
|
|
507
|
-
|
|
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.
|
|
509
|
-
|
|
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 |
|
|
515
|
-
|---|---|---|
|
|
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** |
|
|
526
|
-
|
|
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).
|
|
528
|
-
|
|
529
|
-
## Keyboard
|
|
530
|
-
|
|
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`.
|
|
532
|
-
|
|
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.
|
|
534
|
-
|
|
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).
|
|
536
|
-
|
|
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.
|
|
538
|
-
|
|
539
|
-
Copy `⌘C` (or `Ctrl+Shift+C`) · paste `⌘V` · clear `⌘K` · `Option` is Meta by default.
|
|
540
|
-
|
|
541
|
-
## Code layout
|
|
542
|
-
|
|
543
|
-
```
|
|
544
|
-
cmd/herdr-web/ main: flags, subcommands, listeners, startup banner, interface scoring
|
|
545
|
-
internal/
|
|
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
|
|
609
|
-
```
|
|
610
|
-
|
|
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.
|
|
612
|
-
|
|
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).
|
|
614
|
-
|
|
615
|
-
### Releasing
|
|
616
|
-
|
|
617
|
-
```bash
|
|
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
|
|
620
|
-
```
|
|
621
|
-
|
|
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.
|
|
623
|
-
|
|
624
|
-
**Release created but the npm step failed** (happened once) — rerun the same workflow to publish without recompiling:
|
|
625
|
-
|
|
626
|
-
```bash
|
|
627
|
-
gh workflow run release.yml -f tag=v0.1.0
|
|
628
|
-
```
|
|
629
|
-
|
|
630
|
-
It downloads the archives that were already published, so the re-published binaries are **byte-identical** to the ones in the Release.
|
|
631
|
-
|
|
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.
|
|
633
|
-
|
|
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.
|
|
635
|
-
|
|
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.
|
|
637
|
-
|
|
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`.
|
|
639
|
-
|
|
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.
|
|
641
|
-
|
|
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.
|
|
643
|
-
|
|
644
|
-
Three release-path traps already hit and fixed (all **silent** failures):
|
|
645
|
-
|
|
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.
|
|
649
|
-
|
|
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.
|
|
651
|
-
|
|
652
|
-
### Colours (read this before touching the UI)
|
|
653
|
-
|
|
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:
|
|
655
|
-
|
|
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.
|
|
661
|
-
|
|
662
|
-
## Traps (already handled; noted so nobody walks back into them)
|
|
663
|
-
|
|
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).
|
|
665
|
-
|
|
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".
|
|
256
|
+
Copy `⌘C` (or `Ctrl+Shift+C`) · paste `⌘V` · clear `⌘K` · `Option` is Meta by default. Copy and
|
|
257
|
+
paste on a phone is a different story — herdr copies to the clipboard of **the machine running
|
|
258
|
+
herdr** — see [MOBILE.md](docs/dev/MOBILE.md#手机上怎么复制--粘贴).
|
|
673
259
|
|
|
674
260
|
## Configuration
|
|
675
261
|
|
|
@@ -703,15 +289,15 @@ Changes take effect on restart — configuration is read once at startup. To con
|
|
|
703
289
|
|
|
704
290
|
| Variable | Default | Meaning |
|
|
705
291
|
|---|---|---|
|
|
706
|
-
| `HERDR_WEB_PORT` | `7788` |
|
|
292
|
+
| `HERDR_WEB_PORT` | `7788` | The main port. **It only serves the local network**: a connection whose peer is not loopback / private / link-local / CGNAT gets 403 and nothing else. Public access is a separate, explicit port — see `HERDR_WEB_PUBLIC_PORT` |
|
|
707
293
|
| `HERDR_WEB_HOST` | `127.0.0.1` | Listen address; `0.0.0.0` opens it to the LAN |
|
|
708
294
|
| `HERDR_WEB_TOKEN` | reads `~/.herdr-web/token` | **Legacy**; only good for bootstrapping once (exchanged for a device credential). Not generated on new installs |
|
|
709
295
|
| `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 [
|
|
296
|
+
| `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 [First run](#first-run)) — to always land in a session, bookmark the URL rather than setting this |
|
|
711
297
|
| `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
298
|
| `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
299
|
| `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
|
|
300
|
+
| `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). `~` is expanded; non-absolute entries are discarded (relative to what? keeping them only makes the prefix check pass somewhere surprising) |
|
|
715
301
|
|
|
716
302
|
### Outbox / talking to herdr
|
|
717
303
|
|
|
@@ -725,16 +311,18 @@ Changes take effect on restart — configuration is read once at startup. To con
|
|
|
725
311
|
|
|
726
312
|
### Exposure / TLS / credentials
|
|
727
313
|
|
|
728
|
-
Details in [SECURITY.md](SECURITY.md) (Chinese).
|
|
314
|
+
Details in [SECURITY.md](docs/dev/SECURITY.md) (Chinese).
|
|
729
315
|
|
|
730
316
|
| Variable | Default | Meaning |
|
|
731
317
|
|---|---|---|
|
|
732
|
-
| `
|
|
318
|
+
| `HERDR_WEB_PUBLIC_PORT` | off | **The port to expose.** Opens a second listener on `0.0.0.0:<port>` sharing the same handler, and that is where a tunnel / port forward / reverse proxy should point — never at the main port, which serves the local network only. Requests arriving here are treated as public: loopback-without-pairing and the legacy token's `loopback` tier do not apply (the source address of a tunnelled request is 127.0.0.1 too, so the only trustworthy signal is *which listener it landed on*), the rate limiter's "never block localhost" exemption is off, and TLS becomes mandatory. Why a separate port instead of a switch on the main port: a switch is a *declaration*, and declarations get forgotten — the person (or agent) writing code on this machine sees `127.0.0.1:7788` and has no way to know a tunnel is forwarding it, so every decision made under "only my machine can reach this" becomes a hole. With a separate port, forgetting to configure it means the tunnel gets connection refused |
|
|
319
|
+
| `HERDR_WEB_EXPOSED` | off | **Legacy; prefer `HERDR_WEB_PUBLIC_PORT`.** `=1` declares that *the main port itself* is reachable from the internet (frp / port forwarding / tunnels) — it cannot be detected, only declared. Once declared: TLS is mandatory, loopback-without-pairing is turned off, and the main port's "local network only" gate is lifted (you said it is public). Kept for machines already configured this way |
|
|
733
320
|
| `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
321
|
| `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
322
|
| `HERDR_WEB_ACME_EMAIL` | empty | ACME account email. Can be empty, but then you get no expiry reminders either |
|
|
736
323
|
| `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
324
|
| `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` |
|
|
325
|
+
| `HERDR_WEB_LAN_PORT` | off | Opens a **second listener** on `0.0.0.0:<port>` with a self-signed certificate whose SANs track your current LAN addresses, so a page loaded through a tunnel can probe for a direct LAN route and switch to it — two public hops per keystroke become one switch hop. One manual step per device that cannot be skipped: **open it once and click through the certificate warning**; until then the probe fails at the TLS handshake and the page quietly stays on the tunnel. It has to be TLS — an https page's fetch to an `http://` target is active mixed content and is blocked unconditionally, so a plaintext LAN port cannot be probed at all. Not needed when the main port already serves self-signed TLS on the LAN. **The direct origin holds its own credential** (cookies are host-only), so the same tablet shows up twice in the device panel — the switch carries a one-time pairing code across for you — and **passkeys do not work there**, because a WebAuthn RP ID has to be a domain and a bare IP is not one (installing the CA does not change that). Details: [DEPLOY.md](DEPLOY.md) |
|
|
738
326
|
| `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
327
|
| `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
328
|
| `HERDR_WEB_DEVICE_TTL_DAYS` | `90` | How long a device credential survives without use (renewed on every use). `0` = **never expires** |
|
|
@@ -768,10 +356,11 @@ Details in [SECURITY.md](SECURITY.md) (Chinese).
|
|
|
768
356
|
# 2. Phone / tablet on the LAN: self-signed TLS, pair by scanning the banner QR
|
|
769
357
|
HERDR_WEB_HOST=0.0.0.0 ./herdr-web
|
|
770
358
|
|
|
771
|
-
# 3. Exposed through frp / a tunnel:
|
|
772
|
-
#
|
|
773
|
-
# PUBLIC_URL decides which address the
|
|
774
|
-
|
|
359
|
+
# 3. Exposed through frp / a tunnel: point the tunnel at PUBLIC_PORT, never at the
|
|
360
|
+
# main port — the main port only serves the local network, and every default on
|
|
361
|
+
# it assumes the internet cannot reach it. PUBLIC_URL decides which address the
|
|
362
|
+
# QR code encodes
|
|
363
|
+
HERDR_WEB_PUBLIC_PORT=17788 HERDR_WEB_TLS=proxy \
|
|
775
364
|
HERDR_WEB_PUBLIC_URL=https://herdr.example.com \
|
|
776
365
|
HERDR_WEB_HOSTNAME=herdr.example.com ./herdr-web
|
|
777
366
|
|
|
@@ -804,7 +393,9 @@ herdr-web service install --env-file .env
|
|
|
804
393
|
|
|
805
394
|
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.
|
|
806
395
|
|
|
807
|
-
**
|
|
396
|
+
**DNS provider credentials carry the `HERDR_WEB_` prefix too** (`HERDR_WEB_CLOUDFLARE_DNS_API_TOKEN`, `HERDR_WEB_ALICLOUD_ACCESS_KEY` and friends), so the rule above already copies them — exporting them in your shell is enough, no `--env-file` required. The prefix is not cosmetic: a bare `CLOUDFLARE_DNS_API_TOKEN` matches neither the prefix nor the allowlist, so it is not copied, and that failure only surfaces at the first issuance (or three months later, at the first renewal). lego still reads the bare names, but those can only reach the service through `--env-file`; when both are set, the prefixed one wins. Per-provider variable names are in [DNS.md](DNS.md).
|
|
397
|
+
|
|
398
|
+
Keys in `--env-file` go in **wholesale** (and override the current environment). The file is read at `install` time only and never touched again. In the list `install` prints, credentials show up as asterisks and a length — that output often lands in a pane with an agent in it.
|
|
808
399
|
|
|
809
400
|
The plist / unit is **0600** — its contents are exactly that environment in plaintext.
|
|
810
401
|
|
|
@@ -859,48 +450,45 @@ Checks go to GitHub Releases' anonymous API, with results cached in `~/.herdr-we
|
|
|
859
450
|
|
|
860
451
|
## Security
|
|
861
452
|
|
|
862
|
-
**This thing amounts to a shell over HTTP** (the outbox alone can make an agent run commands, even
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
- **
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
- **
|
|
870
|
-
|
|
871
|
-
- **
|
|
872
|
-
|
|
873
|
-
- **
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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.
|
|
905
|
-
|
|
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.
|
|
453
|
+
**This thing amounts to a shell over HTTP** (the outbox alone can make an agent run commands, even
|
|
454
|
+
without a PTY), so the door is designed on that premise. What is implemented:
|
|
455
|
+
|
|
456
|
+
- **Pair each device once.** A one-time code is exchanged for a per-device credential in an
|
|
457
|
+
`HttpOnly; SameSite=Strict` cookie; the server **stores only sha256** — the agents on this machine
|
|
458
|
+
read untrusted content all day, so the credential file being read by prompt injection is a daily
|
|
459
|
+
risk, not a theoretical one.
|
|
460
|
+
- **Credentials bind to a device, not an IP.** Changing networks costs nothing; trusting an IP loses
|
|
461
|
+
both ways.
|
|
462
|
+
- **No secrets in URLs.** `?pair=` is exchanged for a cookie and scrubbed with a 302, so bookmark
|
|
463
|
+
sync and screenshots stop being leak channels.
|
|
464
|
+
- **Revocable.** `herdr-web revoke`, or Settings → Devices; the next request gets 401.
|
|
465
|
+
- **Only someone at the machine can produce a pairing code** — no path on the web issues one. That
|
|
466
|
+
terminal is the only out-of-band factor in the system.
|
|
467
|
+
- **Refuses to start when exposed without TLS.** A Host allowlist blocks DNS rebinding; Origin +
|
|
468
|
+
`SameSite=Strict` + a custom header make three layers against CSRF; guessing a pairing code gets
|
|
469
|
+
exponential backoff, per-IP lockout and a global breaker.
|
|
470
|
+
- **Passkeys are the second factor** (the server stores only the public key). With one registered,
|
|
471
|
+
moving to a new device does not require going back to the machine, and session credential lifetime
|
|
472
|
+
can drop from three months to one day.
|
|
473
|
+
|
|
474
|
+
→ Threat model, the reasoning behind each choice, what is not built yet: [SECURITY.md](docs/dev/SECURITY.md)
|
|
475
|
+
Reaching it from the internet (frp / tunnels) and the four TLS tiers: [DEPLOY.md](DEPLOY.md)
|
|
476
|
+
|
|
477
|
+
## Documents
|
|
478
|
+
|
|
479
|
+
Everything below is in Chinese — that is where the "why" lives.
|
|
480
|
+
|
|
481
|
+
**This file, plus DEPLOY / DNS, is the user documentation.** The first five below live in [`docs/dev/`](docs/dev/README.md) — that layer is *why it is built this way*: design rationale, hand-verified semantics, and the traps that fail silently.
|
|
482
|
+
|
|
483
|
+
| What you want | Where |
|
|
484
|
+
|---|---|
|
|
485
|
+
| Outbox: why a separate box, how images work, measured polling latency | [OUTBOX.md](docs/dev/OUTBOX.md) |
|
|
486
|
+
| Reading the screen: scraping the input line, scraping what the agent said | [COMPOSER.md](docs/dev/COMPOSER.md) |
|
|
487
|
+
| herdr socket API semantics, verified by hand | [HERDR-API.md](docs/dev/HERDR-API.md) |
|
|
488
|
+
| The whole phone / tablet layer (gestures, keyboard, dock, top bar, notices, clipboard) | [MOBILE.md](docs/dev/MOBILE.md) |
|
|
489
|
+
| Security design and threat model; the rules on the file-serving route | [SECURITY.md](docs/dev/SECURITY.md) |
|
|
490
|
+
| Where to run it, public access, TLS tiers | [DEPLOY.md](DEPLOY.md) |
|
|
491
|
+
| Getting a DNS token from each provider and the scope it needs | [DNS.md](DNS.md) |
|
|
492
|
+
| Read before changing code (layout, releasing, colours, the silent traps) | [CLAUDE.md](CLAUDE.md) |
|
|
493
|
+
|
|
494
|
+
MIT.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bysir/herdr-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "浏览器里的 herdr 终端 + 语音投稿。一个 Go 二进制,前端嵌在里面。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/zbysir/herdr-web#readme",
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
"node": ">=18"
|
|
30
30
|
},
|
|
31
31
|
"optionalDependencies": {
|
|
32
|
-
"@bysir/herdr-web-darwin-arm64": "0.
|
|
33
|
-
"@bysir/herdr-web-darwin-x64": "0.
|
|
34
|
-
"@bysir/herdr-web-linux-arm64": "0.
|
|
35
|
-
"@bysir/herdr-web-linux-x64": "0.
|
|
32
|
+
"@bysir/herdr-web-darwin-arm64": "0.5.0",
|
|
33
|
+
"@bysir/herdr-web-darwin-x64": "0.5.0",
|
|
34
|
+
"@bysir/herdr-web-linux-arm64": "0.5.0",
|
|
35
|
+
"@bysir/herdr-web-linux-x64": "0.5.0"
|
|
36
36
|
}
|
|
37
37
|
}
|