fix(sidebar): a finished pane reads ready once you view it #10

Closed
gabrielgad wants to merge 40 commits from fix/done-reads-idle-when-opened into main
Owner

Change

The browser sends a viewing frame when a pane is visible and focused; only then does a done pane read idle. Attaching alone does not count. herdr's own state is untouched. Closes #2.

Validation

bun run check fast passes. A contract test covers attach alone versus viewing, and viewed-panes.test.ts the status rewrite.

## Change The browser sends a `viewing` frame when a pane is visible and focused; only then does a done pane read idle. Attaching alone does not count. herdr's own state is untouched. Closes #2. ## Validation `bun run check fast` passes. A contract test covers attach alone versus viewing, and `viewed-panes.test.ts` the status rewrite.
fix(sidebar): a finished pane reads ready once you view it
Some checks failed
CI / Native Windows install (pull_request) Has been cancelled
CI / Fast checks (pull_request) Has been cancelled
CI / Integration and browser (pull_request) Has been cancelled
Remote PC bundles / bundle (macos-15) (pull_request) Has been cancelled
Remote PC bundles / bundle (macos-15-intel) (pull_request) Has been cancelled
Remote PC bundles / bundle (ubuntu-24.04) (pull_request) Has been cancelled
Remote PC bundles / bundle (ubuntu-24.04-arm) (pull_request) Has been cancelled
Remote PC bundles / bundle-windows (pull_request) Has been cancelled
Remote PC bundles / ssh (pull_request) Has been cancelled
Remote PC bundles / smoke-windows (pull_request) Has been cancelled
Remote PC bundles / release (pull_request) Has been cancelled
eb3e7bd0d6
The browser now tells the bridge when a pane is visible and focused; only then does done read ready. Attaching alone does not count.
Author
Owner

Finished pane not viewed: Done (green dot). After it is opened: before (left) still Done, after (right) Ready.

pr10-done-not-viewed

pr10-opened-before-after

