Skip to content

test(chat): count the escape scanner's work instead of timing it - #590

Merged
devswha merged 1 commit into
mainfrom
test/escape-scan-deterministic
Oct 8, 2026
Merged

devswha merged 1 commit into
mainfrom
test/escape-scan-deterministic

Conversation

@devswha

@devswha devswha commented Oct 8, 2026

Copy link
Copy Markdown
Owner

The failure

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

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-fix: pre-release review fixes for 0.4.1 #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-fix: pre-release review fixes for 0.4.1 #588 function was only quadratic on the escape shape (it is caught there by the regex charge, and stops at the first shape). For the closing-tag shape I added a content.slice(next) at every closing tag to the current scanner: Expected: < 2.5, Received: 3.999, with the escape shape still passing. Restored, cmp identical.
  • 200/200. HERDR_TEST_MODE=unit bun test ./server/conversation.test.ts -t "strips escapes" run 200 times, eight at a time: 200 passed.
  • bun run check fast: ok (1726 pass, 0 fail).

Note for review

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

The growth test divided two sub-millisecond timings (floor 0.05 ms), so
it failed "Fast checks" on main by scheduler noise. It now counts the
characters the parse reads through charCodeAt, indexOf, startsWith and
slice, charges a regex run the square of its input, and asserts that
doubling the answer at most 2.5x the count. No clock is read.
@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: devswha/herdr-web-ui/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 68c685cb-8718-4296-93b1-41790278d340
📥 Commits

Reviewing files that changed from the base of the PR and between 57d7663 and 76ff6d6.

📒 Files selected for processing (1)
  • server/conversation.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review.


📝 Summary

Summary by CodeRabbit

  • Tests
    • Updated malformed-answer parsing coverage to check how processing effort scales as input size increases, replacing timing-based measurements.

Walkthrough

The malformed-answer parsing test replaces repeated elapsed-time measurements with instrumented work counts for unterminated escapes and printed closing tags. It restores patched methods in a finally block and checks that doubling either input stays below a 2.5× work ratio.

Changes

Malformed-answer parsing test

Layer / File(s) Summary
Counted-work assertions
server/conversation.test.ts
The test counts string primitive work and charges regex execution quadratically. It checks the work ratio for doubled malformed inputs and restores patched methods in finally.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Other

Suggested reviewers: yoonwoo-ha

Merge Risk: ⚪ Minimal · up to 76ff6

This change replaces a flaky timing-based test with a deterministic work-counting test and does not affect runtime behavior. It looks safe to merge.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: replacing timing-based validation with escape-scanner work counting.
Description check ✅ Passed The description explains the failure, the new deterministic behavior, implementation details, regression coverage, and validation results. It does not use the template headings exactly, but it provide…
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 1…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@devswha devswha left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review of 76ff6d6 (lead session; the lane that wrote it is fix-timing).

Verdict: clean, merge when both required checks are green.

