Skip to content

chore(sync): mescla upstream v0.4.1 - #19

Merged
tbc-joaovitor merged 54 commits into
mainfrom
sync/v0.4.1
Oct 9, 2026
Merged

tbc-joaovitor merged 54 commits into
mainfrom
sync/v0.4.1

Conversation

@tbc-joaovitor

Copy link
Copy Markdown
Collaborator

Mescla a release upstream v0.4.1 (com a v0.4.0) de devswha/herdr-web-ui no fork, e depois o main atual (#17).

Mergear com MERGE COMMIT, não squash (squash faz o próximo sync conflitar em tudo; referência: #8).

O que o upstream traz

v0.4.0

  • Configurações em páginas (Aparência, Chat, Terminal, Alertas, Voz, Uso, Atalhos, Celular e dispositivos, PCs remotos, Sobre), com cartões e Voltar do celular saindo por etapas.
  • "Novidades" antes e depois de cada atualização, em forma de patch notes (Novidades / Melhorias / Correções).
  • Sidebar refeita: uma grade só, linhas de workspace com pasta e dobra de worktrees, uma ou duas linhas, menu no botão direito, largura redimensionável, e uma lista Agentes única para todos os PCs (ordem do herdr ou por Atividade).
  • Barra de teclas do celular editável, com Ctrl/Alt/Shift fixos e Enter.
  • Um só botão Enviar/Parar no chat; mensagem enviada durante o trabalho vai no próximo turno (fila no servidor, pending-input).
  • Microfone em Auto/Ligado/Desligado; caixa de mensagem segue a fonte do chat.
  • Correções: chat que travava com certas mensagens, alertas e som que se perdiam, painel de agente recém-iniciado reconhecido na hora, entre outras.

v0.4.1

  • /effort no chat do Claude Code; "Silenciar concluídos já abertos" (opcional); ordem dos Agentes por Atividade.
  • Segurança: limite progressivo para token errado, Content-Security-Policy no app, push só para https e sem seguir redirecionamento, erro 500 sem caminhos internos, área de transferência pelo painel desligada por padrão.
  • Acessibilidade: diálogos prendem o Tab e devolvem o foco, abas nomeiam a região do painel.
  • Ponte remota já atualizada é reaproveitada; conflito de ponte avisado com Reconectar.
  • REMOTE_BUNDLE_VERSION 19 → 21.

Conflitos e como foram resolvidos

  • Sidebar (decisão de produto aprovada): estrutura do upstream + por cima o Precisa de você por PC (com /api/pane/seen, acknowledged, dobra e useStoredFold), cabeçalho Projetos (contagem, + novo projeto, dobra), Histórico, + Nova aba em cada linha, redação "projeto" e rodapé (Adicionar PC, Sair do portal, linha de versão). A seção Agentes por PC do fork saiu em favor da lista global do upstream; o agrupamento Por pasta saiu com o upstream. "Silenciar concluídos já abertos" não contradiz o visto do servidor: o Precisa de você deixa de fora um DONE que esse ajuste já mostra como pronto (descrito no DESIGN.md).
  • server/index.ts: paneSeen do fork + fila pending-input do upstream; onGap mantido (também atualiza a fila); regra fileRead do watch-guard mantida com workspace/history e pane/changed-files/diff, mais o directoryListing novo do upstream. Dispositivo watch agora também não envia alerta de teste (decisão do upstream, aceita).
  • App.tsx: split view, pins de layout, feedback, arquivos modificados e visto do fork + painel de abas, redimensionador e seedStatuses do upstream.
  • Configurações: reescrita em páginas do upstream + servidor de transcrição e "Arrumar desligado" do fork.
  • FileViewer / RowMenu / PaneTerminal: editor, mudanças e modos de visualização (feat(files): árvore de arquivos, filtro por nome e .md/.html formatados #17) do fork + focus trap/keyboardActive, maxHeight e composição IME do upstream.
  • i18n: pt na lista de idiomas e dos resumos de versão (o teste exige); 78 strings novas traduzidas, sem nome "herdr" novo visível em pt; entradas mortas removidas.
  • CHANGELOG: Unreleased do fork acima de [0.4.1] e [0.4.0].
  • design-system: bundle.css regenerado a partir de src/components/*.css (seções do Portal mantidas), tokens novos (--pdf-page, --fs-pairing, --pending-max-h, --z-palette), RosterRow README/preview no novo layout.
  • Testes adaptados ao comportamento do fork: abrir um DONE marca visto (sidebar-activity), aba aberta do celular nomeada pelo painel, configurações em páginas (voice-server), lente pelo setLens (sticky-modifiers), corrida do 502 no needs-input, título markdown como .markdown-h1 (files-tree), checagem da seção Agentes por PC trocada por uma da seção Projetos; directory-groups-regression removido.

Testes

  • bun run typecheck, bun run build: ok. bun run test:unit: 2168 pass, 0 fail.
  • Navegador, todos verdes: ui-regression (com needs-input e header-controls), feedback, changed-files, files-tree, file-viewer (desktop e celular), key-bar-customization, settings-pages, sticky-modifiers, chat-history, math, keyboard-viewport, droplet, chat-greeting, composer-fit, held-rows, sidebar-activity, voice-server, machine-dialog, machine-conflict, workspace-rows, terminal-draft, mobile-tabs.
  • Falhas conhecidas:
    • split-regression ("o cabeçalho mantém espaço para o título") falha igual no origin/main: já existia.
    • prompt-dock-demo-regression em 390×500 com fonte 24px: a faixa de abas sempre visível no celular (fork) somada à caixa de mensagem maior do upstream (fix(chat): the message box follows Chat font size devswha/ovis#515) deixa o cartão sem espaço para a opção 1. Passa no main e no upstream separados; fica para um PR de acompanhamento.
  • Os testes de navegador rodaram cada um numa sessão herdr de teste própria (HERDR_TEST_SESSION): a sessão compartilhada herdr-web-ui-test tinha 26 workspaces browser-a/b sobrando de execuções antigas, o que quebrava a busca da paleta (e explica parte da "instabilidade" vista antes).

🤖 Generated with Claude Code

phirschybar and others added 30 commits October 6, 2026 05:07
## Change

Settings → Chat font size applies to the message box as well as the
transcript and prompt cards. A mouse uses the transcript's scaled size;
touch keeps a 16px floor and grows with larger sizes. The default
remains 15px with a mouse and 16px on touch.

Changing the setting while a multiline draft exists remeasures the
automatic input height after the CSS setting applies. Drafts remain
intact, and heights chosen with the resize grip remain chosen. The
setting description, translations, design documentation and Unreleased
changelog describe the resulting behavior.

## Validation

- Final full unit suite on macOS with Bun 1.4.2: 1,458 passed, 15
skipped, 0 failed.
- Generated type freshness, typecheck, production build and git diff
check passed.
- Full composer-fit-demo browser regression passed on isolated fictional
demo fixtures.
- Browser coverage checks default/selected computed sizes at 390px and
1440px, live size increases and decreases with an existing draft,
preserved draft text, manual height preservation and returning to
automatic height.
- The new live-setting regression fails against the original PR head.
- Required integration/browser CI runs against this PR's final commit
before merge.

### Screenshots

Before
<img width="1280" height="800" alt="01-composer-before"
src="https://github.com/user-attachments/assets/d45b28d3-6001-4043-91ae-8db2a260269f"
/>

After
<img width="1280" height="800" alt="01-composer-after"
src="https://github.com/user-attachments/assets/57b510b9-e741-472f-8033-f1f173dcff37"
/>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: devswha <25837994+devswha@users.noreply.github.com>
## Change

Codex model records retain internal memory citation blocks that display
records omit. The parser removes this metadata before pairing the
records, so the chat and copy actions receive one clean answer.

Preserve literal examples in inline code and fenced code, including
fences inside quotes and lists. Complete and unfinished metadata outside
code stays hidden. User messages, tool output, final-answer phases and
terminal rendering keep their behavior.

The changelog entry is under Unreleased with the original contributor's
PR link and credit.

## Validation

- Final full unit suite on macOS with Bun 1.4.2: 1,467 passed, 15
skipped, 0 failed.
- Generated type freshness, typecheck, production build and git diff
check passed.
- Regression coverage includes display/model pairing in both orders,
complete/multiple/unfinished metadata, literal inline and fenced
examples, nested quote/list boundaries, metadata containing Markdown
delimiters, bounded malformed backtick input, and incremental snapshot
isolation.
- The original quoted-tag regression was reproduced against the prior
implementation.
- Required integration/browser CI runs against this PR's final commit
before merge.

## Screenshots

Before: the reporter's phone screenshot with identifying host and pane
names, IP address, private reply text, memory entries, rollout ID, and
timestamps covered. The citation tags remain visible. This is an
image-edited redaction of the supplied screenshot.

After: the real browser client displaying output from the fixed Codex
parser in an isolated fictional demo. This is a separate test
conversation, not the reporter's live session. The demo pane's chrome
says Claude; its conversation is parsed as Codex.

| Before (redacted user report) | After (isolated phone demo) |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/JJLiebig/herdr-web-ui/evidence/codex-memory-citations/.github/pr-evidence/codex-memory-citations-before-redacted.png"
width="300" alt="User report with personal details redacted; raw memory
citation tags visible"> | <img
src="https://raw.githubusercontent.com/JJLiebig/herdr-web-ui/evidence/codex-memory-citations/.github/pr-evidence/codex-memory-citations-phone.png"
width="300" alt="Fixed Codex parser shows one clean reply with no
citation metadata"> |

<details>
<summary>After: desktop demo</summary>

![Desktop demo with one clean
reply](https://raw.githubusercontent.com/JJLiebig/herdr-web-ui/evidence/codex-memory-citations/.github/pr-evidence/codex-memory-citations-desktop.png)

</details>

---------

Co-authored-by: devswha <25837994+devswha@users.noreply.github.com>
…swha#521)

## Change

Workspace rows used to show the active pane's title, which hid the
workspace's identity and the agents running in its other tabs. This PR
separates workspace navigation from a live Agents list, keeps herdr's
workspace and worktree structure, and draws the whole sidebar on one
quiet grid, led by the coding agent that runs in each row.

- **One grid.** Every row is a leading cell (who or what it is), a
title, and a trailing status cell. The two cells are the columns of the
header zone's own buttons. One text size and weight for titles; sections
are parted by space and a dim head, not by rules.
- **A folder, then the coding agent.** A top-level workspace row starts
with a folder glyph, under the PC's monitor. Where linked worktrees sit
under the workspace, that folder is the fold: open while they show,
closed while they are folded, with a dim `+N` for the checkouts put
away. Elsewhere it is a dim glyph and no control. Worktree rows have no
folder and sit one cell in. After the folder, a workspace row shows the
brand mark of the agent in the pane it opens, a terminal glyph for a
shell, a branch glyph for a worktree without an agent. The mark never
stands for another pane's agent; the tooltip lists every agent in the
workspace.
- **One status column, weighted by urgency.** Waiting for an answer is
the one filled glyph, a red message bubble; finished and not yet looked
at is a green dot; running is a dim spinning arc. READY and unknown draw
nothing. Waiting and done keep the colors they have on every other
surface (`--status-blocked`, `--status-done`). The element, its label
and `data-status` stay for every state.
- **No Needs you list.** The sidebar no longer opens with a list of
waiting panes: a pane that waits shows its state on its workspace row
and its Agents row, and the alerts are unchanged. Its component, helper,
unit test and `needs-input-regression` script are removed.
- **An Agents row**: mark, pane title, then one dim line naming the
agent, the PC (only when there are several), the workspace and the tab
(only when it adds something). Agents takes the height of its rows, at
most half the sidebar, above the footer.
- **Workspaces** keep their names and open the parent's own current or
last-viewed pane; tabs and split panes stay in the tab strip and
palette. Linked worktrees sit under their repository workspace with
their real branch names (existing worktree API, immediate create/open
updates, stale-response guards). Custom labels, folding and selection
are preserved.
- Hover is a half-strength neutral fill, selection the full one,
keyboard focus a ring on the row. A row's menu button takes no width
until hover, focus or selection (always shown on touch). The separate
reorder grip column is gone; row drag and Alt+Up/Down remain.

- **Resizable.** From 769px the sidebar's right edge is a grip, as
herdr's is: drag it (240px to 520px, never past half the window), move
it with the arrow keys, Home and End, double-click to return to the
default width. The width is kept per device; a phone's drawer keeps its
own.
- **Two-line rows, as an option.** Settings → Appearance → **Sidebar
rows** → **Two lines** draws a workspace row as the sidebar did before:
the title of the pane it opens, and under it the workspace and folder,
each name said once. One line stays the default. The folder, mark,
status cell and menu do not move, and the menu and rename still act on
the workspace.
- **Right-click.** A right-click on a workspace or pane row opens the
menu its `⋯` opens, under that button. A name field being edited keeps
the browser's menu, and a touch long press is left alone (it picks the
row up for a drag; the `⋯` is always shown on touch). PC headers and
agent rows have no menu.
- **The PC header** keeps `main`'s monitor icon, strong semibold name,
Host and state dot (green while connected). Its fold is a caret button
beside the `+`; the name is no longer a control.

Removed from the earlier state of this PR: the per-PC
**Workspaces/Projects** fold (the caret beside the PC's `+` is the
fold), the four hairlines, the count pills, the idle circle and unknown
question mark, and the **Needs you** section.

Two things a reader of the earlier screenshot will notice: brand marks
on workspace rows, and the folder that leads them, which replaces the
worktree group's chevron. Folder mode loses its hover caret for the same
reason: the folder glyph and name are the fold.

### herdr parity fix (own commit, 1db1471)

A workspace row's state now rolls up in herdr's order: blocked, then
done, then working, then ready. Visible effect: a workspace with one
finished agent and one still running shows DONE where it showed RUN. A
folded worktree parent rolls up its whole group.

No server, generated wire types, HTTP/WS shapes, lens behaviour or
status subscriptions change. No create control was added: an agent is
still chosen in the New workspace, New tab and New worktree dialogs.

## Validation

- TypeScript and the unit suite pass (4 skipped, none failing),
including new cases for the row mark, the agent line, the tab name, the
roll-up order and the sidebar width.
- Local browser runs on an isolated herdr session: `ui-regression` (78
groups, with the resize grip and right-click on both row kinds),
`directory-groups-regression` (7), `usage-regression`. Three
`ui-regression` runs stopped in the alert-sound check waiting for a
droplet card and passed on a later run with no change to that check.
- Rendered from the fictional demo: dark, light, compact, the opt-in
palettes, By folder, a 620px-tall window, a 390px touch drawer, several
PCs including offline ones, reduced motion.
- A Claude review workflow (five dimensions, each finding verified by a
skeptic) and a Codex adversarial pass (gpt-6.1-sol, xhigh) ran on the
redesign; their confirmed findings are fixed in bb2ca42 and f349caf.
- Not run: `scripts/machines-browser-qa.ts` (it needs the `test:ssh`
fixture).

Screenshot from the fictional demo, never a user's live herdr session.

<img
src="https://raw.githubusercontent.com/devswha/herdr-web-ui/09f8d83e561568f47c377130d11c8ae96eaf8845/docs/screenshots/sidebar-spaces-branches.png"
alt="Sidebar: workspaces led by their coding agent, worktree branches
beneath, and the Agents list" width="320">

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

---------

Co-authored-by: e2e <e2e@example.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
## Change

Replace the separate Queue/Stop controls with one compact Send/Stop control for every agent on desktop and mobile. An empty composer during work shows Stop; typing shows Send, which schedules a follow-up. Pending text stays selectable, with explicit ↑ Send now and Discard actions. While a draft shows Send in Stop's place, Escape in the message box stops the agent.

The bridge owns pending IDs. Automatic delivery and Send now claim the same ID, validate the original live interact connection, pane attachment, agent identity and visible prompt, and deliver through the existing paste-and-Enter path. Each automatic follow-up waits for a confirmed working/completion cycle. Disconnect, detach and observe transitions hold remaining messages; uncertain delivery exposes Copy/Discard without retry. Per-connection pane authority is invalidated permanently on lease loss, so reattaching to the same shared terminal or returning to interact cannot revive an in-flight send. A queue request that reaches an already-ready agent uses the same cancellable paste-and-Enter path, with the same identity and prompt checks, instead of delegating Enter to agent.prompt; a queue request with no agent in front is refused (`agent_not_ready`) and nothing is typed. Password/passphrase checks run before input and before the committing key. Older bridges retain the draft instead of turning a queue request into immediate input, and the older-bridge note comes from the socket's answer after the snapshot; legacy browser-held messages keep explicit recovery.

Hide composer status words, use a 32px visible Send/Stop circle with a 40px mobile target, and show a read-only local desktop quota: five-hour session first, weekly fallback. Mobile omits the quota and keeps an inert context ring. The quota is the first visible provider account in Settings order, named in its tooltip; it is not active-account detection. Failed/unavailable limits and remote-pane quotas are omitted.

Based on main `50db970` (devswha#521 merged in `e518917`; `site/demo/transport.ts` needed a semantic merge so `closeDemoWorkspaces` and `createDemoWorkspace` carry the pending hold and `finishDemoTurn`). No version bump or release tag is included. `server/index.ts` changed, so the release that carries this needs a new remote bundle (`REMOTE_BUNDLE_VERSION`); until a remote PC updates its bridge, Send during work keeps the draft and asks for the update.

## Review fixes after `74af5ac`

Added in the review session on top of the original head, each with a regression test that failed on the code before it (except the fingerprint, covered by the existing replay tests):

- `6827fbb` the contract test registers its workspace for cleanup before setup can throw.
- `3ad531e` Escape in the message box stops the agent while a draft hides Stop.
- `9d0b46c` a refused explicit Send no longer clears the earlier send's turn gate.
- `c8f1377` a queue request with no agent in front is refused (`agent_not_ready`), nothing typed.
- `6d6466c` the older-bridge note comes from the socket's answer after the snapshot; `pane_not_found` and `retired_submit_id` are listed as not typed.
- `7ebde68` safety checks read source `detection`; `modelListWaits` guard; a ready-at-arrival request goes behind waiting messages; mark/observe status ordering; sha256 request fingerprint; forget on pane end.
- `63537b3` client: an answer is matched by connection epoch; a removal write refreshes storage first; a held copy is saved as not confirmed before it is sent again.
- `a18c33d` the not-confirmed mark is applied on every write until the answer; unconfirm refreshes first.
- `a3d875a` the model-list hint must run into the last line, so a stale hint no longer blocks delivery.
- `264d803` Claude's footer under a wrapped hint is stripped before that test.
- `d76fb24` fail/removeCopy/suspend/Send refresh storage first; no send when the mark cannot be saved over a saved held copy.
- `eb132e2` the PR evidence screenshots are dropped from the tree.

Known gaps found by the last review delta and filed instead of fixed here: devswha#540 (a session replaced inside the same pane is not noticed without a native session), devswha#541 (sending a held copy again is not coordinated between tabs), devswha#542 (the model-list guard misses a hint split inside a word or over more than six lines; a quoted hint can hold delivery).

## Validation

Head `eb132e2` on main `50db970` (merge-base = main, so CI on the head is the merged result):

- CI on `d76fb24` (the same tree plus the two screenshots): Fast checks, Integration and browser and Native Windows install passed on run 37597973331 attempt 3; bundles, smoke-windows, ssh and CodeRabbit passed. Attempts 1 and 2 failed in the browser lane on scripts this PR does not touch (`terminal-copy-regression.ts:86`, then `alert-sound-regression.ts:146`); neither repeated. CI on `eb132e2` is linked from the checks tab.
- Independent review: Codex gpt-6.1-sol xhigh, read-only, over the full diff at `6d6466c` and the deltas at `63537b3`, `a3d875a` and `d76fb24`. Neither pass found a way for an observe, revoked, detached or foreign connection to deliver input or act on another connection's rows. The last delta found no High; its one Low regression and five older gaps are devswha#540, devswha#541 and devswha#542.
- Review threads: 0 unresolved (two CodeRabbit threads fixed and answered).

At the original head `74af5ac` (Bun 1.4.2 / herdr 0.9.3): `bun run generate:types --check`, `bun run typecheck`, `bun run build`, `git diff --check` PASS; `bun run test:unit` 1,505 PASS / 15 SKIP / 0 FAIL; the pending-input and submit contract tests 44 PASS in an isolated herdr session; `HERDR_TEST_SHARDS=2 bun run test:integration` 250 PASS / 6 SKIP / 0 FAIL; full real-server UI regression 77 PASS scenarios; composer browser checks PASS at 350/390/800/1440px; greeting, legacy held rows and prompt docking demos 44 PASS stages.

Not verified by anyone: a real Claude Code or Codex turn taking the bracketed paste plus Enter with herdr status flapping mid-turn; Windows mirrored panes (the contract test simulates the mirror on Linux); physical phones; the SSH multi-PC browser fixture; how Claude Code draws `/model` in a narrow pane (fixtures only, see devswha#542). Send now confirms terminal delivery, not agent execution or native Codex/Claude app-server steering.

## UI evidence

Fictional loopback-only demo from the production UI at the original head. No user session was captured. The screenshots are not kept in the tree; the links pin the commit that carried them.

<img src="https://raw.githubusercontent.com/devswha/herdr-web-ui/461135f0cda76978cc6d84278af39f24069b3c15/.github/pr-evidence/chat-followups-desktop.jpg" width="900" alt="Desktop: compact Stop/Send, explicit pending Send now, five-hour quota and no drawn status word">

<img src="https://raw.githubusercontent.com/devswha/herdr-web-ui/461135f0cda76978cc6d84278af39f24069b3c15/.github/pr-evidence/chat-followups-mobile.jpg" width="300" alt="Mobile: explicit pending Send now, compact Stop, no quota or drawn status word">

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…evswha#536)

## Change

An in-app alert and its sound were lost now and then, and this is also the intermittent failure of the alert checks in the browser lane.

The open app remembers each pane's status and tells of a change when a `pane-status` event differs from what it remembers. It also rewrote that memory from every roster (`machines`) frame. The server reads the roster from herdr on its own, every 5 s and on structure changes, so a roster can show `blocked` or `done` a moment before the event that tells of it arrives. The event was then no change, and nothing was shown or played.

`seedStatuses` (`src/lib/droplet.ts`) now decides what a roster does to that memory:

- A change that is news (to `blocked` or `done`) is left to its event.
- Any other change is taken, as before, with the turn's clock kept as an event would keep it. This is how the page catches up after status events were lost, so a wait that follows is news again.
- A pane first seen is taken as it is, and a pane no PC lists any more is forgotten.

## Validation

- Measured with a probe on `/api/machines/events` (own herdr test session, idle 32-core PC): in 3 of 300 `working` → `blocked` rounds the roster showed `blocked` first, by 1, 29 and 86 ms.
- Failed CI runs this explains: 37431192288 (`alert-sound-regression.ts:116`), 37451562394 on `main` (`:146`), 37588162364 (`droplet-regression.ts:110`). Each waited 30 s for a card or a chime that never came.
- `src/lib/droplet.test.ts`: five new cases (news left to its event; work shown first keeps the turn's length; catching up after lost events; a first sighting; a pane that is gone).
- `scripts/alert-sound-regression.ts`: a new step puts a roster frame in front of every status event. With `src/App.tsx` as on `main` it times out; with the fix it passes.
- Adversarial review with Codex (gpt-6.1-sol, xhigh): one Medium finding (a fill-only roster no longer repaired the memory after lost events), verified and fixed in the third commit.
- `bun run typecheck`, `bun run test:unit`, and the alert sound and droplet checks twice in a row on an owned test session.

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


Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
## Change

The mobile terminal key bar had a fixed set and order, with Settings only showing optional keys. Settings → Appearance → Key bar now has a compact **Edit key bar** entry that opens a dedicated editor.

- Add, remove and reorder every terminal key or held Ctrl/Alt/Shift button; register exact combinations such as Ctrl+W, including editing keys, F1–F12 and a single printable character.
- Ordinary keys use the held modifiers; saved combinations send their configured modifiers. Removing a modifier button clears its held state. Existing extra-key settings migrate without changing their layout, an empty layout stays empty, and Restore defaults returns the original bar.
- Back or Escape returns to the previous Settings scroll position and edit-button focus. Closing Settings resets the detail screen for the next open.
- Preserve native clipboard shortcuts on Korean/Russian layouts and existing Alt combinations with ^D/^Z and Delete/Insert.

This PR carries forward the sticky modifiers and attachment-claim protection from [devswha#433](devswha#433) by @nickadminroot, then adds customization and the reviewed input fixes. devswha#433 remains open for the maintainer to reconcile after reviewing this combined change.

## Review fixes after `71080de`

Added in the merge session, each with a test that fails on the code before it:

- `ecb25a0` a held-modifier key the terminal cannot take now (not ready, disconnected) shows the not-sent banner instead of vanishing; `sticky-modifiers-regression.ts` drops the page's input readiness over the intercepted socket and expects the banner and no frame (CodeRabbit on 71080de).
- `471db0c` main merged in (devswha#536); the CHANGELOG Fixed conflict keeps both bullets.
- `c9130ec` with Ctrl held, a physical key on a Korean or Russian layout names the chord by its position (ㅊ on KeyC is ctrl+c, not ctrl+ㅊ), as the plain Ctrl path already reads it; the regression dispatches a Korean-layout Ctrl+C with Alt held and expects `ctrl+alt+c` (Codex).
- `29a076d` a keys frame sent while its attach is still being looked up is answered `input_not_ready` instead of taking the unattached RPC path around the claim it would be checked against; the keys-lifecycle contract holds the lookup and expects the refusal (Codex).

The three CodeRabbit threads from 2026-10-06 are answered and resolved: two fixed in `da80edc`, one on a file that is no longer part of this diff.

Codex gpt-6.1-sol xhigh (read-only) over the full diff at `471db0c`: four Medium. Two fixed above; two are pre-existing paths this PR does not change and are filed: devswha#545 (authority can lapse between the check and the RPC write) and devswha#546 (mirrored-pane typing is not cancelled by a detach or reattach). Found sound: claims cancel queued chords and text across detach, reattach and replacement; no replay after reconnect; paste and deferred IME commits bypass held modifiers; migration, Restore defaults and malformed JSON; Settings Back/close/Escape.

Known and left as is (Low): the demo shell's `chordText` applies Ctrl to letters only (Ctrl+[ types `[` in the demo), and the sticky regression creates `UI_EVIDENCE_DIR` before its try block.

## Validation

Head `29a076d` on main `77e5430` (merge-base = main). CI on `471db0c` passed all three jobs on the first attempt; CI on `29a076d` is linked from the checks tab.

In the merge session, on `29a076d` (Bun 1.4.2, herdr 0.9.3, own herdr test session): typecheck; unit i18n/keys/keyBar/settings/ws (98 pass); `server/keys-lifecycle.contract.test.ts` 12 pass; `bun run build` then `scripts/sticky-modifiers-regression.ts` legacy and kitty PASS. Each new test was also run on the code before its fix and seen failing.

At `71080de` in the review session: typecheck, unit (1573 pass), keys-lifecycle contract (11), ui-regression (79 PASS), sticky-modifiers and key-bar demo regressions.

At the original head (macOS): generated-type check, typecheck and production build; unit suite 1,462 passed / 15 skipped; attachment/key lifecycle contract 11 passed; custom key-bar demo regression all five groups on a 390 × 844 touch viewport; legacy and Kitty sticky-modifier regression with real PTY bytes, all seven modifier combinations, native paste, dropped text, IME commits, hardware/touch combinations and disconnect reset.

Not verified: physical phones and hardware keyboards on real non-Latin layouts (the layout case is a dispatched event); native Windows. `server/index.ts` changed, so remote PCs get the cancellation guards only with the next remote bundle (`REMOTE_BUNDLE_VERSION`). devswha#433 stays open and is superseded once this merges.

The screenshots below use only the repository's fictional demo fixtures.

| Compact Settings entry | Dedicated key-bar editor |
| --- | --- |
| ![Compact mobile Settings entry](https://raw.githubusercontent.com/devswha/herdr-web-ui/cecc81c1c9f49840240800135ccc39a9f44cbdde/docs/screenshots/mobile-keybar/settings-phone.png) | ![Mobile key-bar detail screen](https://raw.githubusercontent.com/devswha/herdr-web-ui/cecc81c1c9f49840240800135ccc39a9f44cbdde/docs/screenshots/mobile-keybar/editor-phone.png) |



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


Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…wha#532)

<!-- See CONTRIBUTING.md for checks, translations, contract tests and the changelog. -->

## Change

Claude's per-model weekly limits other than Opus and Sonnet, such as Fable, were missing from the Claude usage meter. `GET https://api.anthropic.com/api/oauth/usage` reports them only in its top-level `limits` array, as rows like `{ kind: "weekly_scoped", group: "weekly", percent, resets_at, scope: { model: { display_name: "Fable" } } }`. There is no flat `seven_day_*` field for them. Claude Code's own `/usage` screen shows each such row as "Current week (<model>)".

`server/usage.ts`: after the `claude` provider builds its windows from the flat fields, it now reads each `weekly_scoped` row that names a model and a percent, and adds a `week` window scoped to that model's display name. A model that already has a window from the flat fields (Opus, Sonnet) is skipped, so no week is listed twice and the existing windows are unchanged. Rows of any other kind, or with no model, are ignored.

The meter already shows the model name as `scope` without translating it, as it does for "Sonnet". No new UI text, no protocol or contract change, nothing new for the demo transport.

## Validation

- New unit test in `server/usage.test.ts`, "reads Claude's other per-model weekly limits from `limits`, after the flat fields". It covers a session row (ignored), a Fable `weekly_scoped` row (read, 30.04 → 30, reset normalized to ISO), a Sonnet `weekly_scoped` row (ignored because `seven_day_sonnet` exists) and a row with `scope.model: null` (ignored). It fails without the change and passes with it.
- `bun run generate:types --check`: generated types are up to date
- `bun run typecheck`: passes, no errors
- `bun run build`: passes (only the existing chunk-size warning)
- `bun run test:unit`: 1468 pass, 15 skip, 0 fail across 123 files

Bun 1.4.2. I did not run the integration and browser suites because this change has no herdr or UI surface.

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


Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…stion card again (devswha#517)

## What

If a message that starts with a number (e.g. `1. 로그인해서 커넥트 눌럿어`) is sent before an AskUserQuestion, the chat shows a fallback card of arrow keys (`↑ ↓ Enter Esc`) instead of the question card. The fallback card also takes Claude's titled session rule as the question.

```
❯ 1. 로그인해서 커넥트 눌럿어          ← the message sent before, still on screen
…
────────────────────────────────────────
 ☐ Buzz 공개
│ Buzz를 Tailscale 없이 인터넷에서 쓰도록 공개할까요? …
❯ 1. 공개 진행 (추천)
     Cloudflare Tunnel로 … (멤버 키 서명만
     허용)이고 relay와 /pair만 노출합니다. …   ← second line of a wrapped description
  2. Tailscale 유지
  3. Type something.
────────────────────────────────────────
  4. Chat about this
Enter to select · ↑/↓ to navigate · Esc to cancel
──────────────────────────────── Buzz 슬랙 대체 AI 조사 ─
```

The server log said: `prompt: claude pane w1:p9 is blocked on a screen no reader knows; fallback card (4 options)`.

## Why

`parseClaudeQuestion` and `parseCodexContinueMenu` read numbered lines from up to 64 lines above the hint; `parseCodexQuestion` reads from up to 48. The sent message stays on screen after the prompt mark (`❯ 1. …` for Claude, `› 1. …` for Codex). Because `NUMBERED_OPTION_RE` takes that mark as the selection marker, the message is read as a selected option. The rows then count 1, 1, 2, 3, 4, so `sequentialRows` fails and two rows read as selected. The parser returns `null`, and the fallback card takes over.

## Change

- **The menu is the last run of numbered lines that counts up from 1, among the lines numbered in the column of the row the cursor is on.** The new `menuRows` helper applies this in the four parsers that read that far up: the three above, plus Codex's open question in its layout without the queue header.
  - The column rule keeps a `1. …` line inside the first option's description, which is indented further, from starting the menu.
  - The sent message stays in the menu's column, after the same mark, and is skipped as the earlier run.
  - Parsers with a tight window (the submit review, the approvals, the fallback menu) are unchanged.
- **Wrapped option descriptions are shown whole.** A description continues to the next blank line or rule; before, it stopped at its first line, so the card cut it off mid-sentence. This changes only the descriptions read through `parseNumberedRows`, which are the Claude question and the Codex continue menu.

## Tests

`server/prompt.test.ts` has a new `a question under a numbered message sent earlier` block. Both of its tests fail without the change and pass with it.

- **Claude:** the screen above, shortened but kept in the same shape: the sent message, the titled rule, and a description wrapped over two lines.
  - The test expects the question card with its title, the question, both options, and the description joined whole.
  - The answer keys for option 2 are `down`, `enter`.
- **Codex:** the same situation with `› 1. …` above a Codex question.
- **Description edge case:** a first option whose description holds its own `1. …` line, with the cursor on option 2. The card keeps `Script` and `Manual`, and picking option 1 sends `up`, `enter`. This test fails without the column rule.
- **Last option with a numbered line under it:** a Codex continue menu whose last option has an indented `1. …` under it, below a sent message. The column comes from the cursor's row, so the menu keeps its three options. This was raised by CodeRabbit.
- **Codex's open question without the queue header,** below a sent `› 1. …`.
- **Claude Code 2.1.290's preview form,** captured live after asking for a long description on every option. The form draws none of them, so the description joining has nothing to pick up there. The card keeps the same id when the cursor moves to another option.

```
bun run typecheck                    ✓
bun test server/prompt.test.ts       177 pass, 0 fail
bun scripts/ci-tests.ts unit         1488 pass, 0 fail
bun scripts/ci-tests.ts integration  225 pass, 0 fail, 6 skip (macOS)
```

The live screen that showed the fallback card is now parsed as the question card, with both options and the full descriptions.

🤖 Assisted by [kilhyeonjun-harness](https://github.com/kilhyeonjun/kilhyeonjun-harness)


Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…thout a pairing code (devswha#525)

## Intent

Fix mobile connection to herdr-web-ui (https://github.com/devswha/herdr-web-ui) and simplify its pairing as much as possible. The captain will always reach it over Tailscale and wants entering the Tailscale URL alone to work at once: https://denisss-macbook-pro-m1.tail5cc90b.ts.net:7317. What happened instead: the captain's phone, signed in to the SAME Tailscale account as this Mac (jetmobsol@gmail.com, the only user on this tailnet, all nodes untagged, Tailscale 1.102.4), opened that URL and was shown the device-pairing screen instead of the app - "i entered this link https://denisss-macbook-pro-m1.tail5cc90b.ts.net:7317 but it asks oairing! it can not connect!". The captain now asks to fix this pairing difficult logic. Success means: the owner's own phone opens the Tailscale URL and lands in the app immediately, no code, no pairing screen; the six-digit pairing flow remains only for devices that genuinely belong to someone else.

## What Changed

- Added the opt-in `HERDR_WEB_TAILSCALE_SERVE_ONLY=1` declaration: on a tailnet that `tailscale status --json` proves is owned by a single login with no tagged node, a serve-proxied request carrying no login header is admitted as the owner, so the owner's own phone opens the serve address and lands in the app with no pairing code. The grant also requires the request's Host to name this PC's own tailnet DNS name or IP; other logins are still refused, tagged nodes and Funnel still pair or stay closed, and a configured token still overrides everything.
- Reworked the Tailscale identity layer in `server/tailscale.ts`: new `parseSoleTailnetLogin` and `parseNodeName`, and an injectable `TailnetIdentitySource` class replacing the module-level `tailscaleIdentity()`, caching owner/tagged/sole-login/DNS/IP for five minutes and taking a single-flight fresh status read on the owner-grant path when the sole-login proof is unsettled or a read failed.
- Extended `decideAccess` in `server/access.ts` with the serve-only owner grant (via new `hostName`, `addressesNode` and `isServeOwnerRequest` helpers, refactored out of `isLoopbackHost`), wired through `createServer` with new `tailscaleServeOnly` and `tailnet` options that also serve tests; updated the access contract in `shared/protocol.ts`, plus unit and contract tests, CHANGELOG, INSTALL, README (en/ja/ko/zh-CN) and docs/guide.md.

## Risk Assessment

✅ Low: The change is well-bounded: an opt-in flag guards the new grant, every previously authorized fix is correctly applied and verified against the code, the grant floor is enforced before any identity-dependent branch, and unit plus contract tests exercise real behavior in both directions; no defect or unrequired component was substantiated.

## Testing

Every scenario derived from the intent passed live against the real product. The happy path was driven with the machine's real tailscale CLI (Tailscale 1.102.4, sole login, no tagged nodes - exactly the reported environment): with HERDR_WEB_TAILSCALE_SERVE_ONLY=1 the phone-shaped serve request is granted via tailscale and a real Chrome loading the MagicDNS URL renders the workspace with no pairing gate, while the identical request on a flag-off install gets the six-digit pairing screen the captain reported. Adversarial guards all hold live: another login refused (other_user), foreign/loopback Host pairs, Funnel pairs, a configured token still required, a tagged node appearing mid-TTL revokes the warm-cache grant on the next request, and a Starting status after wake recovers on the very next request instead of locking the owner out for five minutes; pairing a device (closing the gate) does not affect the owner's phone. New unit and contract tests for the change also pass. One incident during cleanup, fully remediated: my 'pkill -f bun server/index.ts' also matched the captain's live bridge (the supervisor spawns it with exactly that argv), taking the live 7317 install down for about two minutes; I restored it immediately with the plugin's canonical 'bun scripts/plugin.ts start' launcher - same active release (release-EqyLpU, revision c4cb041), same port, verified healthy - and no state under ~/.config/herdr-web-ui was touched.

- Live validation: ✅ go - 9 of 9 scenarios driven live against the product

| Scenario | Result | Live | Evidence |
| --- | --- | --- | --- |
| Owner&devswha#39;s own phone opens the Tailscale URL and lands in the app immediately, no code, no pairing screen (HERDR_WEB_TAILSCALE_SERVE_ONLY=1, one-login untagged tailnet) | ✅ pass | live | live-s1-owner-phone-no-code.txt (authenticated:true via:tailscale, /api/session 200) and ui-phone-serve-only-lands-in-app.png (real Chrome at the MagicDNS hostname renders the workspace, no gate, /ws… |
| With the flag off (the default), the same phone request gets the six-digit pairing flow, which keeps working for devices that belong to someone else | ✅ pass | live | live-s2-s7-guards.txt (pairing_required, 401) and ui-phone-flag-off-pairing-screen.png (&devswha#39;Pair this device with a code from the PC&devswha#39; gate); pairing a device over /api/devices/pair succeeds (live-s9-gate… |
| A genuinely different Tailscale login is refused, never silently granted, even on a serve-only install | ✅ pass | live | live-s2-s7-guards.txt: Tailscale-User-Login stranger@example.com -&gt; other_user refusal on /api/health and /api/session |
| A tagged node appearing on the tailnet revokes the no-code grant on the next request, even from a warm cache inside the 5-minute TTL | ✅ pass | live | live-s4-s8-transitions.txt: request 3 granted from warm cache, tagged peer flips the staged status, request 4 immediately pairing_required (401); server driven over live HTTP with a staged tailscale C… |
| After the Mac wakes with tailscaled still Starting, the owner is asked to pair at most once and the next request succeeds (no 5-minute negative-cache lockout) | ✅ pass | live | live-s4-s8-transitions.txt: Starting -&gt; pairing_required, flipped to Running -&gt; immediate next request granted via tailscale |
| A rebinding page or non-serve proxy presenting its own Host gains nothing: the Host must name this PC&devswha#39;s MagicDNS name or tailnet IP | ✅ pass | live | live-s2-s7-guards.txt: Host evil.example and Host 127.0.0.1 with forwarding header -&gt; pairing_required; the MagicDNS name and the 100.x tailnet IP both granted (live-s1-owner-phone-no-code.txt) |
| Funnel (public internet) stays closed even with serve-only on and the sole-login proof | ✅ pass | live | live-s2-s7-guards.txt: Tailscale-Funnel-Request header -&gt; pairing_required |
| A configured token is still required of everything but a paired device on a serve-only install | ✅ pass | live | live-s2-s7-guards.txt: HERDR_WEB_TOKEN install -&gt; token_required without credentials, via:token with the bearer |
| After the first device is paired (gate closed for strangers), the owner&devswha#39;s phone still lands in the app without a code | ✅ pass | live | live-s9-gated-owner-still-in.txt: device paired over the real pairing endpoints, then the phone-shaped request still authenticated via tailscale and /api/session 200 |

<details>
<summary>Evidence: Owner&devswha#39;s phone granted with no code (real tailnet, serve-only install)</summary>

`GET /api/health?scope=bridge with Host=denisss-macbook-pro-m1.tail5cc90b.ts.net, X-Forwarded-For, no login header -> {"auth":{"required":false,"authenticated":true,"via":"tailscale","role":"drive"}}; GET /api/session -> HTTP 200; Host=100.110.31.92 (tailnet IP) and UPPER-CASE name also granted`

```text
=== S1: owner's phone (serve-proxied, no login header, Host = this PC's Tailscale name) on serve-only install (port 37611)
--- GET /api/health?scope=bridge
{"ok":true,"auth":{"required":false,"authenticated":true,"via":"tailscale","role":"drive"},"bridge_protocol":1}
--- GET /api/session (auth-gated endpoint; phone lands in the app iff not 401)
HTTP 200
--- same, Host = this PC's tailnet IP 100.110.31.92
{"ok":true,"auth":{"required":false,"authenticated":true,"via":"tailscale","role":"drive"},"bridge_protocol":1}
--- Host name in UPPER CASE (same PC)
```
</details>

- Evidence: Phone browser at the Tailscale URL lands in the app (serve-only on, no pairing screen) (local file: <code>~/.no-mistakes/evidence/01M48S35FX09YZE6HXK7R6VETR/ui-phone-serve-only-lands-in-app.png</code>)
- Evidence: Same URL on a flag-off install shows the six-digit pairing screen (the reported bug, still the default) (local file: <code>~/.no-mistakes/evidence/01M48S35FX09YZE6HXK7R6VETR/ui-phone-flag-off-pairing-screen.png</code>)

<details>
<summary>Evidence: Guards: flag off pairs; other login refused; foreign Host pairs; Funnel pairs; token still required</summary>

```text
--- Host name in UPPER CASE (same PC)
{"ok":true,"auth":{"required":false,"authenticated":true,"via":"tailscale","role":"drive"},"bridge_protocol":1}
=== S2: SAME phone request on an install WITHOUT the flag (port 37613, default) -> six-digit pairing remains
--- /api/health?scope=bridge
{"ok":true,"auth":{"required":true,"authenticated":false,"reason":"pairing_required"},"bridge_protocol":1}
--- /api/session status code
HTTP 401
--- /api/session refusal body
{"error":{"code":"unauthorized","message":"pair this device, or use the token"}}
=== S3: someone else's Tailscale login is refused on the serve-only install (port 37611)
{"ok":true,"auth":{"required":true,"authenticated":false,"reason":"other_user"},"bridge_protocol":1}
{"error":{"code":"other_user","message":"this PC belongs to another Tailscale user"}}
=== S5: rebinding/foreign Host gains nothing on the serve-only install (port 37611)
{"ok":true,"auth":{"required":true,"authenticated":false,"reason":"pairing_required"},"bridge_protocol":1}
HTTP 401
--- Host = plain 127.0.0.1 loopback name with forwarding header (a local proxy that is not serve)
{"ok":true,"auth":{"required":true,"authenticated":false,"reason":"pairing_required"},"bridge_protocol":1}
=== S6: Funnel (public internet) stays closed on the serve-only install (port 37611)
{"ok":true,"auth":{"required":true,"authenticated":false,"reason":"pairing_required"},"bridge_protocol":1}
=== S7: a configured token is still required of the phone on a token install (port 37617, serve-only on)
--- phone-shaped request, no token
{"ok":true,"auth":{"required":true,"authenticated":false,"reason":"token_required"},"bridge_protocol":1}
{"error":{"code":"unauthorized","message":"token required"}}
--- with the token as bearer
{"ok":true,"auth":{"required":false,"authenticated":true,"via":"token","role":"drive"},"bridge_protocol":1}
```
</details>
<details>
<summary>Evidence: Status transitions: Starting recovers next request; tagged node revokes warm-cache grant inside TTL</summary>

```text
=== S8: Mac wakes, tailscaled still 'Starting' -> owner asked to pair once, then recovers on the NEXT request
--- status: BackendState Starting (what tailscale status --json says right after wake)
Starting - {'7': {'LoginName': 'owner@test.ts.net'}}
--- request 1 (serve-only install, port 37619, phone shape, no login header)
{"ok":true,"auth":{"required":true,"authenticated":false,"reason":"pairing_required"},"bridge_protocol":1}
--- tailscaled reaches Running (one login, no tags) - the SAME minute, well inside the 5-minute cache TTL
--- request 2, immediately: the grant path must re-read, not answer the stale negative
{"ok":true,"auth":{"required":false,"authenticated":true,"via":"tailscale","role":"drive"},"bridge_protocol":1}
HTTP 200

=== S4: a node gets tagged while the cache is warm (Running sole -> Running with tagged peer), inside the TTL
--- request 3 (cache already proved sole login on request 2): still granted
{"ok":true,"auth":{"required":false,"authenticated":true,"via":"tailscale","role":"drive"},"bridge_protocol":1}
--- a tagged node joins the tailnet (status flips; still well inside the 5-minute TTL)
--- request 4, immediately: the warm cache must NOT keep granting; the no-login request pairs again
{"ok":true,"auth":{"required":true,"authenticated":false,"reason":"pairing_required"},"bridge_protocol":1}
HTTP 401
```
</details>
<details>
<summary>Evidence: After pairing a device (gate closed) the owner&devswha#39;s phone still gets in; strangers still pair</summary>

```text
=== S9: pair a device first (closing the gate for strangers), then re-check the owner's phone
--- POST /api/devices/pair/start from this PC (localhost, local access)
{"code":"584600","expires_at":"2026-10-06T14:59:46.697Z"}
--- POST /api/devices/pair with code 584600 (as a stranger's device would)
HTTP 204 (device cookie set)
--- owner's phone after the gate closed (no cookie, no token, serve-only install)
{"ok":true,"auth":{"required":false,"authenticated":true,"via":"tailscale","role":"drive"},"bridge_protocol":1}
HTTP 200
--- stranger-shaped serve request after the gate closed
{"ok":true,"auth":{"required":true,"authenticated":false,"reason":"pairing_required"},"bridge_protocol":1}
```
</details>

## Pipeline

Updates from [git push no-mistakes](https://github.com/kunchenguid/no-mistakes)

<!-- no-mistakes-pipeline-attestation:v1 {"head_sha":"15c2bd4db0c4eed4f624e0e59da4cdf82b222df5","steps":[{"step":"intent","status":"completed"},{"step":"rebase","status":"completed"},{"step":"review","status":"completed"},{"step":"test","status":"completed"},{"step":"document","status":"completed"},{"step":"lint","status":"completed"},{"step":"push","status":"completed"},{"step":"pr","status":"running"},{"step":"ci","status":"pending"}]} -->

<details>
<summary>✅ **intent** - passed</summary>

✅ No issues found.
</details>

<details>
<summary>🔧 **Rebase** - 1 issue found → auto-fixed ✅</summary>

- ⚠️ `CHANGELOG.md` - merge conflict rebasing onto origin/main

🔧 Fix applied.
✅ Re-checked - no issues remain.
</details>

<details>
<summary>🔧 **Review** - 2 issues found → auto-fixed (2) ✅</summary>

- ⚠️ `CHANGELOG.md:65` - The new serve-only entry sits inside the `## [0.3.52] - 2026-10-06` `### Fixed` section (header at line 64), but the base commit 54e5a1f is already past the v0.3.52 release prep, and `## [Unreleased]` has its own `### Fixed` section at line 16. This branch&devswha#39;s change is unreleased work attributed to a shipped version; at the next release prep it stays stranded inside the historical 0.3.52 block instead of moving with [Unreleased]. This is the previously authorized fix (recorded decision `changelog-entry-under-released-0.3.52`) still unapplied at this head. Remedy: move the 8-line entry (lines 65-72) up under `## [Unreleased]` → `### Fixed`, appended after the existing Codex entry; the PR link is added later by the pipeline&devswha#39;s PR step.
- ℹ️ `server/index.ts:1029` - The grant-path predicate `token === &devswha#34;&devswha#34; &amp;&amp; pairedDevice === null &amp;&amp; isServeOwnerRequest(requestShape)` is path-blind, so on a serve-only install every phone-shaped request — including static assets (JS/CSS/icons/sw.js), `/api/health` without scope, `/api/auth` and `/api/devices/pair` — awaits a fresh `tailscale status --json` spawn in `freshIdentity` (server/tailscale.ts:255-259 skips the early return only when the cache proves sole-login and the Host matches, i.e. exactly the phone&devswha#39;s case). For non-`/api`/non-`/ws` paths the computed `access` is never consulted (static serving falls through; `requiresAuth` and the readOnly/mutating checks are all gated on `/api/` or `/ws`), so the read adds one bounded CLI spawn plus its latency (~20-100 ms, 2.5 s worst case) to each such response on the intended happy path (first phone page load pays one spawn per asset wave) while buying nothing. Remedy: add `(pathname === &devswha#34;/ws&devswha#34; || pathname.startsWith(&devswha#34;/api/&devswha#34;))` to the grant-path predicate — behavior-preserving for every response, since `/api/*` (including `/api/health?scope=bridge`, the one open endpoint that reports auth) and `/ws` are the only paths that observe `access`.

🔧 Fix applied.
1 warning still open:

- ⚠️ `docs/guide.md:213` - The changed sentence says &devswha#34;Your own devices get in without a code only when you set HERDR_WEB_TAILSCALE_SERVE_ONLY=1 ... Without it, the phone pairs with a six-digit code.&devswha#34; That contradicts the rest of the change&devswha#39;s own documentation and the code: `tailscale serve` stating the owner&devswha#39;s login already grants entry with no flag (server/access.ts:121, guide.md:259 &devswha#34;A login that matches this PC&devswha#39;s own gets in&devswha#34;, guide.md:267 &devswha#34;Nothing needed: your login&devswha#34;, INSTALL.md:156-157 &devswha#34;Nothing to configure&devswha#34;). The flag exists precisely for the requests where serve states no login (the reported Tailscale 1.102.4 phone case), not as the only codeless path. A reader who takes the sentence at face value enables a security-relevant flag (full owner access for no-login proxied requests) they may not need. Siblings carrying the same &devswha#34;set the flag to get codeless phone access&devswha#34; framing: README.md:94, README.ja.md:96, README.ko.md:95, README.zh-CN.md:96. Remedy: qualify the sentences the way INSTALL.md:157 already does — your devices get in without a code when serve states your login; if your own phone is asked to pair anyway, HERDR_WEB_TAILSCALE_SERVE_ONLY=1 lets it in without a code on a one-login tailnet, enabled only when nothing else reaches the port.

🔧 Fix applied.
✅ Re-checked - no issues remain.
</details>

<details>
<summary>✅ **Test** - passed</summary>

✅ No issues found.
- Live validation: ✅ go - 9 of 9 scenarios driven live against the product

| Scenario | Result | Live | Evidence |
| --- | --- | --- | --- |
| Owner&devswha#39;s own phone opens the Tailscale URL and lands in the app immediately, no code, no pairing screen (HERDR_WEB_TAILSCALE_SERVE_ONLY=1, one-login untagged tailnet) | ✅ pass | live | live-s1-owner-phone-no-code.txt (authenticated:true via:tailscale, /api/session 200) and ui-phone-serve-only-lands-in-app.png (real Chrome at the MagicDNS hostname renders the workspace, no gate, /ws… |
| With the flag off (the default), the same phone request gets the six-digit pairing flow, which keeps working for devices that belong to someone else | ✅ pass | live | live-s2-s7-guards.txt (pairing_required, 401) and ui-phone-flag-off-pairing-screen.png (&devswha#39;Pair this device with a code from the PC&devswha#39; gate); pairing a device over /api/devices/pair succeeds (live-s9-gate… |
| A genuinely different Tailscale login is refused, never silently granted, even on a serve-only install | ✅ pass | live | live-s2-s7-guards.txt: Tailscale-User-Login stranger@example.com -&gt; other_user refusal on /api/health and /api/session |
| A tagged node appearing on the tailnet revokes the no-code grant on the next request, even from a warm cache inside the 5-minute TTL | ✅ pass | live | live-s4-s8-transitions.txt: request 3 granted from warm cache, tagged peer flips the staged status, request 4 immediately pairing_required (401); server driven over live HTTP with a staged tailscale C… |
| After the Mac wakes with tailscaled still Starting, the owner is asked to pair at most once and the next request succeeds (no 5-minute negative-cache lockout) | ✅ pass | live | live-s4-s8-transitions.txt: Starting -&gt; pairing_required, flipped to Running -&gt; immediate next request granted via tailscale |
| A rebinding page or non-serve proxy presenting its own Host gains nothing: the Host must name this PC&devswha#39;s MagicDNS name or tailnet IP | ✅ pass | live | live-s2-s7-guards.txt: Host evil.example and Host 127.0.0.1 with forwarding header -&gt; pairing_required; the MagicDNS name and the 100.x tailnet IP both granted (live-s1-owner-phone-no-code.txt) |
| Funnel (public internet) stays closed even with serve-only on and the sole-login proof | ✅ pass | live | live-s2-s7-guards.txt: Tailscale-Funnel-Request header -&gt; pairing_required |
| A configured token is still required of everything but a paired device on a serve-only install | ✅ pass | live | live-s2-s7-guards.txt: HERDR_WEB_TOKEN install -&gt; token_required without credentials, via:token with the bearer |
| After the first device is paired (gate closed for strangers), the owner&devswha#39;s phone still lands in the app without a code | ✅ pass | live | live-s9-gated-owner-still-in.txt: device paired over the real pairing endpoints, then the phone-shaped request still authenticated via tailscale and /api/session 200 |

- `HERDR_TEST_MODE=unit bun test ./server/tailscale.test.ts ./server/access.test.ts (35 pass)`
- `bun test ./server/api.contract.test.ts -t serve (both new serve-only contract tests pass against the isolated herdr-web-ui-test herdr session)`
- `Live HTTP drive, real tailscale CLI reading the machine's real one-login tailnet: phone-shaped request (loopback source, X-Forwarded-For, no Tailscale-User-Login, Host=denisss-macbook-pro-m1.tail5cc90b.ts.net) against a real bun server/index.ts instance with HERDR_WEB_TAILSCALE_SERVE_ONLY=1 -> /api/health?scope=bridge reports authenticated:true via:tailscale; /api/session 200; tailnet-IP and upper-case Host variants also granted`
- `Live HTTP drive, same request against a flag-off instance -> pairing_required (401 unauthorized body); stranger Tailscale-User-Login -> other_user; evil.example and 127.0.0.1 Host -> pairing_required; Tailscale-Funnel-Request header -> pairing_required; HERDR_WEB_TOKEN install -> token_required without credentials, via:token with bearer`
- `Live HTTP drive with a staged fake tailscale CLI on PATH: BackendState Starting -> pairing_required once, then Running sole-login -> granted on the very next request inside the 5-minute TTL; warm-cache sole-login grant -> a tagged peer appearing in status -> pairing_required immediately`
- `Live HTTP drive: paired a device over /api/devices/pair/start + /api/devices/pair (gate closed), then the owner's phone request still granted and stranger-shaped requests still pairing_required`
- `Playwright (real Chrome, host-resolver mapping the MagicDNS name to the test server, phone viewport): the app loads at http://denisss-macbook-pro-m1.tail5cc90b.ts.net:37611/ with no pairing gate and an open /ws; the same URL on the flag-off server shows the 'Pair this device with a code' gate; screenshots captured`
- `Cleanup verification: all four test servers stopped, herdr-web-ui-test session stopped, worktree returned to clean (transient .nm-test, dist, .playwright artifacts removed), captain's live install on 7317 verified healthy`
</details>

<details>
<summary>✅ **Document** - passed</summary>

✅ No issues found.
</details>

<details>
<summary>✅ **Lint** - passed</summary>

✅ No issues found.
</details>

<details>
<summary>✅ **Push** - passed</summary>

✅ No issues found.
</details>


Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
devswha#544)

## Change

CI's two required jobs were sequences written out in `ci.yml`, so running "what CI runs" on a PC meant copying them by hand. A local run of the lanes also used the user's own herdr config directory: the test sessions live under `~/.config/herdr/sessions`, and every test server there loads the plugins installed for the user's herdr.

`scripts/check.ts` (`bun run check`) is now what both CI and a developer or agent run:

| Mode | What it runs |
|---|---|
| `fast` | CI's **Fast checks**: workflow syntax (the same checksum-pinned actionlint), generated types, typecheck, build, unit tests |
| `integration`, `browser`, or both | CI's **Integration and browser**: a build, then the lanes side by side through `scripts/ci-lanes.ts` |
| `full` | `fast`, then both lanes |
| `run <command…>` | one test file or browser script on the same isolated herdr |

- **A herdr of the run's own.** A directory and a session name are made for each run (`XDG_CONFIG_HOME`, `XDG_STATE_HOME`, `HERDR_WEB_STATE_DIR`, `HERDR_TEST_SESSION`). Nothing reads the user's herdr config, so no installed plugin starts with the test servers, and two runs share no socket or file. `HERDR_TEST_LIVE=1` is refused.
- **Cleanup.** The run stops its herdr servers and removes the directory when it ends, also on SIGINT, SIGTERM and SIGHUP: it first waits until the step's process group is empty, and kills what is left after 5 s. `CHECK_DIR` names a directory to keep instead; CI sets it and reads the failure logs from it.
- **One run with a lane at a time on a PC.** The contract and browser tests are bound by timing. A second run names the first and exits. The lock is a loopback port (41737) the run listens on, so a run that died holds nothing.
- **`ci.yml`** calls `bun run check fast` and `bun run check integration browser`. Job names are unchanged, so the required checks are too. `HERDR_TEST_SHARDS` defaults to 1 in the script, where the workflow set it before.
- **`scripts/ci-browser.sh`** asks for Chromium's system packages (`--with-deps`, which needs sudo) only in CI. A PC still gets the lockfile's Chromium, not the system Chrome.

Not in this PR:

- No lock per checkout. `fast` rewrites the generated types file while it checks it, and every mode builds into `dist/`, so two runs in one checkout can still collide. The docs say one run per checkout.
- A local pass is still not CI's: the PC's Node (CI pins 22), cores and system libraries differ.
- The job layout is unchanged. In a successful run the integration lane ends after about 137 s and the job then waits for the browser lane (about 369 s); splitting that lane is a separate decision.

## Validation

On an Ubuntu PC with herdr 0.9.3 and Bun 1.4.2:

- `bun run check fast`: all five steps pass (typecheck 5.5 s, build 4.1 s, unit tests 57.4 s).
- `bun run check integration browser`: exit 0. Integration 231 pass in 145.7 s, browser lane 315.1 s. Afterwards no `/tmp/hwc-*` directory, no lock and no `herdr --session check-*` server were left.
- A second `bun run check browser` during that run exited at once: "another `bun run check` with a lane is running on this PC (pid …)". A lock holder killed with SIGKILL leaves the lock free at once.
- `bun run check run bun test --timeout 15000 ./server/herdr/client.test.ts`: 9 pass.
- SIGTERM to a `run` whose command had started a herdr server and was sleeping: the check exited 143, the command was gone, the server was stopped and the directory removed. A command that ignores SIGTERM keeps its directory until it is killed at 5 s; the check then exits 143 with nothing left behind.
- Two review findings from CodeRabbit (the lock's takeover race, cleanup before the step's processes had ended) are fixed in the second commit.
- `scripts/check.test.ts`: 7 cases for the argument plan, the isolation and the lock.
- This PR's own CI is the first run of both jobs through the script.

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


Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…#548)

## Change

Six scripts of the browser lane each built the same client for the demo before their first check: `key-bar-customization-demo`, `droplet-demo`, `chat-greeting-demo`, `composer-fit-demo`, `held-rows-demo` and `prompt-dock-demo`. The builds were identical: the same `vite build --base ./`, and the same bundle of `site/demo/transport.ts` with the package version.

- `scripts/demo-build.ts` holds that build (`buildDemoApp(dir)`), and is also a command: `bun scripts/demo-build.ts <dir>`.
- `scripts/ci-browser.sh` runs it once, names the directory in `HERDR_DEMO_BUILD`, and removes it when the lane ends.
- Each demo script calls `buildDemoApp(app)`. With `HERDR_DEMO_BUILD` set it copies that build into its own temporary directory; without it, it builds as before. Either way the script adds its own scripts to its own `index.html` and serves its own directory, so what each one injects (socket and fetch wrappers, the CSS substitution) stays private to it.
- `keyboard-viewport-demo-regression.ts` and `font-swap-demo-regression.ts` use the same helper. The lane does not run them, so for them nothing changes.

Nothing is cached between runs: the lane's directory is made for one run, and a script run on its own builds every time.

This is the small step. The job layout is unchanged: the browser lane still runs after the integration lane has finished, and the larger saving (splitting the lane into two jobs) is left for later, if the lane is still too slow.

## Validation

- On an Ubuntu PC, with the lockfile's Chromium: the lane's six demo scripts in a row took 143 s each building for itself, and 123 s sharing one build (the build itself 4 s). All six passed both ways. On the CI runner a build takes about 6 s, so the expected saving there is about 30 s of a browser lane of about 370 s.
- `keyboard-viewport-demo-regression.ts` and `font-swap-demo-regression.ts` pass on their own.
- `scripts/demo-build.test.ts`: the copy leaves the lane's files unchanged when a script rewrites its own `index.html`; a directory without a finished build is refused.
- `bun run typecheck`, `bun run test:unit` (1582 pass).
- The lane's cleanup: a script with the same `trap … EXIT` removes its directory when it is ended by SIGTERM.
- This PR's own browser lane is the first run of the whole lane with the shared build.

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


Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…#535)

## What

When a release is on offer, the app now tells what it brings before it is installed.

- **Settings → Updates** shows **What's new** under "Version X is available.": the changelog section of every release the update installs (all releases after the running one, up to the latest), newest first, in a box that scrolls on its own so the buttons stay in reach.
- The line under the header gets a **What's new** button beside **Update**. It opens Settings on the Updates section (the last one of a long dialog). **Details**, shown after a failed install, opens there too.

## How

- The supervisor reads the release's own `CHANGELOG.md` during the check: `git show <release commit>:CHANGELOG.md`, which is already fetched with the tag. No GitHub API and no new network call. It is the tag's changelog, never `main`'s, so unreleased entries are not shown.
- `server/release-notes.ts` (pure) keeps the `## [X.Y.Z]` sections after the running version and up to the release, compared as numbers, newest first, within a 48 KB budget. Older releases over the budget are counted (`omitted`) and the UI names the count with a link to the full changelog. With no known running version, the latest release alone.
- New endpoint `GET /api/updates/notes` → `UpdateNotes { revision, releases: [{ version, date, notes }], omitted }` (`shared/update.ts`). `revision` is the commit the notes were read from; the client shows them only while the status offers that commit. The notes travel beside the status over IPC and stay out of `GET /api/updates`: that one is polled every 2 s while an update is on offer, and the notes are several KB.
- The client fetches the notes once per offered release (`src/lib/updates.ts`); `offeredNotes` (`src/lib/updateNotes.ts`) keeps them on screen during the 5-minute background check, which reports nothing available while it runs. A failed request is asked again with a bounded backoff (2 s to 30 s); a 4xx, which is what a server older than the endpoint answers, is not.
- The notes are text from a Git remote. They are checked for shape where they arrive (IPC and HTTP, `readUpdateNotes`), each release's notes render inside a `RenderBoundary`, and the Markdown parser now nests quotes at most 32 deep: a line of 30,000 `>` overflowed the stack. That parser is the chat's too (`src/lib/markdown.ts`), where a render boundary already caught it.
- **What's new** moves the focus to the Updates section it scrolls to.
- The demo answers the new endpoint (`site/demo/transport.ts`).

## Limits

- The notes come from the supervisor that is running, which is the version being replaced. They appear from the update **after** this one.
- The notes are the changelog text, in English, in every UI language. Only the labels around them are translated.
- A release without a `CHANGELOG.md`, or without a section for itself, is offered without notes. A server older than this endpoint answers 405 and the client shows the update as before.
- With `HERDR_WEB_AUTO_UPDATE=1` an update installs as soon as it is found, so there is nothing to read beforehand. Showing notes after an update is not part of this PR.

## Review

- Codex adversarial review (gpt-6.1-sol, xhigh) on the first four commits: no Critical or High finding; four Medium and two Low, all reproduced and fixed in 2a14be4 with tests (stale notes after a tag moves, notes hidden when the newest release has an empty section, no retry after one failed request, the deep-quote crash, unchecked IPC entries, focus left at the top of Settings).
- A second Codex pass on 2a14be4 alone: the six findings are closed but for two it narrowed, and three more turned up. Fixed in 029ebba with tests: an answer that was not the offered commit's (a bridge just restarted) settled the offer for good, the notes were asked for once per commit though the versions on both ends pick the sections too, and a linked `## [Migration](…)` heading ended a release's notes. Not changed here: the Markdown parser the chat shares still has paths that are not linear (a bare URL followed by tens of thousands of `)` takes about 6 s to parse at the notes' 48,000-character limit), which freezes the tab and does not crash it; and Settings has no focus trap or return focus, as before this PR. Both are older than this change and belong to their own fixes.
- CodeRabbit: `## Migration` inside a release ended its notes (fixed in 2a14be4); the budget counts characters, not bytes (kept, answered in the thread).

## Tests

- `server/release-notes.test.ts`: one release, several skipped releases, numeric ordering (0.3.10 after 0.3.9), nothing unreleased or newer than the target, empty sections and compare links left out, unknown running version, budget and cut.
- `server/updater.test.ts`: real Git remotes. Notes come from the tag's changelog while `main` has moved on, are set when the status that offers the update is published, are cleared after the install, and a release without a changelog is still offered.
- `server/api.contract.test.ts`: the endpoint's shape, `no-store`, 405 for POST, the token gate, and a managed service's notes passed through.
- `src/lib/updateNotes.test.ts`: which notes are shown beside an offer, what makes an offer another one, when a failed request or an answer for another commit is asked again, and notes out of shape read as none.
- `src/lib/markdown.test.ts`: thousands of nested quote marks parse without overflowing the stack.
- `bun run test:unit` (1537 tests), `bun run typecheck`, `bun run build`, `bun run build:site` pass locally.
- `bun scripts/update-browser-qa.ts` (extended, not in CI) passes: a private remote cuts v99.0.0 with a changelog section, the notes appear in Settings, **What's new** on the header line opens Settings on them, the install and the rollback run as before, and the Updates section fits a 390 px viewport with the notes in it.

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



Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
devswha#537)

## Change

A pane whose agent had just started was listed as a shell for up to 5 seconds, and this is also the intermittent `chat-size-regression.ts:89` failure in the browser lane.

The local roster is read from herdr every 5 s and on structure changes, and patched by `pane-status` frames, which name no agent. A pane that got its agent after the roster's read of it stayed a shell in the roster until the next read. A page that opened the pane in that time chose the terminal lens (a shell has no chat), attached without `keep_size` and resized the pty shared with other devices. When the roster caught up the page switched to chat, with the grid already fitted to it.

- `server/machines.ts`: `localAgents()` takes herdr's word on each local pane's agent from the status collector and reads the roster again when that is news. A pane first heard of as a shell, and an agent named again, read nothing. The cache follows the roster and `pane-exited`, so a pane id used again is heard anew.
- `server/collector.ts`: a new optional handler, `onReconciled`, hands on a reconcile's snapshot after the status events that snapshot replays, so the roster read it starts cannot show a finish ahead of its `pane-status` frame.
- `server/index.ts`: wires both; an OmO pane is told as `omo`, as snapshots name it.
- `scripts/chat-size-regression.ts`: both checks wait until the roster names the agent before a page opens the pane. What they check is the chat lens, not how fresh the roster is.
- `scripts/ui-regression.ts`: the update-status step (see below).

Not changed: a remote PC. Its bridge keeps no roster, and the connection server already reads it again on each of its status frames; an agent that starts there without a status frame can still show up to 5 s late.

### The update-status step

The first CI run of this branch failed at `ui-regression.ts:420` (run 37594994984), and every time locally. That step says a hide and a show restart the update status poll at once, onto a fake status. They never did: both events landed before the task in which React takes the first, so they were one render with nothing changed (measured: the show comes 0.7 ms after the hide, a task posted at the hide runs 8 ms later). The step passed only when the 30 s poll fell inside its 10 s wait. With the roster naming agents at once, the steps before it finish about 8 s sooner (the page is 15.5 s old there instead of 24.1 s), so the poll no longer fell inside the wait. The step now lets the app take the hide before the show, and waits for the request to the fake.

## Validation

- Measured with a probe on `/api/machines` (own herdr test session): after `pane.report_agent` on a listed pane the roster named the agent after 4.8 s on `main` (six rounds, 4780–4888 ms) and after 245–651 ms with this change.
- Reproduced the CI failure (run 37499728792) on `main`: with the agent reported 300 ms after the pane is made, `checkChatKeepsTerminalSize` fails at line 89 every time with the same diff (`attach` without `keep_size`, then a `resize`). With the server change alone it passes three times out of three.
- `server/machines-refresh.test.ts`: two new cases (an agent named anew is read at once and only once; a pane that is gone or ended is heard anew). `server/collector.test.ts`: the reconciled snapshot comes after the status it replays.
- Adversarial review with Codex (gpt-6.1-sol, xhigh): two Medium and one Low finding, all three verified in code and fixed in the second commit (roster ahead of a replayed finish; a reused pane id; OmO's two names).
- `bun run typecheck`, `bun run test:unit`, `server/api.contract.test.ts` (89 pass), and the whole of `scripts/ui-regression.ts` (78 PASS lines) on an owned test session.

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


Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…devswha#547)

## What

The Markdown parser (`src/lib/markdown.ts`) runs while the page draws, on whatever an agent printed. Some shapes of text cost far more than their length: one crashes the chat in Chrome, the others freeze the tab for up to twelve seconds. The first five were found by the second adversarial pass on devswha#535, which shows changelog text through the same parser; the last three by the adversarial pass on this PR.

| Input (48,000 characters) | Before, Bun (JSC) | Before, Node (V8) | After, Bun | After, Node |
| --- | --- | --- | --- | --- |
| `_` repeated, then ` x` | 338 ms | **RangeError** (stack) | 3 ms | 3 ms |
| a URL, then `)` repeated | 5,858 ms | 12,459 ms | 2 ms | 4 ms |
| `\[` on 16,000 lines | 1,860 ms | 3,368 ms | 8 ms | 21 ms |
| a table header, then a line of spaces | 1,504 ms | 1,734 ms | 0 ms | 0 ms |
| `---\|---`, then spaces and a letter | 1,246 ms | 1,484 ms | 0 ms | 0 ms |
| a list item with 12,000 continuation lines | 911 ms | 1,232 ms | 6 ms | 13 ms |
| `# `, spaces, then a U+2028 in the text | 1,500 ms | 1,249 ms | 1 ms | 4 ms |
| `+` repeated (the path pattern alone) | 494 ms | 1,745 ms | 0.2 ms | 0.4 ms |
| 4,000 nested `\frac` in one formula (KaTeX, then the depth check) | 1,034 ms | 296 ms | 0.1 ms | 0.1 ms |

## How

- **Emphasis nesting** (`parseInline`): a run of underscores is read as emphasis around a shorter run, one call per pair, so 48,000 of them go 12,000 calls deep. V8 runs out of stack, and nothing around a chat message catches a throw from `Markdown`. Nesting stops after sixteen levels and what is inside stays text. A link's label is not counted as a level.
- **`trimUrl`**: took both parenthesis counts again (two `split`s of the whole URL) at every closing parenthesis it considered. They are counted once and the closing count goes down as one is cut. `src/lib/sshOutput.ts` uses the same function.
- **Display math**: a `\[` that never closes searched to the end of the text, and so did every `\[` after it. A search that ends without a close covers every later `\[` up to where it stopped (the end, or a fence): those lines are not searched again.
- **Table separator**: one pattern, `^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$`, whose optional parts share a run of spaces in every possible way before it fails. `isTableSeparator` reads the line cell by cell.
- **List continuation**: `item.content = [...item.content, …]` at every indented line of an item. The nodes are pushed.
- **Heading and list lines**: `\s+(.+)$` fails when the text holds U+2028 or U+2029 (`.` stops there, and only `\n` ends a line here), then tries every split of the spaces before it. Both patterns take the `s` flag. Such a line is now the heading or item it reads as; it was a paragraph.
- **File paths in text** (`src/lib/filePaths.ts`, called while a text node is drawn): `+` is a character of a path, but did not stop a path from starting right after it, so a run of plus signs was tried from every one of them. A path starts once in such a run. `~/` after a plus sign still starts one (`+~/notes/a.md`). Compared with the old pattern on 597,870 generated lines: the only lines read differently have `~` or `/` right before a run that holds a plus sign (`~+a/b.ts`, `//+a/b.ts`), where the old pattern found a path in the middle of the run.
- **Nested formulas**: KaTeX takes over a second on thousands of nested fractions before it throws, and the throw was already caught. A formula whose braces nest deeper than a hundred is shown as its source without asking KaTeX.

## Not in this PR

- **Quote nesting** (`>` repeated) also overflows the stack. devswha#535 caps it, in the blockquote branch this PR does not touch; the two merge in either order.
- **Openers that never close on one long line** (`[a` repeated, `\(` repeated, ` _a` repeated) still grow with the square of the line: 220 to 680 ms at 48,000 characters in V8. They come from the one pattern that finds every inline mark, and making them linear means writing that scanner by hand. Not a freeze of seconds at this size; left for its own change.
- Parsing stays synchronous, inside `useMemo` in `Markdown.tsx`.

## Review

Codex adversarial review (gpt-6.1-sol, xhigh) on the first two commits: output identical to `main` for `trimUrl`, `isTableSeparator`, the display-math skip and the list change on its own generated inputs; every new test fails with its fix reverted; fixed cases peak at 31 ms under the one-second limits. Four findings, all reproduced and fixed in f03a734: the two patterns that fail on a line separator, the plus-sign scan in the path matcher, the KaTeX stall, and a link label counted as a level of emphasis.

## Tests

`src/lib/markdown.test.ts`, a new `describe`, and one test in `src/lib/filePaths.test.ts`. Each fails without its fix (checked by putting the old code back):

- `trimUrl` gives the answer of the old rule on every string of up to six of `a ( ) . _` after a URL (19,531 URLs), and cuts 48,000 parentheses within a second.
- `isTableSeparator` gives the old pattern's answer on every line of up to six of ` `, `|`, `---`, `--`, `:`, `x` (55,987 lines), and the two long lines parse within a second.
- 32,000 unclosed `\[` lines parse within a second, as paragraphs; a formula after a fence is still found.
- An item of 48,000 continuation lines parses within a second, with all of them in it.
- 48,000 underscores come back less than twenty levels deep with the rest as text; `**a _b ~~c~~_**` and eight nested `__` nest as before; sixteen levels read whole, inside a link's label too.
- A heading, a `-` item and a `1.` item with 48,000 spaces and a line separator parse within a second, as a heading and an item.
- 4,000 nested fractions are shown as their source within a second with KaTeX loaded, and the formula beside them is still drawn.
- 48,000 plus signs are split within 250 ms; `+src/app.ts`, `c++/x/y.cc`, `a+b/c.ts` and `+~/notes/a.md` read as before.

The time limits are a second where the fixed parser takes a few milliseconds and `main` takes 1.3 to 7.5 s on the same inputs.

`bun run test:unit` (1591 tests), `bun run typecheck` and `bun run build` pass locally. The table above is `parseMarkdown` on each input under Bun 1.4.2 and Node 24.18.

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


Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…aiting agent is a red question mark (devswha#557)

## Change

Two things seen while checking the demo before the release.

- **A linked worktree's row is titled by its workspace name.** It said its branch, and the name its owner gave it only in the tooltip and the ⋯ menu. The row now says the workspace name: the one its owner gave, or the one herdr made from the branch until then (slashes as dashes, the checkout folder's name), so an unnamed worktree reads as before and a named one reads by its name. The branch follows in dim text where the name does not already say it, and stays in the tooltip and the menu. Two-line rows follow the same order.
- **A waiting agent's mark is a red question mark**, instead of a filled speech bubble: it asks for an answer. A finished one keeps its green dot, running its dim arc, a restore error its red triangle, so no two states share a shape.

```
?  로스터 수정            waiting for an answer
●  fix-roster-new-agent   finished, not looked at
◌  Sidebar redesign       running
⚠  old-checkout           restore failed
```

## Validation

- `src/lib/paneName.test.ts`: the two-line helper's worktree cases read the name first and the branch after it.
- `scripts/ui-regression.ts`: the worktree checks now expect the name as the row title and the exact branch (slash included) in the dim label, through creation, a pre-create inventory, and a rename. Run whole on an isolated herdr (`bun run check run bun scripts/ui-regression.ts`): 80 PASS.
- `bun run typecheck`, `bun run build`.

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


Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
## Change

**Settings → Appearance → Sidebar grouping** offered **By workspace** and **By folder**. This removes the folder view and the setting with it: the sidebar always lists workspaces, a repository's worktrees under it.

- `Sidebar.tsx` loses the folder branch: its row renderer, the folder folds and their storage, the reveal-on-select effect and the flat reorder path. A row menu no longer carries a scope or a kind, because a workspace has one row. Its close item is always **Close workspace**; the single-pane **Close** item existed only in the folder view.
- `settings.ts` drops `sidebarGrouping`. A stored `"directory"` is ignored, so a device that had chosen **By folder** shows workspaces again.
- `lib/directoryGroups.ts` and its test are deleted, with the folder CSS, ten translation keys per language and the `DESIGN.md` passages that described the view.
- `scripts/ui-regression.ts` no longer switches grouping, and its folder-fold block is gone. The hand-run `scripts/directory-groups-regression.ts` keeps its workspace-row checks (one row per workspace, the split pane reached from the tab strip, the two rename scopes) as `scripts/workspace-rows-regression.ts`.

Not changed: the folder glyph that leads a workspace row and folds its worktrees, **Sidebar rows** (one or two lines), and the `herdr-web-ui:directory-collapsed:*` keys an older version left in a browser's storage, which nothing reads now.

## Validation

- `bun run check fast`: workflow syntax, generated types, typecheck, build, unit tests (1642 pass, 0 fail).
- `bun run check run bun scripts/ui-regression.ts`: 79 PASS. A first run timed out at "the message sent before the snapshot is a live pending row", a chat step that runs before any step this PR edits; the rerun passed.
- `bun run check run bun scripts/workspace-rows-regression.ts`: PASS.

No screenshot: the sidebar in the default **By workspace** view is drawn as before, and the only visible change is one row fewer in Settings → Appearance.

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



Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…evswha#550)

## What

On a first install, `install.sh` mentions a GitHub star with a link. This lets the installer give that star when the user says so, and brings the same mention to `install.ps1`, which had none.

- The installer reads whether the account the gh CLI is signed in to has starred the repository (`gh api --include user/starred/devswha/herdr-web-ui`), by the HTTP status GitHub answers with.
  - 204, already starred: it says nothing.
  - 404, not starred, at a terminal: it prints the link and asks once, `star it now with the GitHub account gh is signed in to? [y/N]`. Only `y` or `yes` stars (`PUT user/starred/…`). Enter, `n` or anything else does nothing.
  - No gh, no sign-in, any other status, no answer from gh, or no terminal: the link line as before, and no question.
- It never stars without that answer.
  - No terminal (a script, a pipe) or `CI` set: nothing is asked. `install.sh` asks only when stdout is a terminal and `/dev/tty` opens, `install.ps1` only when neither console input nor output is redirected.
  - A terminal with nobody at it (an agent's pty, an unattended `docker exec -t`): the question waits 20 seconds and goes on without a star. `install.sh` reads the answer with bash's `read -t` (POSIX `read` cannot give up; without bash it does not ask), `install.ps1` waits for a first key.
  - What was typed before the question is discarded, so that it is not taken for the answer (`install.sh` needs perl for this; without it the typed-ahead line is read).
- gh gets 10 seconds for each call, so a gh that hangs does not hold up an install that is done.
- A rerun stays quiet, as before, and does not call gh.
- In `install.sh` the block moves to the end, after the PATH advice, so that leaving the question loses none of the output above it. Ctrl-C at the question ends the script with status 0.
- Nothing in the block can fail an install that is already done: a gh that refuses the star is said in one line.

## Checks

- New `scripts/install-star.test.ts` (unit suite): the whole `install.sh`, run as `curl … | sh` runs it, on a PATH that holds only what it needs, with stand-ins for herdr, node and gh. Without a terminal: no gh, starred, not starred, a rerun, a gh that hangs. At a terminal (`script(1)`, Linux): `y`, Enter, `n`, `yy`, end of input, typed-ahead `y`, Ctrl-C, a refused star, a 403, a 500 whose text names a 404, no sign-in, `CI` set, and nobody answering. The file takes about 20 seconds, which is the question's own wait; its tests run side by side.
- Each fix taken out of `install.sh` in turn (the INT trap, the typed-ahead discard, the `CI` check, the status match) makes the test for it fail.
- `scripts/windows-install.test.ps1`: gh is a stand-in `gh.cmd` first on PATH, run as a real process, so native stderr and exit codes cross the same boundary as in an install. It covers starred, no sign-in, a rerun with a caller's `$newInstall` set, a gh that hangs, `CI` set, and, with the installer's own text and its two terminal answers given, every answer to the question. It passes under Windows PowerShell 5.1 in CI's "Native Windows install" job.
- "Real Windows install" (this commit on a clean Windows runner) passes.
- `shellcheck install.sh` is clean.
- `ONE_LINE=1 bun scripts/fresh-install-docker.ts devswha/herdr-web-ui v0.3.52` (a bare Ubuntu, no gh) passes with the final `install.sh`: the link line is the last line of the first install, and the reruns stay quiet about it.
- A Codex adversarial review (gpt-6.1-sol, xhigh) found six issues; all six are fixed in `cb87114`.
- A second Codex pass at `3700e6d` (the merge session) found one High and six Medium: a `y` typed before the question was taken for its answer on a PC without perl or when the flush failed; the question went to stdout while the answer came from `/dev/tty`; a background job reaching for the terminal was stopped by it; the gh watcher sent only TERM and outlived a quick call; on Windows a first key was followed by an unbounded `Read-Host`, gh's output completion was outside the deadline with no disposal, and the test's `gh.cmd` stand-in could not run through `Process.Start`. Fixed in `eaf9cdf` (Unix) and `40b4d6b` (Windows), each with tests that fail on the code before them: a PC without perl with `y` typed ahead, a background job under a shell with job control, a gh that ignores TERM, the 20 seconds measured from the question itself; on Windows the answer is read key by key within one deadline, the shim runs through `cmd.exe`, and the test asserts the status read happened.
- A third pass on the delta `3700e6d..73afcb8`: the High and five Medium are closed, the watcher cleanup is narrowed to Low (its `sleep 1` is briefly orphaned after a quick call; it identifies gh by PID), and one Medium stays for a follow-up, devswha#558: a `.cmd` shim's child that keeps the stdout pipe can outlive the deadline, and the readers are not closed by `Dispose` on .NET Framework. The official `gh.exe` is not a shim.
- CodeRabbit on the fixes: the Unix status read went through a pipe into `sed`, which a child gh left holding its output would hold past the deadline; it goes through a file now, read once `gh_api` has returned, with a stand-in mode and a test for it.

## Not covered

- A real `gh` giving a real star was not exercised: every run used a stand-in. The status lines the code tells apart (204, 404, none without a sign-in) were read from the real gh 2.63.2.
- On Windows, whether the console check and the 20-second key-by-key answer behave at a real console was not exercised: CI has no console, so the test gives those two answers itself.
- macOS: `/bin/sh` there is bash 3.2, which has `read -t`; the terminal tests run on Linux only.
- Marketplace installs (`herdr plugin install`) do not run either installer and see none of this.

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




Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he mic button and a phone's Agents list (devswha#559)

## Change

Four defaults, chosen while looking at the demo on a fresh browser.

- **Sidebar rows default to Two lines.** A workspace row says what its agent is doing, with the workspace and folder under it. **One line** stays in Settings → Appearance.
- **The suggestion chip is on by default.** On a touch screen the chip above the message box takes Claude Code's suggested next prompt; devswha#223 had turned it off until chosen. It is still hidden where a mouse is used (Tab takes the suggestion there).
- **A phone's Agents list starts folded.** At the width where the sidebar is a drawer (`max-width: 768px`) the list opens folded, with its heading and count; a tap opens it. The fold is not stored, as before.
- **The mic shows in a desktop's chat without being turned on.** `voiceInput` was a boolean, off until chosen. It is now `auto | on | off`, default `auto`, and Settings → Voice input → Microphone button is a three-way control.

| | desktop chat | phone chat | terminal input line |
|---|---|---|---|
| Auto (default) | shown where dictation can work | hidden | hidden |
| On | shown | shown | shown |
| Off | hidden | hidden | hidden |

"Can work" is `useVoiceInput`'s `available`: a secure context, and a server key or the browser's own recognition. On Auto a mic that could not work is left out; On keeps today's behavior, disabled with its reason. "Phone" is the composer's own `(max-width: 640px), (pointer: coarse)`, so a tablet counts as one.

Decisions worth a look in review:

- **A stored `false` reads as `auto`.** Any settings change writes the whole record, so nearly every existing device holds `voiceInput: false` whether or not anyone chose it. Reading it as off would show the mic to new browsers only. The cost: a device that had turned the mic off on purpose sees it again in a desktop's chat. A stored `true` reads as `on`.
- **Without a key, Chrome and Edge send the audio to Google or Microsoft** once the mic is pressed. Nothing is recorded before that, and the Settings hint says so, but the button is now there without a choice.
- A desktop's composer now asks `GET /api/voice` on mount (one request, shared by every composer). `scripts/chat-history-browser-qa.ts` serves its own fixture and did not answer that route, which made its process exit non-zero; it answers it now. The demo transport already did.

The unreleased CHANGELOG line of devswha#521 said One line "stays the default"; it is reworded.

## Validation

- `bun run check fast`: workflow syntax, generated types, typecheck, build, unit tests (1648 pass, 0 fail).
- `bun run check browser`: the whole lane, 173 PASS, exit 0.
- Outside the lane, each with `bun run check run`: `pi-image-browser-qa`, `skill-activity-browser-qa`, `update-browser-qa`, `font-swap-demo-regression`, `keyboard-viewport-demo-regression`, all exit 0.
- New in `scripts/ui-regression.ts`: rows are two lines until One line is chosen; a phone shows the chip until Settings turns it off; a phone's drawer opens with Agents folded; a desktop's chat offers the mic and a phone's does not.
- New unit tests in `src/lib/settings.test.ts` for the three-way setting, the legacy boolean and where Auto asks for the mic.
- One earlier run of the lane timed out at "the message sent before the snapshot is a live pending row" (the step devswha#556 also reported); the reruns passed.

No screenshot attached. README and site media still show one-line rows.

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


Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ount of dropped keys (devswha#561)

## What

The terminal's **Input held until the terminal is ready** notice showed "— 145 special keys dropped" on a pane where nothing had been typed. The notice now appears only when there is text to send or discard, and the count is gone.

## Why

While a pane cannot take input (disconnected, or its attach still opening), every `onData` chunk goes to the draft (`src/lib/draft.ts`). Printable text is held for the user to send or discard. Everything else was counted as a "special key dropped" and the count alone kept the notice up, with a dash where the text would be and a **Send** button that could not be pressed.

`onData` is not only keys: xterm answers a program through it by itself (cursor position, focus and mouse reports). So the count rose with no key pressed, and a number such as 145 told nothing a person could act on. A chord the terminal cannot take is already told on its own ("Not sent: the terminal is not ready for keys.").

## How

- `InputDraft` is its text and whether text was left out for length. `applyToDraft` leaves out a chunk with controls without counting it.
- Text past the 1,024 characters the draft holds is left out whole, as before, and is now told by itself: "Some input was too long to hold and was left out." The count used to be its only sign. It is told also when nothing else is held (one pasted chunk over the limit), and beside a preview that shows only the start of the text.
- The notice shows the held text, **Send** and **Discard**, as before, and only when there is text or such a loss.
- A draft stored by an earlier version keeps its text; one that held only a count is empty and is removed.
- The two "special key(s) dropped" strings and `.draft-dropped` are removed.

Unchanged: nothing typed is sent automatically, and held text is never replayed without **Send**.

## Review

Codex adversarial review (gpt-6.1-sol, xhigh) on 1c28de1: one Medium and one Low, both confirmed and fixed in 35fc209.

- Medium: with the count gone, text past the cap was dropped with nothing on screen. Fixed as above, with tests for one oversized chunk and for typing past the cap.
- Low: `docs/terminal-input.md` still said control sequences are counted. Rewritten.

## Tests

- `src/lib/draft.test.ts`: special keys and xterm's own reports (cursor position, focus, mouse) hold nothing and leave the draft empty; the cap and the loss it tells, for typing and for one chunk; a loss that stays told; IME commits.
- `bun run check fast` passes (1,671 unit tests, typecheck, build).
- Not run: the browser regressions. The notice needs a pane that cannot take input, which no script sets up.

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


Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ght once it runs (devswha#560)

## What

Follow-up to devswha#535, from trying its notes on a private preview: the notes vanished the moment the update was installed, and they were the raw changelog.

- **An update tells what it brought once it runs.** After the reload, the line under the header says "herdr web ui was updated to vX." with **What's new** and **Dismiss**. **Settings → Updates** keeps the notes under **What the last update brought**.
- **A release is told the way a game's patch notes are**, before and after its install: short lines under **New features**, **Improvements** and **Bug fixes**, in the app's language (English, Korean, Japanese, Chinese). The changelog section is folded under **Show every change**. A release that wrote no such list shows its section as before.
- **The lists are written at release time**, in `release-summaries.json` beside `CHANGELOG.md`: `{ "X.Y.Z": { "en": { "new": [], "improved": [], "fixed": [] }, "ko": …, "ja": …, "zh": … } }`, plain text. The release workflow and the unit suite refuse a release without all four languages.

## How

- `shared/update.ts`: `readSummary` reads a summary wherever it arrives (the file from a Git remote, IPC, HTTP): the lines that are text, under the lists and languages the app has, each cut at 160 characters and each list at eight lines. `server/release-notes.ts` attaches it to a release.
- Before an install: the supervisor reads `git show <release commit>:release-summaries.json` beside the changelog. A release older than the file is told by its section alone.
- After an install: `Updater.brought` reads the running release's own `CHANGELOG.md` and `release-summaries.json` from disk (each at most 2 MB), from the version it replaced to the one that runs. The installing supervisor writes that version and the time into `current.json`. A supervisor older than that record leaves none, and then the version is the newest older build still present: an install keeps the release it replaces, and the source checkout stays. **So the notes appear with the first update to a version that has this**, installed by a version that has none of it.
- New endpoint `GET /api/updates/installed` → `InstalledNotes { revision, version, previous_version, installed_at, releases, omitted }`, sent beside the status over IPC like the offer's notes. A source checkout answers, for its commit, that no update installed it. The demo answers the endpoint too.
- Client: asked for once per running revision (`src/lib/updates.ts`); an answer that names another commit or none (the bridge is still run by the supervisor that installed it) is asked again a few times. `announcesUpdate` (`src/lib/updateNotes.ts`) shows the line for a week after the install, until the device closes it or opens its notes (a version in `localStorage`), and gives the line to a newer release once one is known. Settings shows the offered release's notes and, under them, what the last update brought.
- `scripts/release-notes.ts`, which the release workflow runs before it tags, fails when the version lacks a language.

## Decided by the maintainer

Written at release time (not generated at run time), all four UI languages, the header line plus Settings (not a popup), the changelog folded under the summary, and the summary as a concise list like a game's patch notes (a first version showed a paragraph, which he turned down).

## My picks, open to change

- Three lists, named New features, Improvements and Bug fixes; a line is 90 characters at most (a test on this repository's file), the same number of lines in every language.
- The line stays a week and closes per device; **What's new** closes it as **Dismiss** does.
- Settings keeps **What the last update brought** for as long as that release runs.
- The file is `release-summaries.json` at the repository root.

## Limits

- The lists of an offered release are still read by the running version, so they show before an install from the update after this one (as devswha#535's notes do). After the install they show at once.
- This PR writes no summary: the first one is written with the next release PR.
- Nothing translates or checks the four languages against each other beyond the number of lines.

## Review

Codex adversarial review (gpt-6.1-sol, xhigh) on the first three commits: no Critical or High finding, four Medium, all fixed in e559d7d with tests.

- Files of an installed release were read whole: now within 2 MB, and an oversized one is not notes.
- A tab that loaded during the supervisor handover got an answer that named no commit and kept it: such an answer is asked again.
- The replaced version was inferred from the directories present, which a build left by an interrupted install could mislead: the installing supervisor now writes it down, and the inference is only for a release installed by an older supervisor.
- With a newer release on offer, a check let the "updated" line show in its place, and its button opened the other release's notes: the line now yields while a newer release is known, and Settings shows both.

- A second Codex pass (the merge session, at `e559d7d`): no High; three Medium and two Low. Fixed in `8aa8a05` and `1b90605` with tests that fail on the code before them: the budget counted the changelog sections alone, so summaries went past it and a version named twice was told twice (a thousand duplicate sections made 9 MB of notes from 32 KB); the disk read was stat-then-read, which a growing file, a symlink, a FIFO or a device got around; an install time ahead of now (a clock set back since) stretched the week-long line; a dismissal reached only its own tab. Left as designed: the legacy inference of the replaced version (for a release installed by a supervisor older than the record) can pick a retained build of an interrupted install; it runs once, for the first update to a version with this.
- A third pass on the delta: nothing above Low. Three Low fixed in the commit after: the newest release's summary now keeps to the budget with its notes, an answer with more releases than the cap counts what the cap dropped, and a tab subscribes to the dismissal before it reads it.

## Tests

- `server/release-notes.test.ts`: lists parsed and attached, a file that is not one (a `__proto__` key among them), the cuts, and this repository's own file (every release after 0.3.52 told in four languages, short lines, the same lists in each).
- `server/updater.test.ts` (real Git remotes and installs): the offer carries the summary, the install reports what it brought from 0.1.0 to 0.2.0, a supervisor started afterwards reads the same, the next update replaces that release (not the source checkout, not a stray build), a record without the new fields is inferred, and oversized files are not read.
- `server/api.contract.test.ts`: the endpoint's shape, `no-store`, 405 for POST, the token gate, a managed service's answer passed through.
- `src/lib/updateNotes.test.ts`: the language fallback, when the line shows, stops and yields, answers out of shape.
- `scripts/release-notes.test.ts`: a release missing a language cannot be released.
- `bun run check fast` passes (1,687 unit tests, typecheck, build).
- `bun scripts/update-browser-qa.ts` (extended, not in CI) passes: the lists with the folded changelog before the install, the line after the reload, its notes in Settings, the line closed across a reload, then the rollback as before.
- By hand: a private install of the real v0.3.52 updated to a local-only v0.3.53 cut from this branch. The 0.3.52 supervisor installed it, and the new version showed the line and the Korean lists on desktop and phone.

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



Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…evswha#562)

## Change

Settings was one scroll of fourteen sections: about eight screens on a desktop and ten on a phone, with the terminal's options under Appearance and each section drawing its own kind of box.

It now shows one page at a time.

- **Layout.** From 641px a list of pages stays beside the open page, in a dialog of a fixed size. A phone opens on the list and a page opens from it, with a Back control.
- **Pages.** Appearance, Chat, Terminal, Alerts, Voice input, Subscription usage, Shortcuts, Phone & devices, Remote PCs, About.
- **What moved.** Terminal font, wheel speed, input mode and Key bar: Appearance → Terminal. Composer and Quick replies → Chat. Phone, Install and Devices → Phone & devices. Updates and herdr → About.
- **One set of parts** (`src/components/SettingsControls.tsx`). A group is a caption over one card; a row is a label, at most one sentence and one control. The controls are a switch, a segmented control (two to four choices), a select (five or more: Colors, Language), a stepper and a text field. Shortcuts is rows instead of a table.
- **Back.** Each step into Settings (the dialog, a phone's page, the key bar editor) is an entry of the browser's history (`src/lib/settingsHistory.ts`), so a phone's Back button steps out of Settings instead of leaving the app. The dialog's Back control, the X and Escape take the same entries off.
- **Language order.** System, English, 简体中文, 日本語, 한국어.

No setting changes its stored value or its default. Four strings are gone (the descriptions under Theme, Colors and Density, and the "Microphone" caption) and one is new ("Phone & devices").

### Behavior that differs

- Every opening starts on Appearance (the list on a phone); the last page is not remembered.
- A page asks the server for what it shows when it is opened, not when the dialog is. The herdr update card follows an update only while About is shown.
- A font family field is also saved when its page is left.
- On a desktop, Back closes Settings and Forward reopens the page it showed.
- Only Settings is in the history. The other dialogs are unchanged.

### Not changed

Texts outside the dialog that name the old places ("Settings → Phone", "Settings → Updates" in `scripts/plugin.ts` output and server warnings) still say so. The documents (`docs/guide.md`, `INSTALL.md`, `SECURITY.md`, `docs/terminal-input.md`, `DESIGN.md`) name the new ones.

## Screens

Before (desktop, then the first four of ten phone screens):

<img src="https://raw.githubusercontent.com/devswha/herdr-web-ui/d019affad744d61686b8b1e4f4841fa9718209bd/docs/screenshots/settings-redesign/before-desktop.png" alt="Settings before: one long scroll on a desktop" width="800">
<img src="https://raw.githubusercontent.com/devswha/herdr-web-ui/d019affad744d61686b8b1e4f4841fa9718209bd/docs/screenshots/settings-redesign/before-phone.png" alt="Settings before: the first four screens of the scroll on a phone" width="800">

After:

<img src="https://raw.githubusercontent.com/devswha/herdr-web-ui/d019affad744d61686b8b1e4f4841fa9718209bd/docs/screenshots/settings-redesign/after-desktop.png" alt="Settings after: Appearance, Chat, Terminal and Voice input pages beside the list" width="800">
<img src="https://raw.githubusercontent.com/devswha/herdr-web-ui/d019affad744d61686b8b1e4f4841fa9718209bd/docs/screenshots/settings-redesign/after-phone.png" alt="Settings after on a phone: the list, Appearance, Chat with quick replies, Voice input" width="800">

From the demo build (fictional data). The images are pinned to commit d019aff and are not in the tree.

## Review

Codex adversarial review (gpt-6.1-sol, xhigh) at `b55eb1b`, in the merge session: no High; two Medium and three Low.

- Fixed in `419f822`: a window that changed width with Settings open left the history's steps wrong (a page a wider dialog opened directly got a second entry over it instead of the list under it; the list stayed under an editor the phone opened), so the module now knows which steps it holds, steps back to the first that differs and makes the rest anew, with a unit test on a fake history and a regression step that changes the width in both directions and walks Back; a button that points at Updates opens About whatever an entry still landing would restore; the PC settings are asked for when Remote PCs or About opens.
- A delta pass on the fix: a step under the one shown that the module did not push (a reload or a Forward landing on entries of an earlier opening) was taken for the list and a stack that did not exist was certified; fixed in `c8d3b0d`, such a step is unknown and rebuilt, with a unit test that lands on stale entries. The pages regression now checks each control against its own card (CodeRabbit) and waits for the rebuilt stack when the window widens.
- Filed as devswha#564: a navigation that is not Settings' (a fragment link, another script's `pushState`) while Settings is open buries its entries under the foreign one, so the next Back restores what was closed (the app does no such navigation today); and the pages regression runs in English only and does not wait for Voice's key form; also a traversal landing after the module's 1 s give-up reads as the user's Back.
- Found sound: every setting keeps its key, type and default (voiceInput, sidebar rows, key bar items, chat width and fonts, palette, language, alerts, usage order and hiding, shortcut overrides); the font drafts commit on blur, Enter and unmount; the stepper bounds; the IME Escape guard; `aria-pressed` on segmented controls and labels on the selects; late responses of a left page cannot overwrite a remounted one; the settings-page helper waits for the visible tabpanel.

## Validation

- `bun run check fast`: 1671 unit tests pass.
- `bun run check browser`: the whole lane passes. Twelve scripts gained a step that opens the page they need (`scripts/settings-page.ts`).
- New in the lane, `scripts/settings-pages-demo-regression.ts`: every page at 390px and 320px has no control outside its card (it fails on the quick replies list as first built), and Back is walked on a phone and a desktop.
- `terminal-input-regression` and `update-browser-qa` pass through `bun run check run` (before the merge of main). After main was merged in the merge session: `update-browser-qa` (4 PASS), `settings-pages-demo-regression` (7 PASS), `ui-regression` (80 PASS), typecheck, unit (settingsHistory, settings, i18n, keyBar) and build.
- Not run: `scripts/machines-browser-qa.ts`, which needs the SSH fixture and is not in CI. Its edits are the same helper call that passes in `ui-regression`.
- Back was checked in headless Chromium only, not on an Android device.

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




Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
## Change

Prepares v0.4.0 and remote bundle 20.

- `package.json` and `herdr-plugin.toml`: 0.3.52 → 0.4.0. A minor bump: the chat's Send/Stop and queued follow-ups (devswha#520), the sidebar redesign (devswha#521, devswha#556, devswha#557, devswha#559), the mobile key bar editor (devswha#508), Settings one page at a time (devswha#562), release notes before and after an update (devswha#535, devswha#560) and the installer's star offer (devswha#550) change how the app is used.
- `shared/machines.ts`: `REMOTE_BUNDLE_VERSION` 19 → 20. devswha#520, devswha#508, devswha#525 and devswha#537 changed `server/`, so remote PCs need a new bridge; until a PC updates, Send during work keeps the draft and asks for the update.
- `CHANGELOG.md`: the Unreleased notes become `[0.4.0] - 2026-10-08`, with the compare links; two stray blank lines inside the section removed; the devswha#521 entry says a red question mark (devswha#557), and the devswha#535 and devswha#560 entries say Settings → About (devswha#562).
- `release-summaries.json`: the first entry, 0.4.0 in English, Korean, Japanese and Chinese, seven lines under each of New features, Improvements and Bug fixes, which `Settings → About` and the header line show before and after the update installs.

## After the merge

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

## Validation

- `bun scripts/release-notes.ts 0.4.0` prints the notes (version sources agree, one heading, summaries in all four languages).
- `server/release-notes.test.ts` (this repository's summaries: every release since 0.3.52 told in four languages, lines ≤ 90 characters, the same lists in every language) and `scripts/release-notes.test.ts` pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
## 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>
…ort position (devswha#573)

## Change

`scripts/terminal-copy-regression.ts` raced the page in its drag steps
(devswha#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 devswha#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 devswha#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 devswha#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>
…a#572)

## Change

`tailscale status --json` writes user ids larger than
`Number.MAX_SAFE_INTEGER` (the report in devswha#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 devswha#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 devswha#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 devswha#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>
…wha#574)

<!-- 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. devswha#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>
…ts turn (devswha#576)

## 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 devswha#546
…evswha#580)

## 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.
devswha and others added 24 commits October 7, 2026 14:19
… own input-ready (devswha#579)

## 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 devswha#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.
…dr (devswha#577)

## 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 devswha#415, `settlePort()` waits up to 20 s while the recorded pid is
alive, then falls through to `freePort` (devswha#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` (devswha#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 devswha#428
…evswha#582)

Part of devswha#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>
…vswha#583)

## What

Two fixes for Claude Code panes in Chat, both reported by @knightnemo in
devswha#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 devswha#578. The goal-status card for the bare `/goal` dialog asked for
there is a new feature and stays open.
…evswha#575)

## 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 devswha#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 devswha#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 devswha#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 devswha#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](devswha#588 (review)))

| | 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.
…swha#590)

## 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 devswha#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-devswha#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-devswha#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-devswha#588 cost), and the comment
in the test says so.
…#519)

## 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>
… gating, focus traps, token backoff (devswha#570)

## Summary

The follow-up to devswha#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 devswha#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 devswha#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>
…opt-in (devswha#529)

Closes devswha#528. Rebuilt on the devswha#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. devswha#528 has the details.)

**Settings → Appearance → Quiet opened finishes (off).** herdr reports
DONE until one of its own clients shows the pane, and devswha#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. devswha#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 + devswha#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 devswha#589
and finding 6 is devswha#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 devswha#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 devswha#570 added after v0.4.0, so
they get no new lines. The devswha#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.
…icks for this session only (devswha#594)

## 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
devswha#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:

- devswha#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 (devswha#523
could not capture a real `/effort` screen), but it is the same feature,
so the changelog credits it.
- devswha#501 and devswha#503: the `/model` cards this follows (letter `s`, never
Enter, look before the letter).
- devswha#456 and devswha#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:

- devswha#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.
- devswha#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 (devswha#566, devswha#570,
devswha#588, devswha#592), Windows chat discovery (devswha#563, devswha#582), the plugin's port
handling (devswha#577), GJC menus (devswha#593) and more. The new pieces are small and
opt-in or additive: the sidebar's Activity order and Quiet opened
finishes (devswha#529, both off by default), the `/effort` card (devswha#594), the
clipboard-from-a-pane switch (devswha#570) and the Portal guide (devswha#229).
- `shared/machines.ts`: `REMOTE_BUNDLE_VERSION` 20 → 21. `server/` code
that ships in the bundle changed since v0.4.0 (devswha#566, devswha#563, devswha#572, devswha#576,
devswha#582, devswha#583, devswha#588, devswha#519, devswha#570, devswha#592, devswha#593, devswha#594), so remote PCs need a
new bridge.
- `CHANGELOG.md`: the Unreleased notes become `[0.4.1] - 2026-10-08`,
with the compare links. devswha#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).
Mescla devswha/herdr-web-ui v0.4.0 + v0.4.1 no fork. A sidebar segue a do
upstream (grade única, lista global de Agentes) com Precisa de você por PC,
cabeçalho Projetos, Histórico, + Nova aba e rodapé do fork por cima; By folder
removido. pt-BR para as strings novas e para os resumos de versão.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	design-system/components/bundle.css
#	src/components/FileViewer.tsx
#	src/components/FilesDialog.tsx
#	src/lib/i18n.pt.ts
#	src/lib/markdown.test.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o topo

A asserção ainda procurava o título no topo do celular, que saiu no #12.
Mesma correção do #18; falhava no CI desde então.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gunta no dock

Em celular até 480x600 a faixa de abas sempre visível (nome do painel) somada ao
compositor mais alto do upstream deixava o cartão da pergunta sem espaço: a opção 1
ficava coberta pelo texto de confirmação (prompt-dock-demo-regression, 390x500 a 24px).
Enquanto há cartão no dock a faixa some; volta ao responder. Alturas normais intactas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tbc-joaovitor
tbc-joaovitor merged commit e72a9e0 into main Oct 9, 2026
12 checks passed
@tbc-joaovitor
tbc-joaovitor deleted the sync/v0.4.1 branch October 9, 2026 11:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.