Finished pane not viewed: Done (green dot). After it is opened: before (left) still Done, after (right) Ready. ![pr10-done-not-viewed](https://git.gabeforge.com/attachments/916a36d2-24cc-44dd-9301-1715c0de5cfa) ![pr10-opened-before-after](https://git.gabeforge.com/attachments/0fa658b1-95fb-44aa-be08-b0eea9e6a1bd)
Author
Owner

Video: a Done pane turns Ready once it is opened. Video

Video: a Done pane turns Ready once it is opened. [Video](https://git.gabeforge.com/attachments/6fbea321-8f75-4772-b6df-4d2360e0d361)
## Change

When the terminal viewport is scrolled back to an old password prompt or
collapsed Codex question queue, input validation currently treats that
historical screen as the active prompt. A secret can be typed into the
current shell, or a follow-up can bypass a current Codex approval.

Read the underlying live screen (`source: "detection"`) for both guards
before sending input. The existing authorization, attachment and
serialization checks stay in place. No HTTP or WebSocket shapes change.

## Validation

- Two isolated fake-RPC reproductions fail on v0.4.0: the stale viewport
causes secret text/Enter or Codex follow-up text/Enter to be sent. With
the fix both reject and send no input.
- Four contract regressions cover stale viewport rejection and valid
live prompts while the viewport is elsewhere, using owned test panes.
- Typecheck passes; full local integration/browser verification is in
progress.
- Independent code review found no blocking concerns.

---------

Co-authored-by: e2e <e2e@example.com>
## Change

`scripts/terminal-copy-regression.ts` raced the page in its drag steps
(#551).

A press in the desktop terminal asks herdr where the pane's viewport
sits (`GET /api/pane/scroll`). A release that comes before that answer
has no history row to map the selection to. It still copies the visible
text with the native copy (`PaneTerminal.tsx` `copySelection`,
`document.execCommand("copy")`), shows "copied to clipboard", and
returns before it reserves the async `navigator.clipboard.write`
(`onMouseUp`, `if (!range) return`). The script's recorder counts only
`navigator.clipboard.write`/`writeText`, so under load (a slow scroll
answer) `clipboardGesture.writes` stayed `[]` and the assertion at line
86 failed. The clipboard and the banner were already right. This matches
the CI failure in #551.

The product behaves as intended: copying the visible text when the
position is not known yet is the documented fallback. So the fix is in
the test. Each press now waits for its own scroll position instead of
racing it: the init script counts the `/pane/scroll` reads the page
asked for and the ones still open, and a press waits until its read was
asked for and none is open, so an answer to an earlier press cannot
stand in for it. The three steps that guessed with `waitForTimeout(200)`
("the viewport position arrives") use the same wait.

Fixes #551

## Validation

On a herdr of the run's own (`bun run check run`), with a scratch runner
that runs `checkTerminalCopy` alone and can hold `GET /api/pane/scroll`
(not committed):

- Before, unchanged script: 3 of 3 normal runs passed; with the scroll
answer held 600 ms, 1 of 1 failed with the #551 assertion (`drag copy
must begin inside mouseup`, `+ []`).
- After: with the scroll answer held 600 ms, 3 of 3 passed; 5 of 5
normal runs passed. A first draft that counted any scroll response
failed 3 of 3 under the hold at a later step (`first selection response
held`), because a double-click's late answer released the next press;
counting asked and open reads fixed that.
- `bun run check fast`: passed (1699 pass, 4 skip, 0 fail), run before
the final wait change; it does not cover `scripts/`, which the runs
above exercise.

No CHANGELOG entry: the change is test-only and nothing a user notices.

Co-authored-by: e2e <e2e@example.com>
## Change

`tailscale status --json` writes user ids larger than
`Number.MAX_SAFE_INTEGER` (the report in #526 has `15633668397603135`).
`JSON.parse` rounds them, so two lookups in `server/tailscale.ts` went
wrong:

- `parseTailscaleOwner` looked up `User[String(Self.UserID)]` with the
rounded id, found nothing, and the owner was never recognised. A request
through `tailscale serve` with the owner's login was not granted, so the
owner's own devices had to pair.
- `readSoleLogin` (the serve-only owner grant from #525) compared
rounded ids. Two logins whose ids round to the same double counted as
one login, and the lookup could then name the other login. The new test
shows this on main: it answers `them@example.com` for a tailnet that two
logins share.

The fix reads the status with every `UserID` kept as the digits written
(`parseStatus`), and both functions use that. Nothing else reads
`UserID`.

Fixes #526

## Validation

- `HERDR_TEST_MODE=unit bun test ./server/devices.test.ts
./server/tailscale.test.ts`. On main, the new cases fail three times:
owner `null` instead of the login, the sole login answered as
`them@example.com`, and a second login with a neighbouring id taken for
the owner. With the fix, all 30 tests pass.
- This PC's own `tailscale status --json` goes through both versions
with the owner found. Its id is below 2^53, so it cannot show the bug,
but it shows that a real status still parses.
- `bun run check fast` passed.

---------

Co-authored-by: e2e <e2e@example.com>
## Problem

A Claude Code pane launched on Windows with a custom `CLAUDE_CONFIG_DIR`
stores its native session and transcript outside `~/.claude`.

The existing custom-store resolution introduced in #504 discovers the
running Claude process's config directory on Linux and macOS, but
Windows has no equivalent path. As a result, the pane falls back to
`~/.claude`, the transcript is not found, and Chat shows `Conversation
unavailable` while Terminal still works.

Example layout:

```text
~/.claude            default account (herdr's SessionStart hook installed)
~/.claude-account2   second account, started with CLAUDE_CONFIG_DIR=%USERPROFILE%\.claude-account2
```

## Root cause

Measured on Windows 10 with herdr 0.9.3 and Claude Code 2.1.292. Three
gaps stack up:

1. **The pane's Claude process is never found.** herdr's
`pane.process_info` reports a Windows Claude as `name: "claude.exe"`
with a backslash `argv0` (`C:\…\.local\bin\claude.exe`). The filter in
`claudeTranscriptPath` only matched `claude` or `/claude`, so `only`
stayed undefined. Neither the config-dir lookup nor the PID-record
lookup ran.
2. **`processClaudeConfigDir` has no Windows branch.** It reads
`/proc/<pid>/environ` on Linux and `ps -E` on macOS. Windows lets no
other process read Claude's environment.
3. **`claudeProcessSession` returns `null` on anything but Linux and
macOS.** A store without herdr's hook (the usual case for a second
account) has no session id, so the pane ends at `no_session_id`.

## Change

- `isClaudeProcess` (claude-store.ts) also accepts `claude.exe` and
backslash paths. It replaces the inline filter in conversation.ts; Linux
and macOS matches are unchanged.
- On Windows, `processClaudeConfigDir` uses the store that holds the
process's own live `sessions/<pid>.json`. It looks in `~/.claude`, the
server's `CLAUDE_CONFIG_DIR` and each `~/.claude-*` directory (one
`readdir` of home, nothing recursive; the same sibling set `usage.ts`
already knows). It answers only when exactly one store has a valid
record, and otherwise falls back to the default store as before.
- `claudeProcessSession` runs on Windows, with the same record checks
(size bound, `pid`, `kind: "interactive"`, UUID `sessionId`). The
stale-PID check is kept. On Windows Claude writes `procStart` as a
FILETIME (100 ns ticks since 1601). That is compared, to the
millisecond, with the start the existing Windows process table reports
(`recentProcessTable`, shared with gjc and cached for 5 s), so a
leftover record from a reused PID is refused. A table that cannot be
read refuses the record.
- A Windows miss is not cached, because Claude writes its record as it
starts. A found store is cached for `PROCESS_DIR_TTL_MS`, as on Linux
and macOS.
- Linux and macOS behavior is unchanged. `platform` and `table` are
injectable parameters with defaults (as in `gjcPidUnderShell`), so the
Windows branch is unit-tested on any OS.
- Nothing new reaches the browser, no credential file is opened (only
`sessions/<pid>.json`), and there are no new dependencies.

## Validation

**Live, on Windows** (`paneConversation` against real herdr panes,
server env as `plugin.ps1` sets it):

| pane store | `main` | this branch |
|---|---|---|
| `~/.claude` (hooked), 2 panes | `claude-transcript` |
`claude-transcript` (same turns) |
| `~/.claude-*` (no hook), 4 panes | `no_session_id` → Conversation
unavailable | `claude-transcript` |

The Chat view was also checked in the browser on that PC, against a
server from this branch on a separate port with a temporary state dir: a
second-store Claude pane shows its conversation, where the installed
`main` build shows **Conversation unavailable**.

**Linux** (Ubuntu 24.04 under WSL, Bun 1.4.2, clean clones of `main` and
of this branch):

- `bun run generate:types --check`, `bun run typecheck` and `bun run
build` pass on both.
- `HERDR_TEST_MODE=unit bun test ./server/claude-store.test.ts`: 19/19
on `main`, 33/33 here.
- `bun run test:unit`: 1680 pass here. The only failures on either tree
are in `scripts/install-star.test.ts` ("install.sh at a terminal"),
which is flaky the same way on both: rerun twice, it went 22/22, then
21/22, on `main` and on this branch alike. It is unrelated to this
change.

**Windows** (Bun 1.4.2): `claude-store.test.ts` has 22 passing, and the
one failure (`chmod` permissions) fails identically on `main`.
`windows-native.test.ts` has 4/4 passing, including the new real-PC
test. `conversation.test.ts` has 48/48 passing.

**Tests added:**

- `server/claude-store.test.ts`, "a Windows Claude's store" (runs on any
OS, with a temp home whose path contains spaces, the server's
`CLAUDE_CONFIG_DIR` cleared, and the process table handed in):
- herdr's Windows process shape is recognized as Claude, and look-alikes
are not.
  - A record in `~/.claude` keeps the default store.
- A record in `~/.claude-*` selects that store, and with the same
session id and project in both stores, the transcript comes from the
pane's own store.
  - A stale record of the same PID in `~/.claude` loses to the live one.
- These records are ignored: another PID, a reused PID (1 ms off),
macOS-style `procStart`, no `procStart`, `kind: "sdk"`, a non-UUID
session id, torn JSON, an oversized record, and a process missing from
the table.
- `~/.claude-*` entries that are files, empty directories, or stores
without a record are harmless, and so is a missing home.
  - Two stores both claiming the process select none.
  - A miss is looked up again once the record appears.
- `server/windows-native.test.ts` (real Windows only, like the existing
tests there): a record carrying this process's real FILETIME start, from
PowerShell, is accepted and selects its `~/.claude-*` store. A record 1
ms off is refused.

Existing Linux and macOS tests in `claude-store.test.ts` and
`claude-session.contract.test.ts` are unchanged.

**Notes**

- `O_NOFOLLOW` and `O_NONBLOCK` do not exist on Windows (Node and Bun
leave them `undefined`), so the record read there has no "no symlink"
guard. The record lives in the user's own store, and creating a symlink
on Windows needs privilege or Developer Mode.
- This fixes the Windows path only. Panes that herdr reports with a
Claude foreground process were verified; I did not add a process-table
fallback for a Windows herdr that would list only the shell.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Hako <devswha@gmail.com>
<!-- See CONTRIBUTING.md for checks, translations, contract tests and
the changelog. -->

## Change

`parseInline` in `src/lib/markdown.ts` took quadratic time on one long
line of openers that never close: `[a`, `\(`, `_a ` or `__a ` repeated.
The chat parses every agent message with it while it draws, so a single
such line (a log, a minified blob) froze the tab: about a minute or more
per megabyte. It stayed under the 1 s bound the tests use at 48 KB, so
nothing failed. #547 hardened this file against other shapes and left
this one.

The cause was the single pattern searched over the whole line: from
every opener it scans to the end of the line for a closer, fails, and
starts again at the next opener.

Now the pattern is tried (sticky) only at places a mark can start. For
the three openers whose closer may be far away, it is tried only when a
closer is there, and each closer is searched for once and reused for
every opener before it:

- `\(` needs a `\)` before the next line break.
- `[` needs `](` at the first `]`, and a `)` ending the address.
- `__` and `_` need their closing `__` or `_` on the line. A `__` that
only a `_` can close is tried as the single-underscore form alone.

The pattern itself is unchanged, so the marks found are the same. While
measuring I found two more quadratic shapes with the same cause, `[a](b`
and `__a b_ ` repeated; the same change covers them.

The change is in `src/lib/markdown.ts` only (a new exported generator
`inlineMarks` that `parseInline` loops over; the loop body is
untouched).

## Validation

Time to parse one line, `parseMarkdown`, main (f8f09d1) against this
branch:

| Line | Bun, main, 192 KB | Bun, branch, 1 MB | Chrome 151, main, 192
KB | Chrome 151, branch, 1 MB |
| --- | --- | --- | --- | --- |
| `[a` repeated | 4003 ms | 41 ms | 9374 ms | 54 ms |
| `\(` repeated | 8069 ms | 25 ms | 4027 ms | 20 ms |
| `_a ` repeated | 4936 ms | 19 ms | 3592 ms | 20 ms |
| `__a ` repeated | 8952 ms | 35 ms | 9159 ms | 29 ms |
| `[a](b` repeated | 1603 ms | 31 ms | 5063 ms | 27 ms |
| `__a b_ ` repeated | 2140 ms | 91 ms | 1696 ms | 90 ms |

Main was not timed at 1 MB for every shape: `[a` at 1 MB took 111 s
there, in the new test. On the branch 8 MB takes 138 to 744 ms in Bun,
so the growth is linear.

- New test in `src/lib/markdown.test.ts`: each of the four shapes at 1
MB parses within the file's 1 s bound and stays plain text, the two
extra shapes parse within it, and for 66,430 short lines `inlineMarks`
finds exactly what one search with the pattern finds. Run against main's
parser it fails (`Expected: < 1000, Received: 111111`); with a closer
looked for one place too far, the comparison fails.
- Output compared with main's parser outside the suite: 8,012,554 inline
inputs (exhaustive short lines over six alphabets, plus random lines
with links, URLs, code, CJK, emoji and line separators, each with and
without links) and 60,000 random documents through `parseMarkdown`. No
difference.
- `HERDR_TEST_MODE=unit bun test ./src/lib/markdown.test.ts`: 41 pass, 0
fail.
- `bun run check fast`: generated types fresh, typecheck, build, unit
tests 1700 pass, 0 fail.

No UI or contract change, so no screenshot or contract test.

---------

Co-authored-by: e2e <e2e@example.com>
## Change

The chat history browser regression uses English text selectors but let
Chromium inherit the PC's language. On a Korean Mac it rendered the
empty-chat message in Korean and timed out even though the history was
correctly empty.

Create this fixture's browser page with `locale: "en-US"`, matching its
selectors and keeping the product's language setting unchanged.

## Validation

- Before: Chromium reported `ko-KR`, the empty state was Korean, and the
English locator timed out with zero chat turns.
- After: the entire standalone chat history harness passes on Bun 1.4.2,
including history clearing, stale requests and product pane transitions.
- Full local QA tree passed the entire browser lane, including this
formerly failing step, along with 1,673 unit and 278 integration tests
on Bun 1.4.2.

Co-authored-by: e2e <e2e@example.com>
## What

On a mirrored pane (Windows x64: herdr cannot `terminal attach`, so
`server/mirror.ts` mirrors the screen), text typed while a message is
still being sent no longer reaches the pane when the sender leaves it,
or leaves and opens it again, before the text's turn comes. The sender
gets `input_failed`, the same answer queued keys and queued pty typing
give.

## Why

The mirror branch of the WS `input` handler in `server/index.ts` waits
its turn in `serialize(...)` and then calls `paneSendText`, checking
only that the connection is still open. Unlike the pty branch and the
`keys` handler, it never kept the sender's claim
(`client.data.attached`) or the attachment it typed into, so a detach,
or a detach followed by a reattach, did not cancel it.

## How

Before queueing, keep `origin` (the attachment the sender is a member
of, if any) and `claim`. Inside the queued step, after the asynchronous
paste shaping, answer `input_failed` and send nothing when the claim
changed, or when `origin` was replaced, closed, or no longer holds the
sender. Typing from a connection that never attached keeps its existing
path (both values stay `undefined`). No wire shape changes, so
`shared/protocol.ts`, the demo transport and the remote bundle are
untouched.

## How verified

- New contract case in `server/submit.contract.test.ts` (`typing without
terminal attach, at the edges`): a server with `terminalAttach: false`
and `submitDelayMs: 1000` holds the pane's turn with a submit, then the
test sends `input` and `detach` (second round: `detach` + `attach`).
  - On f8f09d1 it fails: the recorder receives `one\rtypedtwo\r`.
  - With the fix it passes, and the sender gets `input_failed`.
- Mutation: with the whole new guard disabled, the case fails again with
`typedtwo`. With only the claim half disabled it stays green, because
the attachment check covers both rounds on its own.
- `bun run check run bun test ./server/submit.contract.test.ts`: 22
pass, 0 fail.
- `bun run check fast`: workflow syntax, generated types, typecheck,
build, and unit tests all pass (1699 pass, 4 platform skips, 0 fail).

Fixes #546
## Change

CI's apt calls sat on an Ubuntu mirror until the job's own timeout.
Twice today the browser lane hung in Playwright's `install --with-deps`
(`scripts/ci-browser.sh:6`, which runs apt). The last line was `Get:6
https://archive.ubuntu.com/ubuntu noble-security InRelease [126 kB]` at
20:07:07, and the job was cancelled at its 20-minute limit at 20:26 (job
112991466393). The Website build (`pages.yml`, `apt-get install ffmpeg`)
has sat in its apt step the same way (run 37674971343, still in progress
after more than an hour).

The first version of this PR wrote an `apt.conf.d` drop-in
(`Acquire::Retries "5"`, `Acquire::http(s)::Timeout "30"`). Measured in
`ubuntu:24.04`, it would not have stopped this hang. apt's own timers
already give up on a mirror that accepts and never answers:

| stalled mirror (local) | without drop-in | with drop-in |
|---|---|---|
| http, no response | gives up after 247 s | 391 s |
| https, TLS never completes | gives up after 127 s | 211 s |

30 s is apt's default timeout, so the drop-in only added attempts. The
CI hang outlived apt's timers (a trickle, or a stall they do not see),
so no apt setting bounds it.

So each apt step now gets a wall-clock limit instead.
`scripts/ci-bounded-retry.sh <seconds> <command…>` runs the command
under `timeout --kill-after=15`. An attempt that fails or runs past the
limit is ended together with everything it started (`timeout` signals
its whole process group), and the command runs again, three times at
most:

- `ci-browser.sh` (CI only): Playwright's `install-deps chromium` (the
apt part) gets 300 s. Then `install chromium` downloads the browser as
before. In a passing run the whole `--with-deps` install takes about 3.5
min.
- `pages.yml`: the ffmpeg `apt-get update && install` gets 600 s (about
5 min in a passing run).
- `remote-bundles.yml` (`ssh` job): the openssh-server install gets 300
s.

Packages a cut-off attempt already downloaded stay in apt's cache, so
the next attempt continues from there.

## Validation

- Helper: a command that succeeds exits 0 at once. One that always hangs
is ended 3 times and exits 1, and a grandchild it started is gone. One
that hangs once and then succeeds exits 0 on attempt 2.
- The new `pages.yml` step, verbatim, in `ubuntu:24.04` on this PC's
slow mirror: attempt 1 ran past 600 s and was cut off after 146 packages
were downloaded. Attempt 2 finished, `ffmpeg -version` works, and `dpkg
--audit` is clean.
- `apt-config dump` checked the earlier drop-in, which is gone now.
- CI on this PR: Fast checks and ssh (the bounded openssh-server step)
passed. Integration and browser runs `ci-browser.sh` with the bounded
`install-deps`.

No CHANGELOG entry: CI only.
## Change

`scripts/ui-regression.ts` raced the page in the "sent before the
snapshot" step and failed about one run in five with `Timed out: the
message sent before the snapshot is a live pending row` (CI runs
37631750944, 37675014452).

The step closes the routed socket, holds the reconnect's `snapshot`,
waits for `pendingReady(pendingPane)`, sends, then releases the
snapshot. `pendingReady` read `inputStates`, which was keyed by pane
only. The server sends its snapshot as soon as the socket opens, and
that can be before the page has sent its `attach`. In that order the
held-snapshot wait finished while `inputStates` still held the
**closed** connection's last `input-ready`, so `pendingReady` resolved
at once. The message then reached the bridge before the new attachment
was ready. The server answered `input_not_ready`, and the composer kept
the draft with "Not sent: The pane is not attached and ready". So no
pending row appeared.

An instrumented copy of the script (frame log, not committed) caught the
failing order:

```
85771 route: snapshot held
85771 pendingReady resolved {"ready":true,...}      <- the old connection's state
85772 ws#2 -> attach
85807 route: snapshot released
85809 ws#2 -> submit (delivery: queue)
85820 ws#2 <- submit-result ok:false input_not_ready
86051 ws#2 <- input-ready                          <- the new connection is ready only here
```

In passing runs, `attach` goes out before the snapshot is held, and
`pendingReady` waits for the new connection's `input-ready`.

The product behaves as intended: a send before the pane's attachment is
ready is refused and the draft stays (never queued while offline). So
the fix is in the test. Each input state now records the Playwright
WebSocket that reported it, and a pane counts as ready only on the
page's current, open connection. After the close, the step also waits
for the page to open its new connection. There are no new timeouts or
sleeps.

## Validation

On a herdr of the run's own (`bun run check run bun
scripts/ui-regression.ts`):

- Before (main at 407c1a1): 2 of 14 normal runs failed with this exact
assertion (1 of 6 plain, 1 of 8 with the frame log above). Widened race,
3 of 3 failed with the same assertion. The widening is a scratch copy,
not committed: in the page, the reconnect's `open` handler (which sends
the attach) is stalled 300 ms, and the route holds that connection's
frames to the server, attach first and in order, for 1 s. That is the
failing order above, made certain.
- After (2868996): the same widened race, 3 of 3 passed; 10 of 10 normal
runs passed.
- `bun run check fast`: passed (1699 pass, 4 skip, 0 fail). It does not
cover `scripts/`, which the runs above exercise.
- CI on this PR: Fast checks and Integration and browser passed.

No CHANGELOG entry: the change is test-only.
## Change

`scripts/ssh-integration.ts` (the Remote PC bundles workflow's `ssh`
job) failed on #575's docs-only head (run 37683049615):

```
setup failed Connection failed
AssertionError: ssh: connect to host 127.0.0.1 port 41579: Connection refused
  at scripts/ssh-integration.ts:110
```

`startSshd()` spawned sshd and then slept a fixed 300 ms before the
first setup used it. On a loaded runner sshd was not listening yet.

It now waits until a TCP connect to `127.0.0.1:<port>` succeeds, polling
every 100 ms with a 15 s deadline. The deadline fails with the content
of `sshd.log`, and an sshd that exits still fails at once with its log,
as before. The restart after the host-key change uses the same function.
`stopSshd()` already waits for the old daemon to exit, so only the new
one can answer the poll.

## Validation

On this PC (`bun run check run bun scripts/ssh-integration.ts`, key
mode), with scratch copies (not committed) whose sshd starts 1 s late
(`sh -c 'sleep 1; exec /usr/sbin/sshd …'`), standing in for a slow start
on a loaded runner:

- Before (main): 2 of 2 failed at the first setup with the CI error,
`AssertionError: ssh: connect to host 127.0.0.1 port N: Connection
refused`.
- After: 2 of 2 passed, the restart after the host-key change included.
3 of 3 normal runs passed.
- CI on this PR: Fast checks, Integration and browser and ssh (password
mode) passed.

No CHANGELOG entry: CI only.
## What

`plugin start` keeps the app on its port when the app's own server holds
that port but cannot reach herdr. It reports that state and exits 1
instead of moving the app to another port.

## Why

Since #415, `settlePort()` waits up to 20 s while the recorded pid is
alive, then falls through to `freePort` (#428). When the server that
holds the port is ours and only herdr is away, that move starts a second
launcher on a fallback port, rewrites `plugin-port` and the pid file,
and blames "another program". The supervisor lock is per port, so
nothing stops the second launcher, and a later `stop` stops only that
one.

## How

After the wait, and before it picks another port, `start` asks
`/api/health?scope=bridge`. That endpoint answers without herdr. If the
app answers there, the port is ours: when full health has meanwhile come
back, start reports "already running" as before. Otherwise it fails with
the address, the recorded pid and the error the app's `/api/health`
gives (herdr's `connect_failed`), and leaves `plugin-port` and
`server.pid` alone. A port held by anything else is handled as before.
The wait stays bounded by the existing deadline.

This is option 2 from the issue, with option 1's outcome: no move, a
clear message, and the kept port untouched.

## How verified

- New case in `scripts/plugin.test.ts` (#428) reproduces the exact
setup: the kept port is held by a fake app whose bridge health answers
`ok` and whose full health answers 502 `connect_failed`, and the
recorded pid is alive. On `origin/main` it fails with `Using port 17317
instead`. With the fix it passes, and the whole file passes (12/12).
Disabling the new check (`if (false && await appOnPort())`) turns it red
again.
- End to end, isolated: an own herdr session under a scratch
`XDG_CONFIG_HOME`, scratch HOME and plugin state/config dirs, and
`HOST=127.0.0.2`. I ran the issue's steps:
  1. `start` listened at `127.0.0.2:7317`, and health answered 200.
2. I stopped herdr: `/api/health` answered 502, and `?scope=bridge`
answered 200.
3. `start` exited 1 after 20 s with `herdr web ui is running at
http://127.0.0.2:7317 (pid …) but cannot reach herdr: cannot reach herdr
at …/herdr.sock: Failed to connect. It keeps this port and answers again
once herdr is back.` No `plugin-port` file was written, the pid file
still named the first launcher, and only 7317 was listening.
  4. I started herdr again, and 7317 answered 200.
  5. `stop` stopped the first launcher, and no listener was left.
- `bun run check fast` passes locally.

Not changed: the older case in the issue's "Related" section
(`server.pid` naming a child that died on the supervisor lock) is left
as it was. With this fix, a start that finds the app on the port without
a live recorded pid also keeps the port at once instead of moving.

Fixes #428
Part of #518: this fixes its first two points (the `\\?\` prefix on
rollout paths and on `threads.cwd`). The third point, a pane resumed
through the `codex resume` picker, is left for a separate PR, so this
does not close the issue.

## Change

On Windows, Codex stores its paths in `state_5.sqlite` with the `\\?\`
prefix, as Rust's canonical Windows paths carry it. herdr reports the
plain form. Measured on Windows 10 with herdr 0.9.3 and codex-cli
0.160.1, against a real store: 504 of 505 `threads` rows have a prefixed
`cwd`, and 13 have a prefixed `rollout_path`.

That breaks both ways the server finds a Codex pane's rollout:

1. **The thread herdr names for the pane.** Its `rollout_path` is
`\\?\C:\Users\…\.codex\sessions\…\rollout-….jsonl`. `realpathSync` keeps
the prefix, so `relative()` against the store's
`C:\Users\…\.codex\sessions` puts the file outside the store, and
`codexRolloutPath` refuses it.
2. **The threads of the pane's cwd.** herdr reports `D:\work` and the
row says `\\?\D:\work`, so `WHERE cwd = ?` finds none of them.

The pane then ends at `no_session_path` and Chat shows **Conversation
unavailable**.

The fix, in `server/codex.ts`:

- `withoutVerbatimPrefix` turns `\\?\D:\x` into `D:\x` and
`\\?\UNC\host\share` into `\\host\share`. Any other path is returned
unchanged.
- `codexRolloutPath` strips the prefix before `realpathSync`, then
applies the same checks as before: canonical path, inside
`<codexHome>/sessions`, a `.jsonl` file, and not a subagent's rollout. A
prefixed path outside the store is still refused.
- The two cwd queries (`newerThreads` and the candidate list in
`codexTranscriptPath`) match `cwd IN (?, ?)` with both stored forms from
`storedCwds`. A POSIX cwd is passed twice unchanged, so on Linux and
macOS the queries return the same rows as before.

Nothing new reaches the browser, there is no contract change and no new
dependency.

## Validation

**Live, on Windows** (a real Codex pane in herdr, server environment as
`plugin.ps1` sets it). The pane was started as `codex resume <id>`, and
herdr reports that thread as its `agent_session`:

| | `main` | this branch |
|---|---|---|
| `paneConversation` | `no_session_path` → Conversation unavailable |
`codex-transcript`, 45 turns |
| interactive threads found for the pane's cwd | 0 | 3 |

**Linux** (Ubuntu 24.04 under WSL, Bun 1.4.2, Node 24, a clean clone of
this branch):

- `bun run generate:types --check`, `bun run typecheck` and `bun run
build` pass.
- `bun run test:unit`: 1717 pass, 6 skip, 0 fail.
- `HERDR_TEST_MODE=unit bun test` on `codex.test.ts`,
`codex-processes.test.ts`, `conversation.test.ts`,
`windows-native.test.ts` and `release-notes.test.ts`: 148 pass, 5 skip,
0 fail.
- With `main`'s `server/codex.ts`, the new `codex-processes.test.ts`
case fails, and the other 29 in that file pass.

**Windows** (Bun 1.4.2):

- `windows-native.test.ts`: 5/5, including the new case.
- With `main`'s `server/codex.ts`, the new case fails with `null`, the
same error as the live pane.
- `codex.test.ts`: the new case passes. The one failure there is the
existing symlink case (`EPERM` without Developer Mode), which fails the
same way on `main`.

**Tests added:**

- `server/codex.test.ts`: `withoutVerbatimPrefix` and `storedCwds` for
drive, UNC, POSIX, relative and device paths.
- `server/codex-processes.test.ts`: a pane whose cwd is `D:\work\app`
finds the thread Codex stored under `\\?\D:\work\app`. This runs on any
OS, through the real `codexTranscriptPath` with the existing fake herdr
socket.
- `server/windows-native.test.ts` (real Windows only, like the other
tests there): a `\\?\`-prefixed rollout path inside the store resolves
to the plain canonical path. A prefixed path outside the store, or one
that climbs out with `..`, is still refused.

I did not run `test:integration` or `test:ui` locally.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Hako <devswha@gmail.com>
## What

Two fixes for Claude Code panes in Chat, both reported by @knightnemo in
#578:

- **A slash command's answer shows in the chat.** Claude Code records
what a slash command printed as `{type: "system", subtype:
"local_command", content:
"<local-command-stdout>…</local-command-stdout>"}` (or `-stderr`).
`parseClaudeTranscript` now turns that output into a `notice` part, so a
refused `/goal` ("/goal can't run while hooks are restricted …") is
readable in Chat instead of only in the terminal.
- **`/goal` is suggested.** It joins the Claude built-ins with its
argument hint, `[condition|clear]`.

## Why

The parser read only `message.content`, and these records keep their
text in a top-level `content`, so they parsed to nothing: a user who set
a goal from Chat could not tell whether Claude Code accepted it. `/goal`
was missing from the hard-coded built-in list.

## How

- `server/conversation.ts`: a `local_command` system entry with stdout
or stderr becomes a turn in the user's seat with `{ kind: "notice",
text, source: "local-command" }`. This is the part pi-family runtime
messages already use, and `ChatView` labels a notice with a named source
by its first line, so there is no new wire field, UI string or demo
answer. Terminal colour codes are stripped. The command's echo record
(`<command-name>…`) and an empty answer stay out, as slash-command
bookkeeping always has; the older `user`-message filtering is unchanged.
Like a pi notice, the record does not open a page.
- `server/commands.ts`: `goal` in the Claude list, described the way
pi's commands carry an argument hint.

No agent hooks or settings are read or written.

## How verified

- Both bugs reproduced first as failing unit tests on main (59 pass, 2
fail), then green with the fix (61 pass, 0 fail in
`server/commands.test.ts` and `server/conversation.test.ts`).
- Mutations turn the new tests red: removing `goal` from the built-ins,
disabling the `local_command` branch, and dropping the colour-code strip
each fail exactly one test.
- The issue's own repro scripts, run against this branch:
`paneCommands('claude', null, home)` includes `goal`, and the two-record
fixture parses to one notice carrying the refusal, also when read from a
file through `transcriptPage("claude-transcript", …)`.
- `bun run check fast`: workflow syntax, generated types, typecheck,
build and unit tests (1717 pass, 0 fail) all ok.

Not verified live: a real Claude Code session's `/goal` through the
browser. The record shape is the one the issue captured from Claude Code
2.1.251.

Part of #578. The goal-status card for the bare `/goal` dialog asked for
there is a new feature and stays open.
## What

Refreshes the agent guides from a full scan of the tree (init-deep,
update mode).

- `AGENTS.md`: lists the two new guides in Scope; mirroring also applies
to a Linux or macOS PC without Node for the sidecar; the
`*.contract.test.ts` rule now names the exceptions in
`scripts/ci-tests.ts`; three new notes (demo imports the client, demo
fixtures stay fictional, `THIRD_PARTY_NOTICES.md` ships in `dist/`).
- `server/AGENTS.md`: +15 lines, none removed. Anti-patterns for
`usage.ts` (never refresh an OAuth token), the device gate,
`herdr-marker.ts`, API keys and `static.ts` cache headers; conventions
for mirrored terminals, shell agents and submit receipts; six new WHERE
TO LOOK rows.
- `src/AGENTS.md`: +17 lines, one corrected (`role="alert"` is used for
more than the sidebar error block).
- `scripts/AGENTS.md` (new, 35 lines) and `shared/AGENTS.md` (new, 34
lines).

## Scope kept

The files still hold only what the code and the docs do not show: no
structure trees, command lists or generated headers were added.

## Checks

- Docs only; no code path changes, so no test was added and no changelog
entry is included.
- Line counts: root 81, server 76, src 68, scripts 35, shared 34.
- The added lines were written by agents from code they read; a human
read of the new invariants before merge is worth it.
## Change

Opening Settings with its shortcut while a file preview was open placed
Settings behind the preview. Clicking the visible file's Close button
consumed Settings' copied history entry and left the file open; Escape
could close both overlays.

Place Settings above the preview and pause the preview's Escape listener
until Settings closes. Settings' Back/Escape consumes only its own
entry, preserving the file below it until that file is closed.

## Validation

- Reproduced the visible Close-file button closing only hidden Settings
on the v0.4.0 demo.
- Browser regressions cover 390px and 1280px overlay order, Escape and
browser Back, one-click file close, and preservation of the draft and
same document.
- Full local QA tree (this fix plus companion input/palette/test PRs)
passed generated types, typecheck, build, 1,673 unit tests, 278
integration tests and the full browser lane on Bun 1.4.2.
- Independent review found no blocking concerns; test-session
screenshots are captured for both widths.

## Screenshots

<img width="390" height="844" alt="settings-over-preview-390"
src="https://github.com/user-attachments/assets/c09b9be2-305b-41df-83de-864e78994d3a"
/>
<img width="1280" height="844" alt="settings-over-preview-1280"
src="https://github.com/user-attachments/assets/99d88d98-6703-4abe-b8a6-d6ae593a385f"
/>

---------

Co-authored-by: e2e <e2e@example.com>
## Change

Enter on the command palette's focused Close button used to select the
highlighted pane instead. Tab-focused results could also run a different
highlighted item, IME Enter/Escape activated or dismissed the palette
while composing, and arrow navigation could leave the selected result
outside the scrollable list.

Scope result navigation to the search field, preserve native button
activation, protect IME candidate/commit/cancel keys, and scroll the
selected option into view without moving typing focus.

## Validation

- Reproduced the Close-button and composing-Enter failures on the v0.4.0
demo in ego-browser.
- Browser regression passed for native Close/result Enter, IME
Enter/arrows/Escape, and ArrowUp/Down wrap visibility at 1280×400 and
390×600.
- Full local QA tree (this fix plus companion input/settings/test PRs)
passed generated types, typecheck, build, 1,673 unit tests, 278
integration tests and the full browser lane on Bun 1.4.2.
- Independent code review found no blocking concerns.
- Screenshots captured from an isolated test session for the short
desktop and mobile palettes.

## Screenshots

<img width="1280" height="400" alt="palette-arrow-desktop"
src="https://github.com/user-attachments/assets/77f8e5c1-3fd6-4565-af6d-20ecefd888fc"
/>
<img width="390" height="600" alt="palette-arrow-mobile"
src="https://github.com/user-attachments/assets/bdb28177-30a7-4976-8475-b32bc34d8014"
/>

---------

Co-authored-by: e2e <e2e@example.com>
## Change

Fixes for six findings from the pre-release Codex review of 0.4.1
(findings 2, 3, 5, 7, 8 and 9), one commit each, plus a CHANGELOG
commit. Findings 1, 4 and 6 and `REMOTE_BUNDLE_VERSION` are left alone.

| # | Commit | What |
|---|--------|------|
| 3 | 9373398 | A Claude Code slash command's answer is stripped of
terminal escapes in one linear pass. Also closes the three gaps of #584.
|
| 2 | 5375765 | The secret handler records the sender's attach claim
before the live-screen read and answers `not_attached` when it changed.
|
| 8 | ca4ca92 | Regression for #576 with a second client keeping the
attachment alive, so it fails when the claim check is removed. |
| 7 | a717619 | The plugin's `appOnPort` takes only a 2xx answer with
the app's bridge-health shape. |
| 9 | 36651a5 | Three doc statements corrected after checking the code.
|
| 5 | e6ceb43 | The command palette has its own layer token, above every
dialog. |

Closes #584.

## Reproduced first on 5979118

- **3**: `server/conversation.test.ts` got two tests. On the unchanged
code a cut-off OSC 8 link leaked its address into the notice (`done
8;;https://example.test/?token=hidden`), `ESC ( B` left a stray `B`, an
entry with stdout and stderr showed one stream, and doubling a malformed
answer made parsing 4.0x slower (limit 3; linear is 2). The growth test
compares two input sizes rather than a wall-clock threshold; it passed
8/8 repeat runs after the fix.
- **2**: new case in `server/submit.contract.test.ts`. A keeper client
and the sender attach to a mirrored pane; the secret's `pane.read` is
held on a deferred barrier while the sender detaches and attaches again.
Unchanged code answered `secret-result ok: true` and entered the secret.
- **8**: new case in the same file for ordinary typing. The message
ahead of the typing is held at herdr (`paneSendText` on a deferred
barrier) while the sender detaches and rejoins; the keeper keeps the
attachment, so only the claim comparison can reject the typing.
- **7**: `scripts/plugin.test.ts` got a stranger that answers bridge
health with `{"ok":true}` (foreign JSON) and with the app's shape under
503. Unchanged code took the first for the app (`herdr web ui is running
at … but cannot reach herdr: not us`) and kept the port.
- **5**: not reproducible on the demo: its fixtures hold no file link,
so no preview can be opened there. Reproduced instead with
`scripts/file-viewer-regression.ts`, which drives the real client in
Chromium: a preview, then Settings (raised above it), then the palette.
The palette's search box was focused but not the topmost element. The
case is now in that script.
- **9**: no test (docs only). Each corrected statement was checked
against the code:
- `server/AGENTS.md` said submit IDs are idempotency receipts; only
`delivery: "queue"` submits enter `PendingRequestBook`
(`server/index.ts`), so an immediate submit sent twice runs twice.
- `server/AGENTS.md` said that with a token set every client needs it;
`decideAccess` accepts a paired device's cookie before it asks for the
token.
- The #563 CHANGELOG entry implied any `CLAUDE_CONFIG_DIR` is found on
Windows; `windowsClaudeStores` looks only in the server's own
`CLAUDE_CONFIG_DIR`, `~/.claude` and `~/.claude-*` in the home folder.

## Mutation runs

Each new test was run with its fix removed (file restored byte for byte
afterwards, checked with `cmp`):

- Secret claim check removed from the `secret` handler: only the secret
case fails (`ok: true` instead of `not_attached`).
- Claim comparison removed from mirrored typing: only the typing case
fails.
- `response.ok` check removed from `appOnPort`: the 503 variant fails.
- `z-index: var(--z-palette)` removed: the three-overlay case fails with
`{ above: false, focused: true }`; the screenshot shows the palette
hidden under Settings.

## Validation

All through `dori heavy` on head e6ceb43, Bun 1.4.2:

- `bun run check fast`: workflow syntax, generated types, typecheck,
build, unit tests (1725 pass, 0 fail).
- `bun run check run bun test ./server/submit.contract.test.ts`: 24
pass.
- `bun run check run bun test ./server/secret.contract.test.ts`: 5 pass.
- `bun run check run bun scripts/file-viewer-regression.ts` after `bun
run build`: every step passes, twice in a row, including the new
three-overlay case.
- `HERDR_TEST_MODE=unit bun test ./server/conversation.test.ts`: 52
pass; the growth test 8/8 on repeat.
- `HERDR_TEST_MODE=unit bun test ./scripts/plugin.test.ts
./scripts/windows-plugin.test.ts ./scripts/plugin-port.test.ts`: 20
pass.

The browser lane as a whole and the integration suite run in CI.


## Review round 1
([review](https://github.com/devswha/herdr-web-ui/pull/588#pullrequestreview-5451491270))

| | Commit | What |
|---|--------|------|
| B1 | d2c6249 | `start()` asks the full `/api/health` first, and
`health()` took any 200 `{ok:true}`. It now requires the shape every
release since v0.1.0 has answered (`ok`, a `herdr` object, `auth` with
`required` and `authenticated`). New test: a stranger answering
`{"ok":true}` on both URLs; on 378013b `start` said "already running".
The plugin and phone-setup fakes answer the real shape. |
| B2 | c55eb28 | A stream ends only at a closing tag at the end of the
entry or before the next stream, so output printing the tag itself is
shown again (on 378013b the review's example gave no notice). The growth
test also covers output full of closing tags. |
| B3 | 689a1b5 | `DESIGN.md` and the `CommandPalette.css` comment say
the palette layer is over scrim dialogs: `MachineDialog` uses
`showModal()`. |

On 689a1b5 through `dori heavy`: `bun run check fast` (1726 pass, 0
fail) and `bun run check run bun test
./scripts/phone-setup.contract.test.ts` (1 pass);
`server/conversation.test.ts` 52 pass, growth test 8/8 on repeat.
## The failure

"Fast checks" failed on main at 57d7663 (run 37730991278) on
`parseClaudeTranscript > strips escapes in time linear in a malformed
answer's length` (`server/conversation.test.ts`, added by #588):

```ts
expect(time(large) / Math.max(time(small), 0.05)).toBeLessThan(3);
```

The same code was green twice on the PR.

## Why it flaked

The parse of either input takes well under a millisecond, so the
assertion divided two sub-millisecond timings with a 0.05 ms floor. One
scheduler pause in the large run, or a small run that hit the floor,
moves the ratio past 3 with no change in the code. A required check that
depends on that blocks every PR.

## What the test asserts now

No clock is read. While `parseClaudeTranscript` runs, the test wraps the
primitives the scanner reads the answer through and counts the
characters each one touches:

- `charCodeAt`: 1
- `indexOf`: the distance scanned plus the needle
- `startsWith`: the needle
- `slice`: the copy's length
- a regex run (`RegExp.prototype.exec`, which `test` and `replace` go
through once it is wrapped): the square of its input. A regex's
backtracking cannot be counted from outside, so it is charged its worst
case. The scanner's `/\s/` over one character costs one step; a regex
over the whole answer does not pass.

It then asserts, for both input shapes (unterminated escapes
`"\u001b]x"` and closing tags the output prints itself), that doubling
the input from 20,000 to 40,000 units grows the count by less than 2.5x.
Measured on the current scanner: 240,044 to 480,044 steps (2.00x) and
2,424,046 to 4,844,046 (2.00x). The originals are put back in a
`finally`. A count of zero gives NaN and fails too.

Test-only: `server/conversation.ts` is untouched, so no CHANGELOG line.

## Proof

- **Pre-#588 code fails it.** With `localCommandOutput` from 5979118 put
back, the test fails: `Expected: < 2.5, Received: 7.999`. The source was
then restored and `cmp` against `HEAD` reported it identical.
- **A hand-written rescan fails it.** The pre-#588 function was only
quadratic on the escape shape (it is caught there by the regex charge,
and stops at the first shape). For the closing-tag shape I added a
`content.slice(next)` at every closing tag to the current scanner:
`Expected: < 2.5, Received: 3.999`, with the escape shape still passing.
Restored, `cmp` identical.
- **200/200.** `HERDR_TEST_MODE=unit bun test
./server/conversation.test.ts -t "strips escapes"` run 200 times, eight
at a time: 200 passed.
- `bun run check fast`: ok (1726 pass, 0 fail).

## Note for review

The regex charge means this test also fails if the scanner goes back to
running a regex over the whole answer, linear or not. That is deliberate
(it is the only way a count can see the pre-#588 cost), and the comment
in the test says so.
## Change

One server-side change: make remote bridge replacement conflict-safe.

- Verify and reuse an already-compatible live bridge when an explicit
update races with another app.
- Refuse to downgrade a bridge from a newer app version and report
descriptor/live identity conflicts as `bridge_conflict`.
- Keep a replacement started by this app classified as `update_bridge`
when its own protocol or bundle is still incompatible.
- Update the wire type, focused tests, remote-PC guide and changelog. No
reconnect UI, background-install UI or Vite proxy change remains in this
PR.

## Validation

- `TMPDIR=/private/tmp bun run test:unit`: passed.
- `bun run typecheck`, `bun run build`, `bun run build:remote`: passed.
- `TMPDIR=/private/tmp bun run test:ssh`: passed against a real isolated
OpenSSH server and built remote bundle, including first install,
two-daemon isolation, PTY input, reconnect/restart, idempotent
compatible update, host-key rejection and cleanup.
- The default macOS temp path exceeded Unix socket `sun_path`; using
`/private/tmp` exercises the same test with a valid socket path.

---------

Co-authored-by: Hako <devswha@gmail.com>
## Summary

The follow-up to #569: the rest of a security, accessibility,
reliability and consistency audit.
Every item is a small, independent fix; nothing here changes a contract
or an architecture. Split
from the access-gate change so each PR is one thing.

### Security

- **CSP.** The app now sends itself a Content-Security-Policy, so
third-party content rendered in a
chat — markdown, a pasted link's preview, math — cannot pull in scripts
or reach back to the
server. `HERDR_WEB_CSP=report-only` measures the policy without
enforcing it. `scripts/csp-violations.ts`
  drives the demo harness and reports what the browser would block.
- **OSC 52 clipboard writes are gated.** A pane can no longer write your
clipboard without the
switch in Settings → Appearance (**Clipboard from a pane**, off by
default).
- **Terminal links are checked.** A link printed in the terminal opens
only if it is an http(s)
  address, on both link paths.
- **Token rate limit.** A wrong access token is refused with a growing
wait (five tries, then a
  backoff up to a minute) instead of being retried without bound.
- **Path disclosure.** A 500 no longer repeats the system's own error
text, which carried absolute
paths and the herdr socket location; it names a short id you can quote
in a bug report.
- **Push endpoints must be https**, and a watching device can no longer
make the server subscribe
  somewhere it should not.
- **Held input expires.** Terminal input typed while disconnected is
forgotten after a day, and a
  pane left behind no longer keeps a draft alive.

### Accessibility

- Settings, the command palette, the file viewer, the file browser and
the new-workspace dialog keep
Tab inside them and give focus back to whatever opened them
(`useFocusTrap`).
- Workspace tabs name the pane region they govern; the "reconnecting"
line and the composer's
  terminal-only hint are announced when they appear.
- Agent headings in a chat no longer pose as the app's own page
structure.
- A chat locked out by the token gate, the "Last checked" line under
Settings → Updates, and the
  alerts menu item are announced and worded consistently across states.

### Reliability and consistency

- Two tabs on one pane can no longer send the same message; a failed
rename keeps the field open
with what you typed; a failed workspace reorder no longer undoes a
later, successful one; rename,
  connect and disconnect disable while their request is in flight.
- **Remove PC**'s first click is a quiet ghost button that only arms the
removal; the second is the
  destructive one.
- **Add PC**, **Reconnect PC** and **Update remote bridge** open as a
bottom sheet on a phone, and a
row menu open while the window crosses the 640 px breakpoint switches
between bottom sheet and
  menu instead of hanging in the wrong one.
- The PDF viewer's page colour, the pill radii, the tab dot and the
pairing-code size come from
  tokens, and `DESIGN.md` matches `src/styles.css`.

## Test plan

- [x] `HERDR_TEST_MODE=unit bun run test:unit` — 1721 tests, 0 fail
- [x] `bun run typecheck` — clean
- [x] `server/auth.test.ts` — the token backoff
- [x] `server/http.test.ts` — no system text in a 500
- [x] `server/open-access.test.ts` — a paired watch device reaches no
directory listing and no read
      outside the enums
- [x] `scripts/csp-violations.ts` — runs against the demo harness
- [x] CI: Fast checks, Native Windows install, Integration and browser —
all green

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: radicor <211192474+radicor@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Hako <devswha@gmail.com>
## Change

Step 1 of #202. Adds Portal to **Behind a reverse proxy** in
`docs/guide.md`, right after the 401 check. It also mentions Portal in
the access table's "A public domain, reverse proxy or Portal" row, adds
a bullet to the FAQ "Do I need Tailscale?", and adds a CHANGELOG line.

- **Version:** Portal v2.6.1 (released 2026-10-05). From that release
`--http-route` keeps the browser's `Host`, sends `X-Forwarded-For` and
`X-Forwarded-Proto: https`, and passes the WebSocket upgrade
(gosuda/portal-tunnel#545). It can also drop a request header with
`--strip-request-header` (gosuda/portal-tunnel#564). The plain `portal
expose 7317` sends no `X-Forwarded-Proto`, so a browser gets
`invalid_origin`.
- **Tailscale, first and in bold:** the command drops a visitor's
`Tailscale-User-Login` with `--strip-request-header`, and the warning
says to keep that on a PC that runs Tailscale and not to combine Portal
with `HERDR_WEB_TAILSCALE_SERVE_ONLY=1`. Since #309 a token also stops a
forged header; without one, the header would get a visitor in.
- **Command:** split over five lines with `\`, so it reads on a phone.
- **Relay:** a relay running Portal's code passes the encrypted bytes on
and sees only connection metadata, but it holds its domain's
certificate, so the section says to use one you trust.
- **Token:** a long random `HERDR_WEB_TOKEN` (`openssl rand -hex 32`),
with a pointer to where settings go.
- **Address:** v2.6.1 prints an identity-bound address,
`https://<name>-<address>.<relay>` (gosuda/portal-tunnel#567). No other
identity can take it, even while Portal is stopped, so the section says
to use that address and not the short `<name>.<relay>` alias, which can
be reused. A v2.6.1 client needs a relay on v2.6.1 or later, which
`portal list` shows.
- **Check:** the same 401 check as the other proxies, against that
address.
- The section is a block of paragraphs inside **Behind a reverse
proxy**, like the Caddy and nginx examples. A heading after the 401 line
would have pulled "Sign out" and the paragraphs after it under Portal.

Docs only; no code changes.

## Validation

- Checked against `decideAccess` and `cameThroughProxy` in
`server/access.ts`, `sameOrigin` in `server/machine-security.ts`, and
the current "Behind a reverse proxy" text, at `main` 8a5ac49 (after
v0.4.0).
- Portal v2.6.1:
- every flag in the command exists, and `--strip-request-header`
requires `--http-route`, which the command has;
- the printed URL is the relay's `canonical_hostname`, `<name>-<40
hex>`, and names are limited to 22 characters (the command's name is
22);
  - the client refuses a relay that returns no canonical hostname;
- `portal list` shows gosunuts.xyz, portal.dawnfullstack.com and
kakashit.org on v2.6.1;
- the relay reads only the ClientHello for routing
(`portal/server.go:808`) and copies bytes (`portal/proxy.go:26`); the
tunnel ends TLS (`sdk/listener.go:649`); the relay holds the wildcard
certificate (`portal/acme/certificate.go:42`).
- Anchors `#behind-a-reverse-proxy` and `#in-a-terminal` render, and the
Portal links return 200.
- **End-to-end run:** done through gosunuts.xyz (v2.6.1): token sign-in
in a real browser, the terminal over `/ws`, pairing a device, the 401
and refusal checks, and the header strip. Push alerts were not tested
(the automation browser has no `PushManager`); details in the comment
below.

Prepared with Claude Code; the claims above are checked against the code
they cite.

Co-authored-by: Hako <devswha@gmail.com>
Closes #528. Rebuilt on the #521 sidebar (an earlier version targeted
the old one). Both settings are opt-in; with them off nothing changes.

## Change

**Settings → Appearance → Agents order: Workspaces (default) |
Activity.** Activity pins a blocked agent on top of the Agents list and
orders the rest by their most recent state change, within each PC (each
herdr counts its own changes), the given order breaking ties. The agent
you just sent a message to goes to the top, stays there while it runs
and after it finishes, and moves down only when another agent changes
state; a new agent starts on top. It changes only the Agents list:
herdr's order and the workspace rows stay as they are. (I first ranked
states, blocked > done > working > idle; in use, an agent sent a message
fell below every DONE while it ran and jumped back when it finished, so
recency won. #528 has the details.)

**Settings → Appearance → Quiet opened finishes (off).** herdr reports
DONE until one of its own clients shows the pane, and #521 draws DONE as
"finished, not looked at yet". Opening the pane in the web UI does not
tell herdr (I found no socket call that marks a pane viewed;
`agent.view.set` configures the agents panel), so a finish read here
keeps its dot until it is opened in herdr itself. With this on, a DONE
opened here since it finished — selected while the page is visible —
draws as ready in the Agents list and in its workspace's roll-up.
"Opened" is a per-PC localStorage record (`herdr-web-ui:seen:<pc>`) of
the `state_change_seq` each pane had when last on screen; the first time
it is turned on in a browser, everything open counts as opened, so the
lists start quiet; a PC first seen later starts with nothing opened. A
removed PC's record is dropped. When herdr's counter replaces the
provisional one a pushed change was dated with (below), a look recorded
at the provisional value moves onto it (`carrySeen`). Storage only ever
holds herdr's counters (`persistableSeen`): a page reloaded inside that
window shows the watched finish's dot again rather than guess. #528 asks
whether you would rather keep it on the server, shared by every device.

**Counters.** `session.snapshot` carries `state_change_seq` on `agents`,
which the client already receives, so nothing changes on the server or
the wire. A `pane-status` push updates the status at once but the
counter only with the next roster read (up to `POLL_MS`), so `liveSeqs`
dates a pushed change just above every known counter until herdr's own,
higher one arrives. Without it, an agent sent a message sat in its old
place for seconds, and a finish seen through that gap was recorded at
its old counter.

- Pure logic in `src/lib/sidebarOrder.ts` (unit-tested).
`MachineSidebar` owns the shared state (`src/lib/sidebarActivity.tsx`);
the workspace rows and `AgentSidebar` read it, so both lists stay on the
existing SSE roster.
- The demo's agents now carry `state_change_seq`, bumped on every status
change and new pane (`site/demo/transport.ts`), so both settings work
there.
- New strings have ko/ja/zh entries. DESIGN.md, `src/AGENTS.md`,
`docs/development.md` and CHANGELOG (Unreleased → Added) are updated.

## Validation

- Rebased on f8f09d1 (v0.4.0 + #566). `bun run check fast` passes
(workflow syntax, generated types, typecheck, build, unit tests: 1717
pass), and `bun run check browser` passes on its own herdr, including
the new script, which is added to `scripts/ci-browser.sh` and uses the
shared demo build.
- `src/lib/sidebarOrder.test.ts` pins the review's reproduction:
counters `{a: 5, b: 9}`, a push makes `a` done at 9.001, the look is
recorded there, the roster read brings `a: 10`; without `carrySeen` the
pane reads DONE, with it ready. Storage helpers (`anySeen`,
`forgetSeen`, `loadSeen`) are covered with a stub `localStorage`.
- `bun scripts/sidebar-activity-demo-regression.ts`: by default the
Agents list keeps herdr's order across a finish, and an opened DONE
keeps its dot. With both settings on: the blocked agent is pinned; the
demo's Claude agent finishing out of sight rises under it with its dot;
a message sent from another agent takes it to the top while RUN and
keeps it there after DONE; opening it draws it as ready in the Agents
list and its workspace row, while the unopened finish keeps its dot.
Then, with roster reads held, a finish watched on screen and another
pane opened before the read stays ready once the read lands; this step
fails without the carry.
- The first version has been in use on my own install (about 40
workspaces); the recency-first order came out of that use.
- Screenshots below are from the demo after the same steps, with the
settings off (default) and on.

## Screens

Default:
<img width="360" height="760" alt="pr529-default"
src="https://github.com/user-attachments/assets/2f5fe9da-347f-4ac3-a0a2-b7e8fdd0ff63"
/>

Activity
<img width="360" height="760" alt="pr529-activity"
src="https://github.com/user-attachments/assets/5968fc4f-7737-47a1-8178-a079d827a3a6"
/>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Hako <devswha@gmail.com>
## Change

One composer-only change: keep long drafts readable and scrollable at
browser zoom.

- Wrap unbroken text with `overflow-wrap: anywhere`.
- Reserve a stable gutter and show a thin token-colored scrollbar, so
the bar does not cover draft text at fractional zoom.
- Preserve automatic/manual sizing plus wheel and keyboard scrolling.
- Add focused browser coverage and update the changelog. Terminal line
height and terminal zoom shortcuts are no longer part of this PR.

## Validation

- `TMPDIR=/private/tmp bun run test:unit`: passed.
- `bun run typecheck`, `bun run build`, `git diff --check`: passed.
- `CHROME_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google
Chrome" bun scripts/composer-fit-demo-regression.ts`: passed at 80%,
100%, 125%, 150%, 175% and 200% layout zoom, including wrapped drafts,
stable scrollbar space and wheel/keyboard scrolling.
- Screenshot inspected from the built app: `/tmp/pr522-composer.png`.

---------

Co-authored-by: Hako <devswha@gmail.com>
Fixes from the second Codex pass over v0.4.0..6993563, before v0.4.1:
findings 2, 1 and 5, and the CHANGELOG wording for 4. Finding 3 is #589
and finding 6 is #591; neither is touched here. `REMOTE_BUNDLE_VERSION`
is unchanged.

One commit per finding. Each finding was reproduced by a test or browser
check that fails on 6993563, and that check is kept.

### Finding 2: the shared token budget split attacker text (`fix(auth)`)
- **Reproduced:** a new case in `server/auth.test.ts` sends 60 wrong
tokens from a loopback peer with `X-Forwarded-For: claimed via <n>`. On
6993563 all 60 got 401 and none got 429 (`Expected: 50, Received: 60`).
`sharedKey` split `<forwarded> via <peer>` at the first ` via `, so each
guess got its own shared budget.
- **Fix:** read the peer after the last ` via `. It is the socket's own
address, which never holds a space, so nothing the client writes can
move the split. Per-address budgets, paired devices, tokenless local or
serve-only access, and admitted addresses are unchanged. The existing
auth and access tests pass.
- **Mutation proof:** with the fix removed (6993563's `indexOf`), the
new case fails as above.

### Finding 1: http loopback push endpoints were accepted in production
(`fix(push)`)
- **Reproduced:** a new case in `server/push.test.ts` checks that a
default parse rejects `http://127.0.0.1`, `http://[::1]` and
`http://localhost` endpoints. On 6993563 the default parse accepted
them.
- **Fix:** `parseSubscription` accepts https only. A push service made
with `loopbackHttp` also accepts loopback http. Only tests set it,
through `createServer({ pushLoopbackHttp: true })`, the one injection
seam, and they deliver to `push.fake.ts`. No environment variable
reaches it. `POST /api/push/subscribe` and stored subscriptions on load
both go through the service's rule, so an http entry already on disk is
dropped in production. https endpoints, including https on loopback, are
still accepted.
- **Mutation proof:** removing the opt-in check (`options.loopbackHttp
=== true &&`) makes the new case fail. The existing push tests and the
push contract tests still deliver to the fake.

### Finding 5: a background focus trap took Tab from Add PC
(`fix(a11y)`)
- **Reproduced in Chromium:** a new case in
`scripts/file-viewer-regression.ts` uses the real client and an owned
herdr pane, at 1280px. It opens a file preview, then Settings, Remote
PCs and Add PC. On 6993563, Tab pressed three times stayed on Add PC's
first field. The preview's trap was still on top of `openTraps` and
cancelled each Tab.
- **Fix:** `useFocusTrap` stands down while a native modal
(`dialog:modal`) it is not part of is open. That covers its Tab
listener, its initial focus and its focus restore.
- **Mutation proof:** the same case fails without the fix, as above, and
passes with it, as do the script's other cases.

### Review follow-ups (rev-astra on 94745a5)
- **B1, push delivery followed redirects (`fix(push): never follow a
push endpoint's redirect`):** an endpoint answering 307 to
`http://127.0.0.1` had the alert POSTed there. The new
`server/push.test.ts` case uses a redirector to the fake push service.
It failed before the fix (`ok: true`) and passes with `redirect:
"error"`: the fake receives nothing and the delivery is not ok.
- **B2, Escape over Add PC also closed the preview (`fix(a11y): leave
Escape to a native modal over an overlay`):** the window Escape handlers
in FileViewer, FilesDialog, ConfirmDialog and NewSessionDialog stand
down while a native modal they are not part of is open, using the
exported `nativeModalOver`. SettingsDialog closes when Add PC opens, and
WorktreeDialog already ignores Escape when focus is outside it. The
regression case now presses Escape over Add PC and checks that the
preview and its history entry stay. It failed on 94745a5 in the
reviewer's run and passes now. Only the FileViewer path is exercised in
the browser.

### Finding 4: wording only (`docs(changelog)`)
The #570 line said two tabs can no longer send the same message. The
lease is read and then written in localStorage, so two tabs pressing
Send at nearly the same moment can both send. The line now says that. No
code change.

### CHANGELOG
Findings 1, 2 and 5 correct behavior that #570 added after v0.4.0, so
they get no new lines. The #570 line "A web-push subscription must be an
https endpoint." is now accurate without exceptions.

### Checks
`bun run check fast`, `server/api.contract.test.ts`,
`server/device-access.contract.test.ts`,
`scripts/file-viewer-regression.ts` and `scripts/ui-regression.ts`
(which covers Add PC from Settings and its focus return), all run
through `bun run check run`.
## Change

- Recognize GJC's unnumbered provider selectors and standard/outlined
question menus instead of showing only ↑, ↓, Enter, and Esc.
- Answer choices with cursor navigation and Enter, keeping identity
stable across cursor movement and rechecking the live menu before
confirmation. Changed menus are rejected without sending Enter.
- Recognize startup selectors while the pane is idle, before it reports
blocked. Workspace/worktree creation already uses the shared
AgentPicker; no new launch-mode controls or CLI flags are introduced.
- Add parser and prompt-answer route regressions and an Unreleased
changelog entry.

## Validation

- `HERDR_TEST_MODE=unit bun test ./server/prompt.test.ts`: 190 passed.
- `bun run typecheck`: passed after installing locked project
dependencies.
- `bun run build`: passed.
- `bun run test:unit`: 1,802 passed, 6 skipped, 0 failed.
- `git diff --check`: passed.
- Selection formatting and key semantics checked against installed GJC
v0.18.7's bundled source.

No HTTP/WS wire contract or client component changes. The originally
reported live screen was not reproduced in the browser; no screenshot or
browser end-to-end validation is claimed.
## Change

`/effort` sent from a Claude Code chat was a dead end. It was not in the
chat's slash list, and typed by hand it opened Claude Code's effort
slider in the pane, where no reader knew the screen. herdr reports the
pane idle while the slider waits, so not even the fallback card
appeared, and the level had to be set in the terminal.

This adds a reader for that slider (`claude-effort` in
`server/prompt.ts`), so the existing prompt card shows it, the same way
#501 did for `/model`. Nothing changes on the client or on the wire.

- **What becomes a card.** The slider is recognised by its hint, `←/→ to
adjust · Enter to confirm · s for this session only · Esc to cancel`,
with a line of level names above it, the track with one `▲` above that,
and the `Effort` title. The options are the levels Claude draws (low,
medium, high, xhigh, max on Opus 5.5 and Fable 5.1). `▲` is read as over
the level whose name it is centred on, within 3 columns, else no card.
- **A pick is for this session only.** The answer moves the slider with
`←` / `→` and types `s`. It never sends Enter, which saves the level as
the default for new sessions (per model, in `modelSettings`). Typing
`/effort high` by hand still saves a default, as it does in the
terminal: that is Claude Code's own behaviour for a level typed after
the command.
- **Same guards as the model list.** The slider is read again before the
`s`, also when the pick needs no move, and the letter goes only while
`▲` stands over the chosen level. Each move after the first waits for
the slider to show the one before it. A slider the reader cannot take,
for example level names a narrow pane wrapped, gets no fallback card and
holds pending delivery back (`modelListWaits`), because both end in
Enter.
- **Moves.** `←` / `→` count as moves for this responder only. Elsewhere
they stay a menu's own keys (Claude's question tabs).
- **One card wherever `▲` is.** The card's id does not depend on where
the slider stands, so moving it in the terminal does not replace the
card.
- `/effort` joins Claude's built-in slash list, described in Claude
Code's own words.
- Ultracode (the slider's Tab toggle) is left to the terminal.

Related:

- #523 by @suho-han, closed today, read Claude's effort slider as part
of a composer control for model and effort. Its close comment said the
slider reading would feed into the maintainer's own change, with credit.
This PR is written fresh against Claude Code 2.1.294's screen (#523
could not capture a real `/effort` screen), but it is the same feature,
so the changelog credits it.
- #501 and #503: the `/model` cards this follows (letter `s`, never
Enter, look before the letter).
- #456 and #467: the model and effort shown in the composer's pill. The
pill picks the new level up with Claude's next answer, as it does for a
model.

Known limits:

- #542 applies here too. The guard reads this slider's hint with
`claudeModelListWaits`, so a hint split inside a word, or over more than
six lines, is missed in the same way.
- #469 applies here too. The slider closing between the last read and
the `s` still lets the letter into Claude's prompt.
- A narrow pane's slider was not captured live. A wrapped line of levels
is left to the terminal, and that path is covered by unit fixtures only.
- Models with four levels (Opus 4.6, Sonnet 4.6) were not captured. The
reader takes whatever level names are drawn, but `▲`'s spacing there is
unverified.

## Validation

- `bun run typecheck`: passed. `bun run test:unit`: 1780 passed, 0
failed (before the rebase on 6993563). After the rebase: typecheck
passed and `server/prompt.test.ts` 188 passed, 0 failed.
- `server/prompt.test.ts`: the fixture drawing is checked against a
capture of Claude Code 2.1.294's slider at 120 columns. Tests cover
reading, one id across `▲` positions, keys (`←` / `→` then `s`), no card
and no hold once the slider is answered or under later output, no card
without the session-only key, a wrapped level line (no card, still
holds), `▲` between two levels, and two route cases: a pick two levels
left (`left, left, s`), and `▲` moved in the terminal mid-pick (one
arrow, no letter, 409).
- Real Claude Code 2.1.294 in an isolated herdr 0.9.3 session
(`herdr-web-ui-test-effortprobe`), through this branch's `createServer`.
`GET /api/pane/commands` lists `effort`. `/effort` then `GET
/api/pane/prompt` gave the card with five levels. Answering xhigh and
then low over `POST /api/pane/prompt/answer` printed `Set effort level
to xhigh (this session only)` and `Set effort level to low (this session
only)`, and the banner read `with low effort`. `~/.claude/settings.json`
kept its modification time through every probe and pick.
## Change

Prepares v0.4.1 and remote bundle 21.

- `package.json` and `herdr-plugin.toml`: 0.4.0 → 0.4.1. A patch
release: fixes from the audit and the pre-release reviews (#566, #570,
#588, #592), Windows chat discovery (#563, #582), the plugin's port
handling (#577), GJC menus (#593) and more. The new pieces are small and
opt-in or additive: the sidebar's Activity order and Quiet opened
finishes (#529, both off by default), the `/effort` card (#594), the
clipboard-from-a-pane switch (#570) and the Portal guide (#229).
- `shared/machines.ts`: `REMOTE_BUNDLE_VERSION` 20 → 21. `server/` code
that ships in the bundle changed since v0.4.0 (#566, #563, #572, #576,
#582, #583, #588, #519, #570, #592, #593, #594), so remote PCs need a
new bridge.
- `CHANGELOG.md`: the Unreleased notes become `[0.4.1] - 2026-10-08`,
with the compare links. #592 merged without entries; its three
user-visible fixes are added under Fixed (forwarded-address token limit,
https-only web push that never follows a redirect, Tab and Escape in Add
PC over a file preview).
- `release-summaries.json`: 0.4.1 in English, Korean, Japanese and
Chinese: four new, seven improved and eight fixed lines (eight is the
most a list keeps).

## After the merge

1. `git tag remote-v21 <merge commit> && git push origin remote-v21` →
the remote-bundles workflow publishes the five bundles and
`manifest.json` as release `remote-v21`.
2. Only then `gh workflow run release.yml --ref main -f version=0.4.1`:
preflight validates version, notes and summaries, CI runs on the exact
commit, and the tag `v0.4.1` with its GitHub release is created.

## Validation

- `bun run check fast` (workflow syntax, generated types, typecheck,
build, unit tests).
- `bun scripts/release-notes.ts 0.4.1` prints the notes (version sources
agree, one heading, summaries in all four languages).
Two changes the owner asked for, one commit each.

## 1. Clipboard from a pane (OSC 52) is on by default

#570 turned **Settings → Terminal → Clipboard from a pane** off by
default, so copying from vim, tmux and Claude Code silently did nothing.
It is on again; the switch stays for anyone running output they do not
trust.

**Migration: a new storage key.** Settings are saved as one whole
record, so any 0.4.1 install whose user changed *any* setting has
`terminalOsc52: false` stored without choosing it. The choice now lives
under `paneClipboard` (default `true`) and `terminalOsc52` is ignored
and dropped by `sanitizeSettings`, which already discards unknown keys.
That is the simplest correct option:
- a 0.4.1 record loads as on (its `false` cannot be told apart from a
real choice, so it is not trusted);
- an off chosen from now on is written under `paneClipboard` and stays
off across saves and reloads;
- no version field or one-off migration state is needed. A settings
version would do the same with more code.

Known edge: a tab still running the 0.4.1 bundle that saves settings
writes its own record, which has no `paneClipboard`, so a new "off"
would read as on again until it is re-chosen. Tabs pick up the new
bundle on reload, and settings already have no cross-tab sync (each tab
saves its in-memory record), so this is no worse than any other setting.

Tests (`src/lib/settings.test.ts`, "clipboard from a pane"): fresh
default on; a 0.4.1 record with `terminalOsc52: false` loads as on; an
off chosen after the change survives a save, a reload and an unrelated
change. A mutation that falls back to the old key fails the second case.

Also: the setting's description and code comments say what is true now,
ko/ja/zh entries are updated, and there is a CHANGELOG line under
Unreleased → Changed. No doc in `docs/`, `DESIGN.md`, `INSTALL.md` or
`README.md` mentions the default.

## 2. Patch-note style GitHub release notes

`scripts/release-notes.ts` printed the whole CHANGELOG section (181
lines for v0.4.1). It now prints the English lists from
`release-summaries.json` (`### New features`, `### Improvements`, `###
Bug fixes`, a list with no lines left out), then the full section inside
`<details><summary>Full changelog</summary>` with blank lines around it
so GitHub renders the markdown. Lines come through `readSummary`, so
they are trimmed and capped exactly as an install shows them. Every
validation is unchanged (version format, the three version sources,
exactly one heading, nonempty notes, all four languages).
`scripts/release-notes.test.ts` pins the shape, the order and an empty
list left out. `docs/development.md` → Releasing and `scripts/AGENTS.md`
describe the new body.

The published v0.4.1 release is not touched here; the lead regenerates
it after the merge.

### v0.4.1 rendered with this script (`bun scripts/release-notes.ts
0.4.1`)

### New features

- Typing /effort in a Claude Code chat opens a card to pick the effort
for this session
- Settings → Appearance can keep waiting and the latest agents on top of
the Agents list
- A finished agent you opened in the web UI can lose its dot, as it
would in herdr (opt-in)
- The guide shows how to give the app a public HTTPS address with Portal

### Improvements

- A wrong access token waits longer after five tries, up to a minute
- A program in a pane copies to your clipboard only once you allow it in
Settings → Terminal
- Dialogs keep Tab inside them, and screen readers announce tabs, panes
and status lines
- Add PC, Reconnect PC and Update remote bridge open as a bottom sheet
on a phone
- Updating a remote PC that is already current reuses its bridge without
a new download
- Alerts go only to https push services and never follow a redirect
- Long drafts wrap, and the scrollbar no longer covers text when the
page is zoomed

### Bug fixes

- A long line of unclosed brackets or broken escape codes no longer
freezes the chat
- On Windows, more Claude Code and Codex panes show their chat instead
of an error
- Your own devices are recognised by their Tailscale login again,
without pairing
- A Claude Code chat shows what a slash command answered, and /goal is
suggested
- Settings, the palette, Add PC and file previews stack in order; Escape
closes the top one
- The plugin's start keeps the app on its port while herdr is away
- A secret or typed text no longer reaches a pane you have already left
- Gajae Code's selection and startup menus show their choices, not only
arrow keys

<details><summary>Full changelog</summary>

### Added
- `/effort` sent from a Claude Code chat opens a card with the effort
levels, low to max, so the
level no longer has to be set in the terminal. A pick applies to this
session only, as a pick
from the `/model` card does, and leaves the default for new sessions
alone. `/effort` is also in
  the chat's command list now.
  ([#594](https://github.com/devswha/herdr-web-ui/pull/594),
  [#523](https://github.com/devswha/herdr-web-ui/pull/523) by @suho-han)
- Settings → Terminal has a **Clipboard from a pane** switch, off by
default: a program running in a
pane can no longer put text on your clipboard unless you turn it on.
Programs that copy this way
  (vim, tmux, Claude Code) copy again once it is on.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- The guide's **Behind a reverse proxy** shows how to give the app a
public HTTPS address with
[Portal](https://github.com/gosuda/portal-tunnel) v2.6.1 or later,
behind a long random token
  and with a visitor's `Tailscale-User-Login` header dropped.
([#229](https://github.com/devswha/herdr-web-ui/pull/229) by
@rabbitson87)
- **Settings → Appearance → Agents order → Activity** keeps a waiting
agent on top of the Agents
list and orders the rest by their latest state change, as herdr's agents
panel keeps the latest
work in view: the agent you just sent a message to stays on top while it
runs and after it
finishes, and a new one starts there. herdr's own order and the
workspace rows are unchanged.
  **Workspaces** (herdr's order) remains the default.
([#529](https://github.com/devswha/herdr-web-ui/pull/529) by
@phirschybar)
- **Settings → Appearance → Quiet opened finishes** (off by default): a
finished agent you have
opened in the web UI loses its dot and reads as ready, as viewing it in
herdr would make it.
herdr's DONE otherwise stands until herdr itself shows the pane. It is
remembered per PC in
this browser. ([#529](https://github.com/devswha/herdr-web-ui/pull/529)
by @phirschybar)

### Changed
- A wrong access token is refused with a growing wait after five tries,
up to a minute, whether it
is typed into the sign-in form or sent with a request. Each visitor
behind `tailscale serve` has
their own count, and a browser holding an old token never stops you
signing in with the new one.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- The app now sends itself a Content-Security-Policy, so third-party
content rendered in a chat —
  math, agent marks — cannot run script in the app.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- A web-push subscription must be an https endpoint.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)

### Fixed
- Gajae Code's selection menus show their choices instead of only
arrow-key buttons, including
startup selectors shown before the pane reports that it is waiting.
Answers move to the selected
  row and recheck the menu before confirming.
  ([#593](https://github.com/devswha/herdr-web-ui/pull/593))
- Secret input and the Codex follow-up fallback validate the live
screen, so a password
prompt or collapsed question queue in scrollback cannot send input into
the current program.
  ([#566](https://github.com/devswha/herdr-web-ui/pull/566))
- The PC's Tailscale login is read from a real Tailscale user id. These
ids are too large for a
JavaScript number, so the owner was not recognised and the owner's own
devices had to pair. Two
  logins with neighbouring ids are also no longer taken for one.
  ([#572](https://github.com/devswha/herdr-web-ui/pull/572))
- On Windows, a Claude Code pane started with a second account's
`~/.claude-*` directory as its
`CLAUDE_CONFIG_DIR` shows its chat. Before, the pane fell back to
`~/.claude`, so the chat said
**Conversation unavailable** and only the terminal worked. Windows does
not let the server read
another process's environment, so the store is the one among the
server's own
`CLAUDE_CONFIG_DIR`, `~/.claude` and the `~/.claude-*` directories
beside it that holds the
Claude process's own record, checked against the time the process
started. A directory
elsewhere is not looked in. Claude Code processes reported as
`claude.exe` are
  recognized too.
([#563](https://github.com/devswha/herdr-web-ui/pull/563) by
@David-Sousa-Web)
- A long line of brackets, `\(` or underscores that never close, as an
agent prints in a log or a
minified file, no longer freezes the chat: a megabyte of them took a
minute or more to read, and
  now takes milliseconds. What every message shows is unchanged.
  ([#574](https://github.com/devswha/herdr-web-ui/pull/574))
- On a mirrored pane (Windows, where herdr cannot attach a terminal),
text typed while a message
was still being sent no longer reaches the pane once you have left it,
or left it and opened it
  again, before the text's turn came.
  ([#576](https://github.com/devswha/herdr-web-ui/pull/576))
- The plugin's `start` keeps the app on its port when the app's own
server holds it but cannot
reach herdr. It used to move the app to another port beside the running
one and blame another
program; now it says that the app runs there without herdr and exits,
and the app answers
  again on its port once herdr is back.
  ([#577](https://github.com/devswha/herdr-web-ui/pull/577))
- On Windows, a Codex pane shows its chat when Codex stored its paths
with the `\\?\` prefix, as
it does for a canonical Windows path (`\\?\D:\work` for `D:\work`).
Before, the chat said
**Conversation unavailable**: the session's file seemed to lie outside
Codex's store, and none of
  the threads matched the pane's directory as herdr reports it.
([#582](https://github.com/devswha/herdr-web-ui/pull/582) by
@David-Sousa-Web)
- In a Claude Code pane, the chat shows what a slash command answered,
so a `/goal` that Claude
Code refuses says why in the chat instead of only in the terminal.
`/goal` is also among the
  commands the message box suggests.
  ([#583](https://github.com/devswha/herdr-web-ui/pull/583))
- Settings opened over a file preview is visible above it; Escape and
Back close Settings
first, preserving the preview and its history entry until the file
itself is closed.
  ([#568](https://github.com/devswha/herdr-web-ui/pull/568))
- Command palette buttons keep their native Enter action; IME commit and
cancel keys
stay with text input, and arrow navigation keeps the selected result
visible.
  ([#567](https://github.com/devswha/herdr-web-ui/pull/567))
- Long drafts in the message box wrap and keep a narrow scroll cue in
reserved space, so the
  scrollbar no longer covers text at fractional zoom.
([#522](https://github.com/devswha/herdr-web-ui/pull/522) by @suho-han)
- A secret sent from a pane you then left and opened again, while
another browser kept the pane
open, is no longer typed into the pane: you send it again from the pane
you opened.
  ([#588](https://github.com/devswha/herdr-web-ui/pull/588))
- A Claude Code slash command whose answer holds a long run of broken
terminal escape codes no
longer stalls the server while the chat reads it. Its answer also drops
a link cut off before
its end and a stray `B` after a charset switch, and an answer with both
output and errors
  shows both.
  ([#588](https://github.com/devswha/herdr-web-ui/pull/588))
- The command palette opened over Settings and a file preview shows
above both, instead of
  taking the keyboard unseen beneath them.
  ([#588](https://github.com/devswha/herdr-web-ui/pull/588))
- The plugin's `start` no longer takes another program on the app's port
for the app because
its answer says `ok`: it treats it as any other program there, moving
the app to a free port,
  or saying the port is taken when `PORT` is set.
  ([#588](https://github.com/devswha/herdr-web-ui/pull/588))
- A 500 from the server no longer repeats the system's own error text,
which carried absolute paths
and the herdr socket location; it names a short id you can quote in a
bug report instead.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- Settings, the command palette, the file viewer, the file browser and
the new-workspace dialog keep
  Tab inside them and give the focus back to whatever opened them.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- The tabs of a workspace name the pane region they govern, so a screen
reader announces the tab and
  the pane together.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- Agent headings in a chat no longer pose as the app's own page
structure; they sit below the app's
  own headings and look the same as before.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- The "reconnecting" line and the composer's terminal-only hint are
announced when they appear.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- A chat locked out by the token gate, the "Last checked" line under
Settings → About (with its
date in your language) and a remote PC's state word in the sidebar are
translated like the rest of
  the UI.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- The alerts menu item now says the same thing the same way in every
state.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- Held terminal input typed while disconnected is forgotten after a day.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- A link printed in the terminal opens only if it is an http(s) address,
on both link paths.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- A second tab open on one pane no longer sends a message the first tab
is already sending: it
sees that send on its way and holds back. Two tabs that press Send at
nearly the same moment can still
  both send it.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- A row's ⋯ menu is capped to the room its button leaves and scrolls
instead of being cut off by the
viewport, so the pane picker of a tab with many panes keeps every entry
reachable with the pointer
as well as the keyboard. Before, items below the fold were rendered but
unreachable.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- A row menu open while the window crosses the 640 px breakpoint now
switches between bottom sheet and
  popover instead of keeping the form it opened with.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- The workspace drawer a narrow window opened is closed again when the
window is widened past 768 px,
  so narrowing it no longer brings back a drawer and its scrim unasked.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- **Add PC**, **Reconnect PC** and **Update remote bridge** open as a
bottom sheet on a phone, like
every other dialog, and keep clear of the on-screen keyboard. Before,
the one native dialog stayed
  a centred card on a phone.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- **Remove PC**'s first click is a quiet ghost button that only arms the
removal; the second is the
  red one, as revoking a device already was.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- A PC's rename, connect and disconnect buttons disable while their
request is in flight, so a double
  click no longer sends two overlapping requests.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- A failed pane or workspace rename keeps the field open with what you
typed, so a network blip no
  longer makes you write the name again.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- A workspace reorder that fails no longer undoes a later, successful
reorder.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- A tab watching several busy panes gives up its oldest cached
conversation answers when they grow
  past a byte budget, not only past sixteen of them.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- The usage meters' note is the same size as every other advisory and
empty state.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- The PDF viewer's page colour, the pill radii, the tab dot and the
pairing-code size come from
design tokens now, and the pairing code follows the compact density
setting.
  ([#570](https://github.com/devswha/herdr-web-ui/pull/570) by @radicor)
- Updating an already current remote bridge verifies and reuses it
without downloading or
restarting it again, while a bridge from a newer app is left running
instead of downgraded.
A PC that waits on such a conflict says so in the sidebar and under the
header, with a
  **Reconnect** button, instead of asking for setup approval.
([#519](https://github.com/devswha/herdr-web-ui/pull/519) by @suho-han)
- A wrong access token sent through a proxy on this PC with a made-up
`X-Forwarded-For` address
that itself says " via " now counts against the limit every visitor
through that proxy shares,
like any other wrong token. Before, each such try started a fresh count.
  ([#592](https://github.com/devswha/herdr-web-ui/pull/592))
- A web-push subscription must be an https address, with no exception
for this PC, and an alert is
never sent on where a push service redirects it, so an alert can never
be posted to a service
running on this PC.
([#592](https://github.com/devswha/herdr-web-ui/pull/592))
- In the **Add PC** dialog opened over a file preview, Tab moves through
the dialog's own controls
and Escape closes the dialog alone, leaving the preview beneath it open.
  ([#592](https://github.com/devswha/herdr-web-ui/pull/592))

</details>


## Checks
- `bun run check fast`: ok (1817 pass, 6 platform skips, 0 fail).
- No browser regression covers OSC 52 (`terminal-copy-regression.ts`
covers drag and Ctrl+C copy), so none was run for it.

## Codex review (gpt-6-astra, read-only)
- **Fixed** (P2): summary text was put into the markdown as-is, so
`"…\n\n<!--"` could hide the whole changelog fold. The gate now refuses
a summary line with a line break in any language, and the English lines
escape `<`. A test covers both and fails without the fix.
- **Declined, documented** (P2): a tab still on the 0.4.1 bundle that
saves settings drops `paneClipboard`, so a new "off" reads as on until
it is chosen again. A versioned storage key would stop that, but then
every setting changed in such a tab would be lost instead. It lasts only
until that tab reloads, and every setting added before this one had the
same gap.
- **Pre-existing, not in this diff** (P2): settings have no cross-tab
sync, so a stale tab's next save writes its own copy of every setting,
this one included. Reported to the lead as a follow-up.
Refs #432 (left open until a native Safari 26.6.2 run).
## What

Anonymous install and update counts, announced and on by default
(opt-out):

- **When:** one event when an install first runs, one each time it runs
a new version, nothing in between. No event is sent before the app has
shown its one-time notice (a line under the header with **What is
sent**, **Turn off** and **Dismiss**), and the first one waits ten
minutes after it, so **Turn off** on the notice stops it. The notice is
recorded only once it was painted in a visible tab, and stays up until
the server confirms the switch is off.
- **What:** `event` (`install`/`update`), a random `install_id` made on
the PC, `version`, `previous_version`, `os`, `arch`, `install_method`
(`plugin`/`managed`/`source`). Nothing about terminals, agents, files or
accounts.
- **Off switch:** Settings → About → **Anonymous usage counts** shows
the next event exactly as it would be sent and turns it off.
`HERDR_WEB_TELEMETRY=0`, `DO_NOT_TRACK=1`, `CI` and the test
environments block it whatever the switch says.
- **Where it does nothing:** only the real entrypoint
(`server/index.ts`) passes a `Telemetry`, so `createServer` in tests,
remote-PC bridges and the demo answer `/api/telemetry` with 404 and the
app shows neither the notice nor the switch.
- **Receiver:** `telemetry/` is a Cloudflare Worker over one D1 table.
It never reads the sender's address or any header but the content type,
stores the day rather than the time, keeps each (install, event,
version) once, and has Workers Logs off. Deploy steps and the queries to
read the counts are in `telemetry/README.md`.

A failed send is logged once and tried again at the next start; it never
loops. Turning the switch off aborts a send in flight, the state file is
re-read before each decision (two servers on one state directory agree),
and a state directory that cannot be written leaves the app running with
telemetry silent.

## Contract

- `GET /api/telemetry` → `TelemetryStatus`; `POST /api/telemetry` `{
enabled?, notice_seen?: true }` with `x-herdr-update: 1` and same-origin
(`shared/telemetry.ts`, catalogued in `shared/protocol.ts`).
- `site/demo/transport.ts` answers 404 like a server without telemetry.
- `telemetry/` is added to `tsconfig.json` and to the unit-test glob in
`scripts/ci-tests.ts`.

## Before merging / releasing

- The lead deploys the Worker right after the merge (the D1 database
exists and its id is in `telemetry/wrangler.toml`; the account's
workers.dev subdomain is `devswha`, so `TELEMETRY_URL` in
`server/telemetry.ts` is right). Until then every send fails quietly:
one log line per start, retried at the next start.

## Verification

- `bun run test:unit`: 1790 pass, 0 fail (before rebasing onto v0.4.1);
after the rebase `bun run typecheck` and the telemetry, Worker and i18n
tests pass.
- `bun run build` passes.
- New tests: `server/telemetry.test.ts` (no send before the notice,
install once, update from the last reported version, switch, environment
blocks, retry after a refused send, route validation),
`telemetry/worker.test.ts` (stored fields, no address, refusals), and a
`telemetry API` block in `server/api.contract.test.ts` (404 without
telemetry, notice and switch through HTTP, token gate). The contract
block was not run locally; CI runs it.
- Browser check against a real server and a stand-in receiver
(Chromium): the notice shows on first open and the install event arrives
once; **What is sent** opens Settings → About; the switch turns sending
off on the server; after a reload the notice is not shown again; the
notice wraps cleanly at 390px in Korean.

## Review pass (merge lane)

- Merged `main` (v0.4.1, #598, #524); the changelog entry sits under `##
[Unreleased]`.
- Codex `gpt-6-astra` adversarial pass: six findings verified and fixed
in c74606b and 2f8a0bc, each with a test that fails without the fix
(`server/telemetry.test.ts`, `telemetry/worker.test.ts`); the rest are
listed in a comment below.
- `bun run check fast`: 1837 pass, 0 fail.
`server/api.contract.test.ts`: 96 pass. `scripts/update-browser-qa.ts`:
4 PASS.
- Browser check (Chromium, a real server and a stand-in receiver, 4 s
grace): the notice shows, nothing is sent until the grace passed and
then one install event arrives; a failed **Turn off** keeps the notice
with "Could not turn it off. Try again.", the retry closes it, the
switch is off on the server and nothing is sent after the grace; the
failure line fits at 390px.
Follow-ups to #529: three of the four low-severity notes from the merge
comment there. The fourth, a push lost inside the poll window, is left
as is, as discussed.

## Change

1. **Demo agents after the last one closes.** `site/demo/transport.ts`
takes the agent template once at startup, as it already does for panes,
tabs and workspaces. `addAgent` used to clone `snapshot().agents[0]`, so
once every demo workspace was closed, a workspace made afterwards was
listed with no `state_change_seq`.
2. **A damaged record and first use.** `anySeen()` counts only a
`herdr-web-ui:seen:*` record that `loadSeen()` accepts. Before, a
damaged value made it true while `loadSeen()` returned null, so turning
Quiet opened finishes on did not start quiet as DESIGN.md says.
3. **A possible flake.** The Activity case of
`scripts/sidebar-activity-demo-regression.ts` starts from an existing
empty record, so first-use seeding no longer races the demo's 4.5 s
self-finish on a slow load. Its storage assertion now says what an empty
start implies: the opened finish is recorded, the unopened one is not.

No user-visible change with the settings at their defaults, so no
CHANGELOG line. Happy to add one under Fixed for (2) if you'd like.

## Validation

- `bun run check fast` passes: workflow syntax, generated types,
typecheck, build, unit tests.
- (2) A new `sidebarOrder.test.ts` case stores damaged records and
checks `anySeen()` is false, then true once a readable one is there. It
fails without the fix.
- (1) A new case in `sidebar-activity-demo-regression.ts` closes every
demo workspace through the demo's API, creates one with an agent, and
checks that its `agents` entry has a `state_change_seq`. On main's
transport it fails (`"agents":[]`).
- (3) The script passes three runs in a row, all three cases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_018AND7SgU9fTx9Qb6DPqnAs

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Hako <devswha@gmail.com>
## Change

A chat message that starts with `/` and names a command Claude Code does
not know seemed to vanish. It left the chat's box and never showed in
the chat.

Reproduced live on Claude Code 2.1.294, with the message sent exactly as
the composer does (bracketed paste, then Enter):

- Claude Code refuses it in the terminal (`● Unknown command: /xyzabc`,
`● Args from unknown skill: hello there`) and clears its input. Nothing
reaches the model.
- The transcript records **no user turn** for it, only two entries:
  ```json
{"type":"system","subtype":"informational","content":"Unknown command:
/xyzabc","level":"warning",...}
{"type":"system","subtype":"informational","content":"Args from unknown
skill: hello there","level":"warning",...}
  ```

#583 shows a slash command's answer from `local_command` entries, and
its test has `Unknown command: /gaol` inside `<local-command-stderr>`.
This Claude Code version writes the refusal as `informational` instead,
which the chat skipped.

`parseClaudeTranscript` now shows `system`/`informational` entries as
notices (`source: "informational"`), drawn the way #583's are:
- Terminal codes are stripped with `stripTerminalControls`, and the text
is capped like a command's answer.
- Lines written within a second of each other join into one notice. The
two lines above are 1 ms apart and read as one notice: `Unknown command:
/xyzabc` with the dropped arguments under it.
- An empty entry is skipped.

The same subtype carries Claude's usage-limit notices (`Usage limit
reached · continuing automatically at 4am · esc to cancel`, `Usage limit
reset · continuing automatically`), which now show too. Those and the
unknown-command pair are the only `informational` contents across this
machine's transcripts from the last 30 days.

## Validation

- New `server/conversation.test.ts` case: the two-line unknown command
joined into one notice, the two usage-limit lines kept apart, terminal
codes stripped, an empty entry skipped. It fails on `main` and passes
here.
- Live, against Claude Code 2.1.294 in an isolated herdr session: `GET
/api/pane/conversation` returns the notice. In the chat it reads
`Unknown command: /xyzabc` and opens to both lines.
- `bun run test:unit` 1814 pass, `tsc --noEmit` clean.

---------

Co-authored-by: Yoonwoo-Ha <274636136+Yoonwoo-Ha@users.noreply.github.com>
Co-authored-by: Hako <devswha@gmail.com>
## Change

A chat message with an invisible character in it (a zero-width space, a
joiner, a byte-order mark; pasted text often carries them) seemed lost.
The chat cleared its box, but the message never reached the agent.

Reproduced live on Claude Code 2.1.294, sent exactly as the composer
does (bracketed paste, then Enter):

```
                       Removed 1 invisible character · review and press Enter to send
────────────────
❯ helloworld test
────────────────
```

Claude Code takes the characters out and, instead of sending, **keeps
the message in its input** until it is reviewed. The chat has already
cleared its box by then.

That hint does not stay. With a turn already on screen its row goes to
the status line (`✻ Cooked for 1s · done`): in 20 seconds of polling
after an Enter it never showed, while `❯ timingtest` sat in the box the
whole time. So the hint cannot be the only signal.

**A card for the held message** (`parseClaudeHeld`, responder
`claude-held`):
- **When it shows.** Claude's input box holds a message, and either:
  - the hint is right over the box, or
- the box holds what the chat last sent to this pane, less only
invisible characters.
- **Remembering the send.** For the second case `submitText` records the
chat's last message per pane (`noteSubmitted`), only when it carried
such characters, for 10 minutes.
- **Matching.** `removedInvisible` compares the two without whitespace,
since the box wraps a long message. Every character the box lacks must
be invisible: format controls (zero-width, bidi, tags, BOM), combining
marks, or Hangul fillers.
- **The card.** It names how many characters were removed and shows the
message as Claude holds it.
  - **Send** presses Enter.
  - **Discard** presses Ctrl+C, which clears Claude's input.
- Like every card, an answer first checks that the screen still shows
it.
- **When it doesn't show.** Nothing else is a held message: the hint
over an empty input (it lingers after a send), a box that differs in
anything visible, or a quoted hint with output under the box. Typing a
new draft removes the hint.

## Validation

- New `server/prompt.test.ts` cases, built on the live screen:
- the hint and the box give a card, with Send → `enter` and Discard →
`ctrl+c`;
- a held message of several lines, and the count of removed characters;
- the box alone, plus what the chat sent, gives a card; a box that
differs, or no send, gives none;
- no card for an empty input, a quoted hint, or the words without a box;
- `removedInvisible`, including U+FEFF (which `\s` would have taken for
a space) and a Hangul filler.

  Two of these fail on `main`.
- Live, Claude Code 2.1.294 in an isolated herdr session, through this
server:
- `POST /api/pane/prompt/answer` with **Discard** emptied Claude's
input.
  - With **Send**, Claude answered the message.
- In the browser: a message typed into the chat composer with a
zero-width space produced the card. **Send** cleared the card and Claude
replied.
- `bun run test:unit` 1818 pass, `tsc --noEmit` clean.

---------

Co-authored-by: Yoonwoo-Ha <274636136+Yoonwoo-Ha@users.noreply.github.com>
Co-authored-by: Hako <devswha@gmail.com>
## Summary
<!-- bigpowers-provenance: agent-generated -->

Clicking an agent status notification now opens that pane in the Agent
chat view. This is a one-shot routing override; it does not replace the
pane's saved view preference.

## Change

- Carry `view: "chat"` with the machine/pane target in service-worker
messages and cold-start URLs.
- Validate and preserve the requested view in notification target
parsing.
- Apply the override only to the matching machine and pane, and clear it
after manual pane/view navigation.
- Include a regression test and screenshot from an isolated browser test
session.

## Validation

- `bun run generate:types --check` — passed.
- `bun run typecheck` — passed.
- `bun run build` — passed.
- `bun run test:unit` — 1,817 passed, 6 skipped.
- `bun run test:integration` — 287 passed, 0 failed, 0 skipped.
- `bun run test:ui` — passed, including the one-shot notification/chat
regression.
- `git diff --check origin/main...HEAD` — passed.

![Isolated browser test showing Chat selected while Terminal remains
available](https://raw.githubusercontent.com/Xianbei233/herdr-web-ui/fix/notification-agent-chat/pr-evidence/notification-chat-one-shot.png)

---------

Co-authored-by: Hako <devswha@gmail.com>
## What this fixes

Herdr recognizes a running Devin CLI pane, but the web UI has no Devin
conversation reader, leaving it terminal-only. This adds native history
rendering rather than scraping terminal output.

## Implementation

- Read Devin CLI's SQLite WAL store through a read-only Bun SQLite
transaction. The contributor reports Devin CLI 3000.11.3, schema 17;
regression fixtures are synthetic.
- Require an exact live Devin executable and an explicit session ID from
herdr or resume arguments, then verify the session's directory in the
store. Never select by directory or recency alone.
- Follow the selected main-chain ancestry, excluding abandoned branches;
render user/assistant messages and paired tool calls/results using
existing chat parts.
- Preserve terminal-text fallback for unresolved identities and missing,
locked, malformed or oversized stores. Only invalid/stale history
cursors return 409 `history_changed`.
- Bound ancestry to 5,000 nodes and UTF-8 payloads to 8 MiB. Read
complete, bounded tool-state JSON before trimming displayed output with
a visible marker.
- Keep pagination and ETags, including model-only changes. Cache
generations prevent old branch pages from surviving a restart, reset or
eviction; pane teardown and test resets release the cache.
- Include demo transport, documentation, contributor credit and browser
regression coverage. Latest main `5efc2ab` is integrated; head is
`6b10d87`.

## Verification

- Workflow syntax, generated types, typecheck and production build:
passed locally.
- Unit suite: **1,862 passed, 6 skipped, 0 failed**.
- Isolated integration suite: **291 passed, 0 failed**, including **97
HTTP/API contract tests**.
- Focused `chat-history-browser-qa.ts`: passed, including Devin bubbles,
expanded tool output, and switching to Claude without retaining Devin
history.
- Full browser lane (`CI= bun run check browser`): passed.
- Current GitHub CI:
https://github.com/devswha/herdr-web-ui/actions/runs/37763852818
- Current remote bundle/SSH/Windows smoke workflow:
https://github.com/devswha/herdr-web-ui/actions/runs/37763852801

The first local `check full` browser lane encountered the host's
inherited `CI=1` and could not perform privileged dependency
installation. The browser lane was rerun in local mode (`CI= bun run
check browser`). The existing build chunk-size warning is unchanged.

## Boundaries

- No fresh real Devin CLI installation or physical iPhone validation was
performed in this maintainer review. Tests use synthetic stores and
isolated test/demo sessions, never the user's live panes.
- The reader still traverses and hashes its bounded active chain on each
poll; unchanged-store caching is not part of this PR.
- Long tool output has a truncation marker but no fetch-rest endpoint.
SQLite may create WAL coordination files even with the database opened
read-only.
- No merge or auto-merge has been performed.

---------

Co-authored-by: Hako <devswha@gmail.com>
## What

- **New address.** The website and every install command now name
https://herdrweb.dev/. The Pages custom domain is already live (Let's
Encrypt cert, HTTPS enforced), and GitHub redirects every
`devswha.github.io/herdr-web-ui/` path there with a 301, so `curl -fsSL
https://devswha.github.io/herdr-web-ui/install.sh | sh` and the
PowerShell one keep working (checked: they end on
`https://herdrweb.dev/install.sh`). `herdrapp.dev` 301s to
`herdrweb.dev` with the path kept. Changed: READMEs, INSTALL.md, guide,
development and CONTRIBUTING docs, installer comments,
`scripts/plugin.ts`, the Settings link, the film end card, the Windows
install workflow, and the page's canonical, og and sitemap URLs. Past
CHANGELOG entries keep the old address.
- **Simplified Chinese page.** `site/zh/index.html` is the page in
Simplified Chinese at `/zh/`. The header links `中文` ⇄ `EN`, and both
pages carry hreflang alternates. `build-site.ts` now fills in the
figures, unlinks missing media and writes the FAQ structured data for
both pages, and lists both in `sitemap.xml`. The clip captions come from
`README.zh-CN.md`; the mock agent screens (Codex prompt, task titles)
stay in English, as the agents print them.
- **Front-page copy for v0.4.0.** README (en/ko/ja/zh-CN) and the page
now mention the customizable key bar (#508), typing the next message
while an agent works (#520), and patch notes per update (#535, #560);
the comparison's "Also" row mentions the four UI languages. The
ko/ja/zh-CN READMEs gain the Windows install section, which only the
English one had, and the Korean README the voice-input line.
- **Header width.** With the language link, the header now fits from
316px up on both pages: under 560px the star chip hides (the stats strip
right below shows the same count), and the nav hides under 960px instead
of 900px.

## Not in this PR

- The feature-clip and hero videos (and their alt text, which still
names the old sidebar's **Needs you** and DONE/RUN labels) will be
re-shot separately.
- Devin CLI (#438) and anonymous usage counts (#599) are unreleased, so
the front page does not mention them yet. The privacy FAQ ("Does my code
or conversation leave my machine?") should mention the counts in the
release that ships #599.
- Google Search Console verifies the old URL; `herdrweb.dev` needs its
own property.

## Checks

- `bun run check fast`: 1776 pass, 0 fail.
- `bun scripts/build-site.ts`: exit 0; `_site/zh/index.html` gets its
figures and FAQPage data, and `sitemap.xml` lists both pages.
- Both pages served from `_site/` in headless Chromium at 390 and
1440px: no 4xx, no page errors. Horizontal overflow measured every 2px
from 300 to 1200px: none on `/zh/`, none on `/` from 316px up.
## Change

Keep app shortcuts as configurable Mod+Shift bindings and keep the
mobile key bar a terminal input device, not an app-action toolbar.

- Resolve command-palette hints from the saved mapping; disabled
shortcuts have no hint.
- Recognize non-Latin physical letter keys outside IME composition,
sharing the terminal's physical-key convention and keeping Latin-layout
letters intact.
- Mark a narrow set of known browser/OS reservations and explain the
currently selected conflict without rejecting or silently changing saved
bindings. The list is intentionally not exhaustive.
- Keep shortcut selects readable on narrow screens and explain global
versus focus-local keys and device-local settings
(English/Korean/Japanese/Chinese).
- Start fresh/restored mobile bars with Esc, Tab and Ctrl+C. Preserve
explicit custom layouts, empty layouts and legacy extra-key ordering.
- Update the guide, design rules and existing CI browser regression. No
HTTP/WS changes or new demo transport behavior.

This does not replace #480 (tab reordering) or #602 (split view). Their
shortcut paths were reviewed separately; both currently conflict with
main and lack the required CI runs. Review comments document the
remaining checks/fixes rather than closing either PR as superseded.

## Validation

On Linux, Bun 1.4.2 / Node 24.18.0:
- `bun run check fast` on the rebased branch: workflow syntax, generated
types, typecheck, build and unit suite pass; **1853 pass, 6 skip, 0
fail**.
- `bun scripts/key-bar-customization-demo-regression.ts`: PASS.
Rebinding/Off/reset/persistence and palette hints, reservation warning
without rewriting the setting, non-Latin shortcut with no terminal
input, IME guard, Ctrl+C visibility at 320/390px, legacy/custom layouts
and held modifiers.
- `bun scripts/settings-pages-demo-regression.ts`: PASS. All settings
pages at 320/390px, Back/Forward/Escape and responsive history behavior.
- `bun run check run bun scripts/terminal-input-regression.ts`: PASS on
an isolated herdr, including input readiness, drafts, IME and shortcut
interception.
- UI evidence below uses fictional demo fixtures only. Non-Latin browser
events are synthetic; physical phones, macOS and real non-Latin hardware
layouts were not tested. Production build retains the existing
large-chunk warning.

| Shortcut settings | Default terminal keys |
| --- | --- |
| ![Phone shortcut
settings](https://raw.githubusercontent.com/devswha/herdr-web-ui/49e198e52d46631678867661829bd31317866790/docs/screenshots/shortcuts/settings-phone.png)
| ![Phone terminal key
bar](https://raw.githubusercontent.com/devswha/herdr-web-ui/49e198e52d46631678867661829bd31317866790/docs/screenshots/shortcuts/key-bar-phone.png)
|
Closes #538

## Change

An OpenCode pane had no native chat: herdr already reports its session
(`agent: "opencode"`, `agent_session: {kind: "id", source:
"herdr:opencode", value: "ses_…"}`), but the conversation endpoint
answered `{"source":"scrollback","turns":[]}`. This adds a transcript
adapter for OpenCode, so the chat reads the session itself.

**Where the store is (checked against OpenCode 2.0.24).** OpenCode 2
keeps every session in one SQLite database, written by its background
service (`opencode serve --service`), not by the TUI in the pane. Its
path follows the rules `opencode debug paths` prints:
`$XDG_DATA_HOME/opencode/opencode.db`, else
`~/.local/share/opencode/opencode.db`, and `OPENCODE_DB` (relative to
the data dir, or absolute; `:memory:` is no store). The service is
started with the user's environment, so the server's environment finds
it; the pane's process environment would not. A conversation is the
`session_message` rows of the session (`seq` orders them, `type` + JSON
`data`); `session_v2` holds the session and a staged `/undo`. OpenCode
1.x wrote `session`/`message`/`part` instead, and 2.0 copies those into
`session_v2` when it migrates; a store without `session_v2` keeps the
terminal.

**New: `server/opencode.ts`**
- **Binding:** the pane's `agent_session` from the snapshot, only when
herdr's integration reported it (`source: herdr:opencode`), of kind
`id`, with an id matching `ses_[A-Za-z0-9]+`. No cwd guess. At
OpenCode's home screen there is no session, so the terminal stays.
- **Reading:** the database is opened per request with `readonly` and
`create: false` (a missing path is never created), with a 250 ms busy
timeout, and closed again. Every query is parameterized.
- **Platforms:** nothing here is Linux-only.
- OpenCode's data dir has no per-platform branch (`XDG_DATA_HOME ||
~/.local/share`, plus `/opencode`, on macOS and Windows too), and the
server resolves it with the same `os.homedir()` and `path` rules.
- On macOS Bun uses Apple's own SQLite, which can predate `octet_length`
(3.43) and need not include JSON1. So a row's size falls back to
`length(CAST(data AS BLOB))` when `octet_length` is missing, and the
settings are parsed in JS instead of with `json_extract`.
  - A Windows shell's CRLF is trimmed from a `!` command's output.
  - No `/proc`, process environment or shell is read.
- **Rows to turns:**
- Prompts show `metadata.displayText` when present, plus their pasted
images (inline base64 only: an attachment by path is never followed) and
a chip for each skill the prompt mentions.
- A model step is a row of its own, so consecutive steps merge into one
assistant turn until OpenCode's `idle` row or a step that finished with
`stop`. Reasoning becomes thinking; tool calls carry their output, their
error, a non-zero shell exit, the `skill` tool's chip, the query of a
web search, and the pictures a tool returned.
- Outputs are cut like the other agents' and fetched whole through the
existing `/api/pane/conversation/tool-output` route (ref `<message
id>:<place in its content>`). Images go through
`/api/pane/conversation/image` (ref `opencode:<message id>:<nth
image>`).
- A command run with `!` is a notice (`$ cmd` plus its output). OpenCode
echoes that command to the model with the next prompt, and the echo is
skipped. A background job's or subagent's result, or a restart notice,
is a notice that starts its own turn. A completed compaction is the
compaction card. Instructions, agent and model switches stay out.
  - An error is shown as `Error: …`, except Esc's `aborted`.
- **Metadata:** model and reasoning variant (`default` reads as none)
from the latest step or `/model` switch. The context ring counts `input
+ output + reasoning + cache.read + cache.write` of the last step with
usage, unless a compaction came after it. That is OpenCode's own footer
figure; it matched the TUI exactly in live checks (3.8K, 4.7K, 4.6K
after `/undo`). No window is stated: OpenCode Go's models are not in its
`models.json` cache.
- **Paging:** cursors name a `seq` instead of a byte offset. Otherwise
the pages follow the JSONL readers' rules: 50 prompts, a 16 MB window
measured with `octet_length` (which does not read the rows), a newest
page that may start mid-turn, older pages that reach back up to four
windows, and `before`/`since`/`from` with 409 for a foreign cursor.
- **History id and caching:** the history id changes while an `/undo`
hides rows (`session_v2.revert`). Once the next prompt deletes them, any
cursor naming a deleted row is refused with 409, checked in the store
itself, so it holds after a restart. A chat whose pages reach the
deleted turns always holds such a cursor, because the deletion runs from
the undone message to the end, so it reloads; pages before the undo
stay. An unchanged session answers from memory, which keeps the ETag /
304 path working. While a step streams in place, only the rows whose
`time_updated` moved are parsed again.

**`server/conversation.ts`:**
- Routes `agent === "opencode"` to the adapter.
- Adds `opencode-transcript` to `RecognizedConversation["source"]`. The
byte-stream helpers are typed with `StreamSource` (the union without
it), so a database is never handed to a file reader.
- Wires the image and tool-output routes, and adds
`forgetOpencodeState()` to `forgetTranscriptState()`.

**`shared/protocol.ts`:** `ConversationResponse.source` gains
`"opencode-transcript"`. The client already treats every source but
`scrollback` as a conversation, so `src/` needs no change.

Docs: the supported-agents table in `docs/guide.md`, the agent lists in
the four READMEs and `site/index.html`, `server/AGENTS.md`'s chat-lens
row, and a CHANGELOG line under Unreleased.

Not in this PR (left for later, as the issue says): prompt cards for
OpenCode's permission and question prompts, and OpenCode's slash
commands in the palette.

## Validation

- `bun run generate:types --check`, `bun run typecheck`, `bun run
build`: pass.
- `bun run test:unit`: 1530 pass, 0 fail. New `server/opencode.test.ts`
(23 tests): store paths, binding, every row type above, paging (pages
meet exactly, `since`, `from`, foreign cursors), `/undo` staged and
committed (including a cursor into deleted turns on a cold read), the
in-place streaming cache, a 1.x store / a missing file (not created) / a
non-SQLite file, page starts with a small window, tool output and images
by ref, including another session's message and one an `/undo` hid, and
the byte-size fallback. Paths are built with the platform's `path`
module and every database is closed before the temp dir is removed, so
the file also runs on macOS and Windows. The fallback was also run end
to end against the real store by forcing the `octet_length` probe to
fail: pages, stitching and metadata were identical.
- `bun run test:integration`: 235 pass, 0 fail across 22 files. New
`server/opencode.contract.test.ts` (4 tests) on a real herdr: a stand-in
`opencode` process holds the pane, the session is reported the way
herdr's OpenCode integration reports it, and the server is handed a
store of the test's own through `createServer({ opencodeDb })`. It
covers the conversation over HTTP with 304, paging and 409, tool output
and image over their routes, and a session the store does not hold
falling back to scrollback.
- `bun run test:ui`: 78 pass, 0 fail.
- `bun run build:site`: builds.
- **Live, OpenCode 2.0.24 with DeepSeek V4.1 Flash (OpenCode Go)** in an
isolated herdr session (`herdr-web-ui-test-oc`, herdr 0.9.3), with a
server of its own and a temporary state dir:
- Prompts sent from the chat composer reached the TUI and came back as
folded turns ("Worked for 4s · 1 edit · 1 command", "Ran …", "Read …").
A slow `sleep 8` showed "Working…" live, a tool's PNG loaded through the
image route, a `!` command showed as a notice with no duplicate echo,
and a background subagent's result showed as its own notice, with the
answer after it as a new turn.
- `/undo` hid the turn and changed the history id. The next prompt
committed it, and the client reloaded the history.
- Read-only against the real store (1.7 GB, about 2,000 sessions): a 133
MB session's newest page took 44 ms cold and about 1 ms when unchanged.
Walking every older page of a 158-prompt session gave exactly the turns
of a whole-session parse. On the byte-bound sessions the parts matched
exactly, with turns split only where a page starts mid-turn, as with the
JSONL readers. In the browser that session paged back through three
`before` pages to "beginning of conversation".

---------

Co-authored-by: Hako <devswha@gmail.com>
fix(sidebar): a finished pane reads ready once you view it
Some checks failed
CI / Fast checks (pull_request) Waiting to run
CI / Integration and browser (pull_request) Waiting to run
CI / Native Windows install (pull_request) Waiting to run
Remote PC bundles / bundle (macos-15) (pull_request) Waiting to run
Remote PC bundles / bundle (macos-15-intel) (pull_request) Waiting to run
Remote PC bundles / bundle (ubuntu-24.04) (pull_request) Waiting to run
Remote PC bundles / bundle (ubuntu-24.04-arm) (pull_request) Waiting to run
Remote PC bundles / bundle-windows (pull_request) Waiting to run
Remote PC bundles / ssh (pull_request) Waiting to run
Windows install / Real Windows install (pull_request) Waiting to run
Remote PC bundles / smoke-windows (pull_request) Has been cancelled
Remote PC bundles / release (pull_request) Has been cancelled
6b3bb5a1ad
The browser now tells the bridge when a pane is visible and focused; only then does done read ready. Attaching alone does not count.
gabrielgad closed this pull request 2026-10-22 14:57:23 -04:00
Some checks failed
CI / Fast checks (pull_request) Waiting to run
CI / Integration and browser (pull_request) Waiting to run
CI / Native Windows install (pull_request) Waiting to run
Remote PC bundles / bundle (macos-15) (pull_request) Waiting to run
Remote PC bundles / bundle (macos-15-intel) (pull_request) Waiting to run
Remote PC bundles / bundle (ubuntu-24.04) (pull_request) Waiting to run
Remote PC bundles / bundle (ubuntu-24.04-arm) (pull_request) Waiting to run
Remote PC bundles / bundle-windows (pull_request) Waiting to run
Remote PC bundles / ssh (pull_request) Waiting to run
Windows install / Real Windows install (pull_request) Waiting to run
Remote PC bundles / smoke-windows (pull_request) Has been cancelled
Remote PC bundles / release (pull_request) Has been cancelled

Pull request closed

Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
gabrielgad/ovis-web-ui!10
No description provided.