What I ran, in a scratch copy of 76ff6d6 (git archive, main's node_modules), Bun 1.4.2:

Check Result
bun test ./server/conversation.test.ts -t "strips escapes", 30 runs 30/30 pass
the same test with server/conversation.ts from 5979118 (the pre-#588 scanner) fails: Expected: < 2.5, Received: 7.999
the whole file on the PR head 52 pass, 0 fail

Read: the diff touches only the test. No clock is read any more, so the result does not depend on the runner's load; the count is a function of the input alone. The prototypes are wrapped only around one synchronous call and restored in finally.

Not run by me: the closing-tag mutation (3.999) and the 200-run loop from the description.

One thing to know, not a blocker: the test is tied to the primitives the scanner uses today (charCodeAt, indexOf, startsWith, slice, a regex per character). A rewrite that reads the answer another way would be counted as less work, or as none (NaN, which fails). The comment says so.

Background: main went red on the timing version at 57d7663 (run 37730991278) and passed on a rerun, which is what a ratio of two sub-millisecond timings does.

@devswha
devswha merged commit a851e9a into main Oct 8, 2026
12 checks passed
@devswha
devswha deleted the test/escape-scan-deterministic branch October 8, 2026 05:33
aNNdii added a commit to aNNdii/herdr-web-ui that referenced this pull request Oct 8, 2026
Upstream's settings redesign (devswha#562) replaces next's own (one page per
section, with search and a phone list); next's file viewer settings
become a File viewer page in upstream's dialog.
aNNdii added a commit to aNNdii/herdr-web-ui that referenced this pull request Oct 8, 2026
chore(sync): merge upstream main (v0.4.0, devswha#229–devswha#590) into next
devswha added a commit that referenced this pull request Oct 9, 2026
…lane (#627)

## Change

A failed browser lane kept herdr's server log, but no page screenshots
and no reliable per-script timings. This adds wall-second timing and
exit-code lines for all 17 browser scripts plus the shared demo build,
and an EXIT summary of every script run so far. The first failure still
stops the lane; the trap preserves its exit code and removes the
temporary demo build. Order, checks, and retries are unchanged.

With CI or CHECK_DIR set, existing screenshots go under
`${CHECK_DIR:-.ci}/browser-evidence`. The failure artifact includes
those PNGs and the existing herdr logs, with hidden files explicitly
enabled for the narrowly scoped `.ci` paths.

Evidence is deliberately restricted to sticky-modifier and file-viewer
checkpoints (eight viewport PNGs on a complete passing run). The UI
suite has evidence-only assertions and viewport changes, and the key-bar
demo adds viewport changes and font waits, so their evidence branches
remain off in the lane. No live user session is captured: these scripts
use isolated test panes and synthetic transcripts. Outside CI/CHECK_DIR,
explicit local UI_EVIDENCE_DIR behavior is preserved.

Remaining #552 work:
- Capture every open page at the exact failure point; the scripts have
no common browser launcher/failure handler.
- Playwright traces of failing contexts.
- Timestamps on the web UI test servers' own lines; there is no common
test-server output wrapper across these scripts.
- Recording browser-received frames.
- Multiple-run measurement for #548 and decisions on #553; this PR does
not split the lane.
- Investigating the seven historical fork runs with no jobs/logs.

No CHANGELOG entry: CI/test-only changes are not user-visible, following
#548, #581, and #590.

## Validation

- Four cheap `scripts/ci-browser.test.ts` tests exercise the real shell
lane against stub commands: passing and failing summaries, exact exit 7,
first-failure stop, temporary-directory cleanup, evidence filtering, and
CI/CHECK_DIR/local behavior. The first three failed on the unchanged
lane before implementation, then all four passed.
- Captured a failing stub run: both passing steps and the failing step
print numeric seconds, the EXIT summary includes all three, and the
process exits 7.
- `bash -n scripts/ci-browser.sh` and `git diff --check` passed.
- `dori heavy tri-client3 -- bun run check fast` passed: actionlint,
generated types, typecheck, build, and 1,932 unit tests passed, 0 failed
(six existing skips).
- The PR's CI browser lane will provide the real-surface timing proof
and comparison against recent main runs.

Refs #552
jiunshinn added a commit to jiunshinn/herdr-web-ui that referenced this pull request Oct 9, 2026
…swha#625, devswha#627, devswha#632, devswha#646, devswha#647, devswha#648, devswha#658) (#5)

* fix(terminal): recheck the sender's right to type right before herdr is written to (devswha#624)

Fixes devswha#545. Refs devswha#589 (both points in the issue body; the window in its
follow-up comment is left as is, see below).

## Problem

- **devswha#545**: a queued terminal chord or text passed its attachment, claim
and authorization checks inside `serialize(...)`, then waited for
`herdrRpc`'s Unix-socket connect. If the sender detached, switched to
observe or had its device revoked during that wait, the request was
still written when the connection resolved.
- **devswha#589**: the `secret` handler re-checked the connection, attachment,
claim and readiness after its live-screen read, but not (1) whether the
pane's pty had been replaced under the same attachment (a live handoff),
or (2) whether the sender had switched interact → observe → interact
while the read was pending.

## Change

- `herdrRpc(method, params, socketPath, timeoutMs, guard?)`: an optional
guard asked once more right before `sock.write`. False sends nothing and
rejects with `cancelled`. `paneSendText` / `paneSendKeys` pass it
through. Callers without a guard are unchanged.
- `keys` handler: the checks it already makes become the guard; a
cancelled send is answered `input_failed` (as the existing cancel paths
are, per `server/AGENTS.md`).
- `input` handler (mirrored and queued branches): the same, through the
existing `.catch(inputFailed)`.
- `SocketData.roles` counts mode changes. The secret handler captures it
and the attachment's pty when it accepts the secret, and refuses after
the screen read with `read_only` (role changed) or `input_not_ready`
(pty replaced). On a mirrored pane the secret's text also carries the
guard.

**Left as is (devswha#589 comment):** on a mirrored pane, once the secret's
text is in, its Enter follows without another check. Withholding the
Enter would leave the secret on the prompt line for the next Enter
anyone presses, and no single key clears a line in every shell herdr
mirrors (Ctrl+U for a POSIX tty, Escape for PowerShell/cmd). This
matches the devswha#588 review's call. The issue stays open for that decision.

**Cost to a legitimate user:** none found. The guard only refuses what
the existing checks already refuse, evaluated a moment later. The app
never sends `role` on its own (`src/AGENTS.md`), so the role count
changes only for a client that switches roles itself.

## Validation

- `server/rpc-guard.test.ts` (new, unit, a stand-in herdr on a Unix
socket): a guard that turns false while the connect is pending writes
nothing and rejects `cancelled`. Passes with the fix; fails with the
guard check replaced by `if (false)` (1 fail).
- `server/submit.contract.test.ts`: new case, a secret whose screen read
is held while the sender switches to observe and back, answers
`read_only` and types nothing. With the fix it passes, together with the
two devswha#588 rejoin cases (3 pass). With the role check removed it fails
(the secret was entered). Run through `bun run check run` on its own
herdr.
- `bun run typecheck` clean.
- Not covered by a test: the pty-replaced branch (it needs a live
handoff mid-read). It is one identity comparison next to the existing
attachment check.

No protocol change. `server/` changes, so a remote PC gets this with the
next `REMOTE_BUNDLE_VERSION` bump.

* fix(machines): a remote PC tells its connection server of a new agent at once (devswha#625)

Fixes devswha#555.

## Problem

On a remote PC, a pane whose agent has just been named could stay listed
as a shell for up to 5 s. The connection server reads a remote PC's
snapshot every 5 s and on each `pane-status` / `pane-exited` /
`session-changed` frame from its bridge (`server/machines.ts`,
`observe`). An agent herdr names **in a status event** therefore reaches
it with that event's frame. An agent the bridge's collector first sees
**in a snapshot it reconciles from** (a pane created a moment ago that
got its agent after the roster's own read, or an agent that starts
without a status change) has no frame behind it, so nothing asks for the
roster until the next 5 s read. devswha#537 fixed the local PC with
`MachineManager.localAgents`; a bridge runs with `machines: false`, so
that call did nothing there.

## Change

- `AgentNews` (`server/machines.ts`): the "is this news" test
`localAgents` had (an agent named anew or another one; a pane first
heard of as a shell is not), with `forget` / `keepOnly`.
`MachineManager` uses it unchanged in behaviour.
- `bridgeAgentNews(tell)`: a bridge's record. A status event's agent is
recorded with no frame of its own, because its `pane-status` frame
already makes the connection server read the roster. An agent first seen
in a reconciled snapshot calls `tell`, which `createServer` wires to
`broadcastAll({ type: "session-changed" })`. It never prunes on a
reconcile's payload, which leaves out panes heard of while its snapshot
was read. Panes that end are forgotten through `onPaneEnded`.

No wire change: `session-changed` already exists and every connection
server reads the roster on it.

**Remote bundle:** the bridge's behaviour changes, so remote PCs get
this only after `REMOTE_BUNDLE_VERSION` is raised at the next release.

## Validation

- `server/machines-refresh.test.ts`, four new cases for
`bridgeAgentNews`: news first seen in a reconcile tells once; a status
event's agent tells nothing; a pane left out of a reconcile is not
forgotten; an ended pane's id is news again. With the fix: 19 pass. With
the `tell` removed: 3 fail. With the first version of this PR (tell on
status, prune on reconcile): 2 fail.
- The devswha#537 `localAgents` cases still pass after the refactor.
- `bun run typecheck` clean.
- Not run end to end: a live herdr reconcile that names an agent with no
status event. I found no deterministic way to make herdr do that in a
contract test. The first version's contract case went through a status
event, a path the connection server already covered, so it was removed
(see the Codex comment).

* ci(browser): per-script timings and kept screenshots for the browser lane (devswha#627)

## Change

A failed browser lane kept herdr's server log, but no page screenshots
and no reliable per-script timings. This adds wall-second timing and
exit-code lines for all 17 browser scripts plus the shared demo build,
and an EXIT summary of every script run so far. The first failure still
stops the lane; the trap preserves its exit code and removes the
temporary demo build. Order, checks, and retries are unchanged.

With CI or CHECK_DIR set, existing screenshots go under
`${CHECK_DIR:-.ci}/browser-evidence`. The failure artifact includes
those PNGs and the existing herdr logs, with hidden files explicitly
enabled for the narrowly scoped `.ci` paths.

Evidence is deliberately restricted to sticky-modifier and file-viewer
checkpoints (eight viewport PNGs on a complete passing run). The UI
suite has evidence-only assertions and viewport changes, and the key-bar
demo adds viewport changes and font waits, so their evidence branches
remain off in the lane. No live user session is captured: these scripts
use isolated test panes and synthetic transcripts. Outside CI/CHECK_DIR,
explicit local UI_EVIDENCE_DIR behavior is preserved.

Remaining devswha#552 work:
- Capture every open page at the exact failure point; the scripts have
no common browser launcher/failure handler.
- Playwright traces of failing contexts.
- Timestamps on the web UI test servers' own lines; there is no common
test-server output wrapper across these scripts.
- Recording browser-received frames.
- Multiple-run measurement for devswha#548 and decisions on devswha#553; this PR does
not split the lane.
- Investigating the seven historical fork runs with no jobs/logs.

No CHANGELOG entry: CI/test-only changes are not user-visible, following
devswha#548, devswha#581, and devswha#590.

## Validation

- Four cheap `scripts/ci-browser.test.ts` tests exercise the real shell
lane against stub commands: passing and failing summaries, exact exit 7,
first-failure stop, temporary-directory cleanup, evidence filtering, and
CI/CHECK_DIR/local behavior. The first three failed on the unchanged
lane before implementation, then all four passed.
- Captured a failing stub run: both passing steps and the failing step
print numeric seconds, the EXIT summary includes all three, and the
process exits 7.
- `bash -n scripts/ci-browser.sh` and `git diff --check` passed.
- `dori heavy tri-client3 -- bun run check fast` passed: actionlint,
generated types, typecheck, build, and 1,932 unit tests passed, 0 failed
(six existing skips).
- The PR's CI browser lane will provide the real-surface timing proof
and comparison against recent main runs.

Refs devswha#552

* fix(alerts): one chime per alert across open tabs (devswha#632)

## What was wrong

With the alert sound on and the app open in two tabs, every tab chimed
for the same alert: each tab played its own audio and nothing told one
tab what the other played (devswha#439 left the cross-tab half out because a
Web Lock alone could drop a question no tab told).

## The rule (`src/lib/alertTurns.ts`)

BroadcastChannel only, so it works on plain-HTTP LAN addresses where Web
Locks do not exist. All times are this tab's own receive times, never a
sender's.

- A tab claims an alert (machine + pane + kind) only when it would chime
it itself: alerts and sound on, not the pane in front of a visible tab,
and its audio context running (`canPlayAlertSound`). A tab that would
not chime never claims, so another tab chimes alone.
- After a 150 ms window the lowest tab id among live claims plays and
posts `chimed`; `chimed` cancels the others and settles that alert's
claims. Only a `chimed` heard up to 400 ms before a tab's own alert
counts as telling it, so the same pane can ask again soon after, and a
tab ignores a late duplicate `chimed` for an alert it played itself. A
`chimed` is sent only once the audio actually starts, also for a
question queued behind another chime (`playAlertSound` now resolves when
its notes begin).
- No alert is dropped on a guess: a tab that deferred a question or a
finish chimes it itself if no `chimed` arrives within 600 ms per lower
claimant (so a third tab waits for the second), and at once when the
winner withdraws (its audio could not play, or the tab is closing:
`pagehide` withdraws its claims; `pageshow` reopens the channel).
- No BroadcastChannel: chime at once, as before. A chime before the
first tap or key is still skipped, never queued.

Costs: an alert now waits about 150 ms for the window (up to a timer
tick longer in a background tab). If timers in both tabs run more than
1.5 s late, both may chime: a double chime, never a lost question.

## Reproduction on b82584d

The new two-tab case in `scripts/alert-sound-regression.ts` (which can
now also run on its own) against the unchanged app:

```text
$ dori heavy … bun run check run bun scripts/alert-sound-regression.ts
PASS no alert sound before the page was tapped
… (4 more PASS)
two-tab shared alert: 2 chimes (4 notes)
AssertionError: exactly one chime across two tabs for the same alert   4 !== 2
```

The updated `src/lib/alertSound.test.ts` against the unchanged
`alertSound.ts`: 10 pass, 4 fail (a queued question is confirmed only
once it starts; an interrupted one is not).

## After

- `HERDR_TEST_MODE=unit bun test ./src/lib/alertTurns.test.ts
./src/lib/alertSound.test.ts`: 28 pass, 0 fail. `bun run typecheck`
clean. `bun run check fast` passed locally (1950 tests, 0 fail).
- Browser, through `bun run check run bun
scripts/alert-sound-regression.ts`: all 7 steps pass, including `two-tab
shared alert: 1 chimes (2 notes)`, "two open tabs chime once for a
shared alert" and "the other tab tells a question in front of the first
tab". It passed twice: once in this worktree, and once with only the
`src/` change applied to a clean checkout of b82584d.

## Test setup change

The script's primer now waits (bounded) for each `working` event before
reporting again. In one worktree under heavy load, two runs timed out at
the first existing step, waiting for a `blocked` event that never
reached the page. The same fix with the old primer passed in a clean
checkout, so the fix did not cause it. The cause was not confirmed; the
pacing is a robustness change only.

Review rounds: see the Codex comments below (fixes bbb9f9f, 76450d2,
18ee46a and 1efe181; Lows listed there).

## Finishes get the safety net too (1efe181)

A tab that deferred a finish used to drop it at the claim deadline, so a
winning tab whose audio did not start, or that froze, left the finish
unheard in every tab, where before this PR every tab chimed. A finish
now waits like a question: the winner's withdraw redecides at once and
the 600 ms rescue plays it if no `chimed` arrives. The winner confirms a
finish as soon as it plays (`playAlertSound("done")` answers
synchronously), so the rescue does not double it. The unit test "does
not give a finish a question safety net", which pinned the old behavior,
is replaced by "chimes a finish in another tab when the winning tab
cannot play it", "chimes a finish once when the winning tab played it"
and "rescues a finish when the winning tab never answers".

Fixes devswha#449



## A slow winner, and the pane's next question (after the CI failure on
1efe181)

Run 37886117774 chimed one question twice in "two open tabs chime once
for a shared alert". Two ways lead there, both fixed here:

- The winner's `chimed` can come late: its audio waits behind a chime
its tab still plays (a finish, a Settings preview), or the runner is
slow. The other tab's 600 ms rescue then chimed the alert again. The
winner now posts `playing` before its audio starts; a tab deferring that
alert then waits for its `chimed` or `withdraw` (a withdraw still
redecides at once), up to 5 s, after which it rescues. A winner that
freezes mid-play therefore costs a late chime, never a lost one.
- A tab that chimed the pane's previous question less than 1.5 s ago
ignored every `chimed` for that pane and kind as a late duplicate, also
the genuine one for the next question it was deferring (Codex's M2), and
its rescue chimed again. A `chimed` now settles an alert this tab is
waiting on; it is ignored as a duplicate only when nothing is pending.

Tests: unit tests "takes another tab's chime of the pane's next question
although it chimed the first one itself", "waits for a winner that is
playing, however long its audio waits, and chimes once" (question and
finish), "still rescues an alert whose winner said it was playing and
then went silent" and "redecides at once when a winner that said it was
playing withdraws". `scripts/alert-sound-regression.ts` gains "a winner
that confirms late is not chimed over by the other tab", which holds
every `chimed` back by a second in both tabs.

* docs(readme): re-shoot the hero and the six feature clips on v0.4.0 (devswha#647)

Re-shoots the README's hero video and all six feature clips on
**v0.4.0**. That release reworked the UI: the two-list sidebar
(Workspaces and Agents), the single status column, the composer with its
+ button, the greeting on an empty chat, and the key bar's Enter key.
The old clips still showed v0.3.49 and the sidebar's Needs you list,
which v0.4.0 removed.

Docs only: WebPs, the four READMEs, `site/index.html`, and the hero's
upload link in `scripts/build-site.ts`. No app code, no CHANGELOG, no
version change.

## What changed

| | Upload | Length | Change from the current README |
|---|---|---|---|
| Hero |
[herdr-web-ui.mp4](https://github.com/user-attachments/assets/d854dbb6-64bd-4eba-81c7-fbd3f525726b)
| 26.5 s | New story (see below) |
| Switch to the live terminal |
[upload](https://github.com/user-attachments/assets/32ca9aa4-960f-4629-9fb5-17c12ba35c80)
| 11.5 s | The phone uses the key bar's new Enter |
| Your herdr layout, in the browser |
[upload](https://github.com/user-attachments/assets/22d10639-9aa6-4953-a5f3-a1b743f4053b)
| 12.5 s | Status column replaces the READY/RUN/DONE badges; two-line
rows |
| Know when an agent needs you |
[upload](https://github.com/user-attachments/assets/b228a2b8-6db5-4546-a15b-972056000cab)
| 11.3 s | The Needs you list is gone; the desk shows the red ? and the
card in the chat |
| Send a screenshot from your phone |
[upload](https://github.com/user-attachments/assets/a193b426-259c-47d9-bd73-10acc2594265)
| 13.8 s | + replaces the paperclip; starts on the greeting |
| Open what the agent made |
[upload](https://github.com/user-attachments/assets/bc419d3e-ebda-4fb5-acc5-e498609559fe)
| 10.0 s | Same beats on v0.4.0 |
| Branch off a second agent |
[upload](https://github.com/user-attachments/assets/150a4c9d-7667-4f63-ba56-8f91e6a25878)
| 11.7 s | Reframed: the phone beside the browser's sidebar, ending on
the nested row |

**The hero, in order:**
1. herdr's own TUI, with Claude Code at work in checkout-api.
2. A dissolve into the browser's Terminal view of the same pane. Claude
Code's screen stays still: both grids are 98x35 in the same cells.
3. The turn finishes in the browser, and a click on Chat shows the same
session as a chat.
4. The camera pulls back to the phone, which shows the same chat.
5. On the phone: ⋯ → New worktree → Claude Code → Create. The browser's
sidebar nests the new checkout.
6. It ends on the phone's greeting, "What should Claude do in
worktree-…?". Both apps use their default themes: herdr's catppuccin and
the web's amber.

**Text changes:**
- The hero's caption, `site/index.html`'s aria-label and show-note, and
the site poster time (`at: "11.8"`, where both screens show the same
chat).
- All six alts in all four languages. Each one matches what its loop
shows; attach's alt names the step that only the full video shows.
- Two captions: the terminal one ("Enter beside it") and the attach one
("The + button").
- The footnote ("live recording … no cuts") still holds and is
unchanged.

**Checks:**
- Each upload was fetched back: 200, a ranged GET gives 206, and its
SHA-256 matches the local render.
- `bun run build:site` passes. The hero downloaded at full size
(3,964,804 B) and the poster was cut at 11.8 s.

## How it was made (disclosure)

- **Real:**
- herdr-web-ui **v0.4.0** (built `dist/`) on herdr 0.9.0, in the
separate `herdr-web-ui-demo` session.
- Real Claude Code 2.1.294 turns on a real account: 4 in the hero, 0–3
per clip take (worktree needs none).
  - Real git worktrees made by herdr, and real clicks and taps.
- Recorded at the same moment on one clock (herdr's TUI, a 1280×800
desktop page and a 390×844 phone page) and played back 1:1, with no
speed change and no time cuts.
  - The latest release is now v0.4.2; these videos were shot on v0.4.0.
- **Staged:**
- `checkout-api` is a small demo repo. web-dashboard (Codex · working),
infra and release are props: their agent states are set through herdr's
API.
- Warm-up questions and tasks were sent through herdr's API off camera,
not typed. Warm-ups also scroll Claude Code's header (plan line) off the
screen.
- Claude Code ran with acceptEdits, the publishing tools denied and
spinner tips off. Bash and AskUserQuestion were denied where a clip
doesn't need them (alerts keeps the question; attach's two later Bash
prompts were approved off camera, after its cut ends).
  - The attach screenshot was rendered from the repo's own receipt page.
  - The phone is an emulated page on the same machine.
  - The host name reads "workstation", and paths read `~`.
- **Composited:**
  - The wallpaper, the window and phone frames, and the camera moves.
  - The arrow and the tap disc, drawn at the recorded pointer or touch.
- The hero's dissolve, a crossfade of two recordings of the same moment.
For the film, herdr's terminal is xterm.js in a 1210×775 page and the
browser's terminal font is 16 px (default 13), so the grids match.
- **Privacy:** every frame of every render and its source takes passed
the OCR check against the account, machine and network names. The
flagged hits were OCR misreads of motion-blurred UI text, checked frame
by frame.

## Known, left as the app does it

- After Send, "No conversation yet" shows for about 0.2 s.
- Switching to Chat shows "Loading conversation…" for 4–7 frames.
- A new worktree's pane goes through its shell and terminal before its
chat. In the hero this happens small, during a camera move.
- Chat bubbles show a clock time.

These could be app issues, but this PR does not touch the app.

## For the README session picking this up

- Text that is translated by hand and worth a native read: the
ko/ja/zh-CN alts and captions in README.ko.md, README.ja.md and
README.zh-CN.md.
- Source material lives in the film worktree,
`_film/renders/herdr-web-ui-<clip>.mp4`. The film tooling
(`scripts/film/…`) is uncommitted there and is not part of this PR.
- Re-cutting a loop: `bun scripts/film/webp.ts
scripts/film/cut-<clip>.ts --q 85 --out docs/media/readme/<clip>.webp`,
run in that worktree.

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

## Chinese page (57f0d64)

`site/zh/index.html` gets the same update as `site/index.html`: the six
clips link to their new uploads with the alts and captions from
`README.zh-CN.md`, and the hero's aria-label and note tell the new
story. Before this, the Chinese page would have kept the old upload
links, the Needs you and paperclip alts, and the health.ts hero text.
`bun scripts/build-site.ts` passes; `/zh/` renders the new clips with no
4xx or page errors.

---------

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

* fix(chat): read a Claude pane's session on a Mac whose locale puts the day first (devswha#646)

## Change

On a Mac whose language puts the day before the month (English (UK), for
one), every Claude pane's chat showed "Conversation unavailable — show
terminal output" when herdr's Claude integration is not installed.

Without the hook, the server finds the session through Claude's native
PID record (`~/.claude/sessions/<pid>.json`) and accepts it only when
its `procStart` equals `ps -o lstart` for that PID. Claude Code records
the start in the C locale's order, but the server ran `ps` in its own
locale:

| | |
|---|---|
| `procStart` Claude 2.1.285 wrote | `Fri Oct  9 04:43:00 2026` |
| `ps -o lstart` under `LANG=en_GB.UTF-8` | `Fri  9 Oct 04:43:00 2026` |

So the record looked like a reused PID's, the pane had no session
(`no_session_id`) and the chat fell back to the scrollback.
`darwinProcessStart` now runs `ps` with `LC_ALL=C`, as `gjc-runtime.ts`
already does since devswha#484. Linux (`/proc` ticks) and Windows (FILETIME)
compare numbers and are unaffected.

The test fixture built `procStart` with the same locale-dependent `ps`
call as the code, so it passed in every locale. It now writes the record
as Claude does, and a new macOS test runs the check under `en_GB.UTF-8`.

## Validation

- `HERDR_TEST_MODE=unit bun test ./server/claude-store.test.ts` on macOS
with `LANG=en_GB.UTF-8`: before the fix, "reads an exact live PID's
session" and the new "reads it on a Mac whose locale puts the day first"
fail (`Received: null`); after it, 35 pass. CI runs no unit tests on
macOS, so these two skip there.
- Against a live Claude Code 2.1.285 pane with no herdr hook:
`claudeProcessSession` now returns the session and
`claudeTranscriptFile` finds its `.jsonl`.
- `bun run check fast`: generated types, typecheck and build pass.
`test:unit` has 1926 pass and 3 fail, all in `scripts/check.test.ts` >
`isolate`. They fail the same way on unchanged `main` on this Mac,
because the temp directory makes the herdr socket path too long (120 >
100).

* fix(plugin): discover Bun when launched with a minimal PATH (devswha#648)

## Change

Closes devswha#637

- Unix build/start/action/phone 명령과 preflight가 같은 Bun 탐색을 사용합니다.
- 기존 PATH 우선순위와 인자 경계를 보존하며 Windows 명령과 Bun 최소 버전 검사는 유지합니다.

## Validation

경로·인자·manifest 테스트 11개, 타입·빌드·생성 타입 검사 및 실제 Phone entrypoint 계약 검사 통과.
실제 `env -i HOME="$HOME" PATH=/usr/bin:/bin sh scripts/with-bun.sh
--version`은 `1.4.2`를 반환했습니다.

전체 단위 실행은 1938 통과·33 생략·1 실패였습니다. 실패는 변경하지 않은
`server/collector.test.ts`의 recovery 타이밍 테스트이며, 분리 실행한 해당 파일 27개는
통과했습니다. 같은 collector의 다른 브랜치 전체 실행도 통과했으며 이 PR에서 해당 테스트를 바꾸지 않았습니다.
Windows 실제 실행은 검증하지 않았습니다.

모든 검증은 별도 작업 공간과 소유한 테스트/demo 환경에서 수행했습니다. 사용자의 실행 중 앱·terminal은 변경하지
않았습니다.

---------

Co-authored-by: studiood <253281802+od-studio-webagency@users.noreply.github.com>
Co-authored-by: Hako <devswha@gmail.com>

* fix(terminal): a tab you are not using leaves the pane's size alone (devswha#658)

## Problem

A herdr-web-ui tab left open on a desktop took a shared pane's size even
while nobody was using it. The terminal lens re-asserts its grid on
`visibilitychange`, on `role-ack` (every reconnect), from its
ResizeObserver, and through the attach a reconnect replays. A window
that only turned visible behind another app counted, for example when
the screen woke. So did one that reconnected in the background.

Seen on a MacBook running herdr's own TUI in Ghostty, with the pane
split to about 105 columns:

1. The phone opened the pane in the terminal lens (54×62).
2. The user came back to herdr in Ghostty.
3. A Chrome tab on the same pane, sitting behind Ghostty, turned visible
and resized the pane to its own 202×54.
4. Herdr's TUI then drew the 202-column pane inside its 105-column
split, cut off at the edge.

The sidecar's argv (`pty-host.mjs 202 54 …`) showed the size came from
that tab. Herdr 0.9.3 keeps an attach's size after it leaves, so the
pane stayed that way.

## Change

`PaneTerminal` drives the shared grid only while the tab is in use,
meaning `document.visibilityState === "visible"` and
`document.hasFocus()`:

- `visibilitychange` refits only when the tab is visible with the focus.
The window's `focus` still refits, so going back to a tab takes the pane
back as before.
- `role-ack` refits only in use. Out of use, the refit waits for the
user.
- The ResizeObserver still fits xterm, but sends `resize` only in use,
so a window the system moves or resizes in the background does not take
the pane.
- Out of use (window `blur`, or a hidden or unfocused
`visibilitychange`), the pane is marked `keepSize`. A reconnect in the
background then attaches with `keep_size` and adopts the pane's size, as
the chat lens already does. The next refit clears it.

- A pane attach out of use (a reload behind another app, or the next
pane after the one shown closed in herdr) goes with `keep_size` too. The
lens effect, which runs on every load, and a chosen font's load resize
the pty only in use, so they don't take the pane right after that
attach. Out of use they fit only xterm.

There are no server or protocol changes.

Not addressed here: after a phone uses a pane, herdr's TUI still shows
it at the phone's size until something resizes it. Giving a pane back to
herdr's layout size when no tab is in use would need a new frame. The
server also can't tell whether a native TUI is attached, so on a
headless herdr that would resize panes to herdr's default layout. I left
that for a separate discussion.

## Validation

- New browser check `checkBackgroundTabKeepsTerminalSize`
(`scripts/chat-size-regression.ts`, run from `ui-regression.ts`). It
uses a desktop page and a phone page on one owned pane, and a second
pane the desktop moves on to:
  - The phone takes the grid.
- The desktop turns visible without the focus: no `resize`, and the pane
keeps the phone's size (`stty size`).
- The desktop reconnects in the background: the only frame is `attach`
with `keep_size: true`, and the pane keeps the phone's size.
- The desktop reloads in the background, with a chosen terminal font:
the only frame is `attach` with `keep_size: true`, and the pane keeps
the phone's size.
- The pane closes in herdr while the desktop is in the background (the
phone has left): the desktop moves on to herdr's focused pane with an
`attach` with `keep_size: true`, and that pane keeps its size.
  - The desktop takes the focus: that pane goes to the desktop's size.
- The check fails on the previous `PaneTerminal.tsx` ("a window shown
without the focus sends no resize") and passes with this change. Each
later step fails with its own fix undone (the attach, the font load, the
lens effect).
- `checkChatKeepsTerminalSize` and `checkPaneSwitchKeepsTerminalSize`
still pass. `document.hasFocus()` is `true` in Playwright pages (focus
emulation), so the existing browser checks keep their behavior.
- `bun run check fast`: generated types, typecheck and build pass. Unit
tests (with current `main` merged): 1964 pass, 3 fail. The 3 failures
are `scripts/check.test.ts > isolate`, which fail on unchanged `main` on
this Mac because the macOS temp dir makes the socket path too long (120
> 100).
- `scripts/ui-regression.ts` via `bun run check run`: exit 0, 93 PASS
lines.
- I haven't tried it on a real iPhone. Returning to the app there should
refit through `visibilitychange` with the focus, or through the window's
`focus`. A page opened fresh while `document.hasFocus()` is `false`
attaches with `keep_size` and waits for that `focus` too.

---------

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

---------

Co-authored-by: Hako <devswha@gmail.com>
Co-authored-by: e2e <e2e@example.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studiood <studio-od@naver.com>
Co-authored-by: studiood <253281802+od-studio-webagency@users.noreply.github.com>
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.

1 participant