agentleFS
iYassriYassr/shahi/CLAUDE.md

shahi

CLAUDE.md2 starsChanged 51 days agoMITView on GitHub

A phone-shaped view of a running herdr session. README.md says what it is for a user; this says what an agent working on it needs to know before changing anything. herdr-plugin.toml at the root is how the sidecar is distributed: herdr plugin install iYassr/shahi (see docs/plugin.md), and the only way — the older install.sh was deleted once the plugin covered both platforms. Building the iOS app needs a Mac — see docs/on-a-mac.md, which is also where the iOS tests are…

  • Reads credentials

The file

Show its rules and text

We split this CLAUDE.md into the rules it gives an agent

23 rules. About 0 are common, 9 are distinctive, and 14 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈9

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 14

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Keep components modular and concerns clearly separated.

    line 64
  2. Distinctive:

    Prefer established, well-maintained libraries when they reduce overall complexity or improve reliability.

    line 65
  3. Very specific:

    Every pane herdr restores comes back with a new terminal_id (w3:p1's term_65c5ac306c4425 as term_65c5ac360bcd31).

    line 162
  4. Very specific:

    Before any write to an agent, by either path and whatever herdr says its status is, prompt.ts reads the visible screen: if the prompt …

    line 311
  5. Very specific:

    Every file under src/app/ is a route, and Metro bundles it — expo-router's ignore pattern spares only +api, +html and +native-intent — so a …

    line 621
  6. Very specific:

    Always Use HTTPS and minimum TLS 1.2 were enabled through the signed-in dashboard.

    line 659
  7. Very specific:

    All four reached the same end state: startup hook, systemd user service, /api/meta answering, relay.connected: true.

    line 704
  8. Very specific:

    Every test starts from the same place: an automatic fixture in e2e/fixtures.ts (freshStub) resets the stub to busy first, which also clears …

    line 772
  9. Distinctive:

    A dead stub is reported as a dead stub: Every test takes the server's pulse before it runs and again if it failed, because a run once …

    line 793
  10. Distinctive:

    Commit messages are prose, not bullet lists of changed files: what broke, how it was found, what it cost.

    line 969
  11. Distinctive:

    When a second backend arrives it wants a sibling, not a rename.

    line 988
  12. Distinctive:

    Keep advanced connection details collapsed and highest-permission cautions visible even before selection.

    line 1031
  13. Very specific:

    Do not load third-party scripts or cache decrypted session data.

    line 1153
  14. Distinctive:

    Keep signed forms, France/ANSSI declarations, signatures, email exports, personal contact/address details, reviewer passwords, pairing …

    line 1182
  15. Very specific:

    Keep code literal, preserve URL punctuation within the address, and leave local file links on the authenticated file-viewer path.

    line 1195
  16. Distinctive:

    Never use terminal repaint, focus or status-change times as chat activity.

    line 1234
  17. Very specific:

    Runtime behavior needs measurement: Use realPath/realPathSync for user paths: Bun realpath treats a backslash as a separator on macOS.

    line 1307
  18. Very specific:

    Installation messages must name the right herdr session: Use herdrCli for instructions outside a pane.

    line 1356
  19. Very specific:

    UI invariants cross clients: Use shared paneTitle; trim only leading blank lines and trailing whitespace from drafts.

    line 1364
  20. Very specific:

    Read, since its startup screens (sign-in, terms, trust) come before any transcript.

    line 1403
  21. Very specific:

    All four Codex modes set approvals_reviewer="user".

    line 1414
  22. Very specific:

    Every shape an agent writes has a decision in transcript-shapes.ts (shown, state, followed, dropped, with the reason), held to the readers …

    line 1448
  23. Distinctive:

    Run it before a release while an agent works through a multi-step task.

    line 1458

≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
CLAUDE.md

Working on Shahi

A phone-shaped view of a running herdr session. README.md says what it is for a user; this says what an agent working on it needs to know before changing anything.

The shape of it

shared/    the wire contract and the end-to-end crypto — both clients import it
server/    Bun sidecar: owns herdr's unix socket, speaks HTTP + WebSocket
plugin/    the herdr plugin: startup hook, actions, the service it installs
relay/     the blind relay: a Cloudflare Worker, one Durable Object per box
mobile/    the Expo app — the product, and where new work goes
web/       the React PWA: mobile behavior with responsive phone/laptop layouts
e2e/       Playwright, against a stub of the server
site/      public site Worker and hosted PWA
operations/ monitoring Worker and service alerts
dashboard/ private infrastructure-owner dashboard, gated by Cloudflare Access
demo/      isolated App Review computer
scripts/   brand assets and notice tooling

herdr-plugin.toml at the root is how the sidecar is distributed: herdr plugin install iYassr/shahi (see docs/plugin.md), and the only way — the older install.sh was deleted once the plugin covered both platforms. Building the iOS app needs a Mac — see docs/on-a-mac.md, which is also where the iOS tests are free rather than behind a paid EAS plan.

bun run test                              # unit, workflow policy and dependency checks
bun run test:e2e                          # both engines, against the stub
bun run test:e2e --project=ios            # WebKit only — what the phone runs
bun run build:site && bun run test:hosted && bun run test:pwa # public PWA, encrypted fixtures and real cache
bun run build:web                         # what a development server serves

bun run test is the canonical unit command (package.json's test); it also runs ./.github, the workflow-policy and herdr-installer tests. The ./ is load-bearing: bun test skips dot-directories unless a path names one, so .github without it silently matches nothing.

Rebuild after touching web/. Development servers serve web/dist. Managed production services serve the web assets inside their approved release; publish and install a release to change those. See docs/releases.md.

How to build here

These govern every change, and they outrank anything below that disagrees with them.

  • Preserve the supported Shahi API contracts: current and previous generation, with at least 90 days after the successor's stable release before retirement. The security floor is API 5 and encrypted transport 2; never restore older unsafe protocols. Additive features negotiate capabilities. herdr differences belong in the server adapter. See docs/releases.md for the release policy.
  • Choose the simplest implementation that fully meets the current requirements. Avoid speculative abstractions, configuration, and indirection.
  • Grow the system in layers. Start from the smallest version that works end to end, and add each new capability on top of a product that already works. Never trade a working product for unfinished complexity.
  • Keep components modular and concerns clearly separated.
  • Prefer established, well-maintained libraries when they reduce overall complexity or improve reliability. Do not reimplement common functionality without a clear reason.
  • Lean on the dependencies already in the project before writing your own implementation or adding packages. Do not assume a library lacks a capability without checking its documentation and types.
  • Make architectural decisions for the long term. Do not accept a stopgap that only works for now and is meant to be replaced later.

What herdr actually does, as measured

The docs are wrong in places. These were established against herdr 0.7.5, protocol 17, re-checked against 0.8.2, protocol 20, and every one of them cost an afternoon. Most are also asserted by server/lib/herdr-live.test.ts against a real herdr on every push — 0.9.0 through 0.9.5, protocol 22, and whatever is current (see Testing) — so the next drift is a red job rather than a report from a phone. Restart/id-reuse, agent-lifetime, request-size and plugin-registry measurements also include manual checks; the live suite does not prove every observation below.

  • One response per connection. The socket API closes after answering, though the docs describe persistent connections. Open one socket per RPC. The single exception is events.subscribe, which streams.
  • A request is one line, written whole, and at most 1 MiB. herdr acts on nothing until the newline arrives, and Bun's socket.write takes only what the kernel's buffer holds — 8192 bytes on a macOS unix socket — so the rest must follow from drain. Every request over 8 KB used to time out having delivered nothing, which Linux's larger buffers hid from CI. A line over 1 MiB (measured on 0.9.1) is closed unanswered, so the client refuses it unwritten and /prompt answers 413 above 256 KB.
  • revision cannot detect output changes. It tracked structural changes only: four polls returned revision: 0 while the text changed. Hash the text yourself.
  • Scrollback depends on the source, and this was wrong here for months. source: "visible" is the current screen and ignores lines — which is where "there is no scrollback" came from. source: "recent" returns the last N of the pane's total rows, scrollback included, capped at 1000 server-side. Measured live: a shell gave 268 rows against 36 visible, a codex pane 196, and every Claude Code pane exactly its 36 — it draws on the alternate screen, where no rows exist behind the ones you can see. The poller reads visible every tick and recent once per pane, to seed history from before it was watching; the recorder builds the rest.
  • Output cannot be re-flowed. recent_unwrapped returns the same hard-wrapped 146 columns, because agents wrap before the bytes reach the PTY. So: render faithfully and let the user scale, never re-wrap.
  • herdr does not expand ~. It silently uses $HOME instead, so a display path lands every new space in the wrong folder. Always send absolute paths. It does the same for a folder that does not exist or is a file, so the create routes check the folder first (folderProblem in dirs.ts) and answer a readable 400 before herdr is asked.
  • Key names are strict. shift+tab is accepted, S-Tab is not — it answers invalid_key. Every name in the key bar has been sent to a live pane.
  • Agent detection needs an interactive shell. bash -lc found 2 of 4; bash -ic finds 4, because ~/.bashrc is where nvm and friends live. A slow profile must not freeze the sidecar, so discovery is asynchronous (Bun.spawn) with a 10s timeout that SIGKILLs the shell (an interactive shell ignores SIGTERM); callers that arrive together share one shell, and a killed shell's empty answer is not cached.
  • agent.start races the shell it needs. The pane exists before its shell does, so starting immediately fails with agent_pane_busy. The server owns the retry (startAgentInTab), and clients call one route. The kind is checked against server.agent_manifests before a tab is made, and a start that certainly failed — herdr refused before acting, or agent.get says the launched agent is gone — closes its tab and says so in words; a timeout or a pane still busy keeps it, since it may yet become an agent.
  • agent.prompt refuses a blocked agent. herdr's own semantic submit (protocol 20) is how a prompt reaches an agent — one call, no paste delay, herdr drives the composer. But "if the agent is already blocked, submission is rejected with agent_blocked before any input is sent", and a blocked agent is exactly the one you type a free-text answer into. So prompt.ts sends blocked agents, shells and unknown programs the terminal sequence — pane.send_text, 200ms, Enter — and falls back to it if herdr answers agent_blocked under a stale status. The 200ms is measured: codex's composer drops Enter that arrives too soon after pasted text (150ms sufficed). Measured on Claude Code 2.1.280 / herdr 0.9.1 (2026-09-23): at the Bash permission menu with the cursor on 1. Yes, typing "no" then Enter ran the command. The text-field rows are Type something. (the question tool) and Tell Claude what to change (plan approval): typed text replaces their label and Enter submits it, so those are still typed. No, and tell Claude what to do differently ignores typing. Shells at their prompt are unaffected. Once typed into, a field's label is its text, so isTextField knows the question tool's by its place above Chat about this. While a field has the cursor a digit is typed into it (measured on 2.1.282), so /answer sends Up before the digit there.
  • Claude Code's folder-trust question is an unnumbered menu, and its default quits. ❯ No, exit over Yes, I trust this folder, no digits, Enter to confirm beneath. Measured on a live pane: a digit does nothing there; up, down, j, k and the key bar's Up/Down all move the cursor; several keys in one pane.send_keys land in order; and herdr reports the pane unknown for a few seconds after agent.start before blocked. The parser anchors this shape on the confirm hint, and the server walks the cursor to answer it (answer.ts). A menu read during that unknown window is kept, and the poller asks herdr's status again on an unchanged screen, so the card gains its buttons once herdr says blocked; a static menu never changes the hash that would otherwise trigger a re-parse.
  • Pane ids are reused; terminal ids are not. Measured on 0.9.1 in a named session (2026-09-26): close the highest space (w4), restart herdr, create a space, and it is w4 again, with w4:p1; every named session starts at w1:p1. Every pane herdr restores comes back with a new terminal_id (w3:p1's term_65c5ac306c4425 as term_65c5ac360bcd31). A pane id is therefore not a conversation. The sidecar names each pane's occupant (PaneInstances in herdr-pane.ts, sent as DashboardPane.instanceId) from its terminal, and carries it across a restart only when herdr resumes the same agent session. Recorded history belongs to an occupant, writes naming another are refused with 409 pane_replaced, and both clients forget a pane's draft, uncertain send and remembered conversation when its occupant ends (endedPanes in shared/src/pane-instance.ts), and keep the occupant with pins and notifications.
  • agent_session outlives its agent. A pane herdr restores without its agent (resume off, or the agent missing from the restarted server's PATH) comes back with agent: null and the old agent_session still set. Read it only through agentSessionOf in herdr-pane.ts, which counts a session only while the same agent runs in the pane.
  • The plugin CLI, measured on 0.9.1. herdr plugin list --json and herdr plugin config-dir <id> read the registry and work without a server; config-dir resolves for any id, installed or not. disable, enable and uninstall need a running server; disable leaves the entry with enabled: false, and uninstall drops it.

Decisions worth not relitigating

The reader is fed by the agent's own transcript, not the terminal. Claude Code writes JSONL to ~/.claude/projects/; codex keeps a rollout file indexed in SQLite. herdr hands over the id to join on — but for codex only once its integration is installed (herdr integration install codex), which adds the SessionStart hook that reports one. Without it the reader falls back to asking /proc what file the codex process has open, and never guesses from the working directory: a new session must not show another session’s transcript. On macOS, lsof supplies the same exact process-file lookup; installing the Codex integration also keeps transcripts available after the process exits. The process-file lookup answers only when the codex process has exactly one rollout open. With more than one — subagent threads, or a thread still loaded after /new — there is no transcript until herdr reports a session id, which is the same refusal to guess.

Reading those files is what makes a phone-shaped conversation possible at all — terminal text arrives pre-wrapped at 146 columns and cannot be reflowed. The terminal is still there, on the Screen tab, for when you need the real screen.

Unknown shapes are dropped, never guessed. The codex reader renders a fixed set of shapes and nothing else: conversation, reasoning, tool calls, MCP and web-search activity, and native apply_patch edits — an unrecognised type renders nothing rather than something invented. Since codex 0.151, reasoning, MCP calls, native edits and web searches arrive only as item_completed items (Reasoning, McpToolCall, FileChange, WebSearch); the legacy events are still read for old rollouts. The exec row that wraps a native edit or MCP call deliberately stays beside the item: items carry no call id to join on, and a failed patch emits no FileChange, so the exec row is the only record of that failure. codex-items.ts also reads 0.157.1's completed commands and tool responses, questions (including messages with no prose), plans, reviews, compaction notes, viewed/generated image paths, and the web-search, image-generation and sleep extensions. Inline Codex images use authenticated, rollout-scoped image references; base64 bytes never travel in Reader pages. Local paths use the file viewer. Audio attachments are indicated, but inline audio playback is unavailable. Injected hook prompts and unknown shapes stay dropped. Codex user messages that are really <task-notification> reports, <send_user_message_question_reply> answers, or Claude Code's command and !cmd tags are unwrapped.

Codex 0.160.0's synchronous request_user_input questions live in raw function_call arguments; its RequestUserInput event is deliberately not retained in rollouts. Keep those questions and option descriptions outside collapsed Activity. Async questions arrive as completed AgentMessage items with delivery: "async": omit only their exact duplicated generated prose, and do not call that completed question message a running tool. Their reply envelope accepts one object or an array, optionally after Codex's exact IDE context prefix; neither form may disappear or expose the wrapper as user prose.

Codex 0.160's question digits submit immediately, including "None of the above"; never add Enter or mark that choice as a text field. Its printed Tab shortcut opens notes. Show that as "Add notes" without an invented digit. While notes have focus, keep the original question/page and selected choices as context. Before submitting typed notes, compare the exact question panel and field replacement/append: a moved question, selection or cursor must leave the text typed and Enter unsent. Wrapped or multiline notes stay conservative.

The Claude reader's system-note handler is an explicit allowlist (SYSTEM_NOTE_SUBTYPES) for the same reason: away_summary and model_refusal_fallback carry text the person saw, and so does an informational row at level warning, which Claude Code leaves in the conversation (measured by reader-parity on 2.1.301); every other system subtype, and an informational notice, is chrome and stays dropped. Claude Code 2.1.301's request log (api-request, api-request-blob, api-request-shape), copies of what each request sent, is dropped whole. User rows flagged isCompactSummary — the 14–19KB handoff Claude Code writes after /compact — are dropped like isMeta rows; <bash-input> renders as ! <cmd>, and <bash-stdout>/<bash-stderr> as its output, with no message when nothing was printed. Same for the prompt parser: no confident parse means the raw terminal and a free-text box, which is a far better failure than answer buttons for a question nobody asked.

Claude Code permission dialogs put the tool and command above a generic question. When the question sits directly on the options, the prompt's context is the dialog's block between its full-width top rule (20 or more ─) and the question, in screen order, one entry per paragraph, keeping line breaks and relative indentation; with no rule on screen there is no context. The folder-trust question, which ends "…first." and is found by the ? inside it, gets the same block ahead of what sits under it, because that block is the folder being trusted. AskUserQuestion cards carry their header row (☐ Colour) as context.

The prompt parser requires exactly one cursor. An agent writing a numbered list in prose is common; a rendered menu always has its cursor on exactly one row. Without that rule the dashboard offers to answer prose. An unnumbered menu needs more: rows whose labels line up, directly above an Enter to confirm hint — the ❯ glyph alone is the shell's echo and the composer.

A prompt is answered by the server, against a fresh read of the screen. Both clients post the option they showed — index, label, and the question and context it sat under — to /answer; the server re-reads the pane, re-parses, and only if all of them still match presses the keys — the digit for a numbered menu, cursor moves and Enter for an unnumbered one. The client's copy of the screen can be seconds old, a move computed from a stale cursor lands on the wrong row, and the trust menu's wrong row exits the agent. The question and context are compared because index and label are not enough: every Claude permission offers "1. Yes". An older client that sends only index and label is still answered on those two. A 409 with prompt_gone or prompt_changed is the answer when the screen moved on; nothing is pressed. Enter is never sent with the keys that move to a row: the moves (or a digit that only highlights) go first, and Enter follows once a fresh read shows that row lit under the same question. Claude Code refuses a key within 150ms of a menu opening and puts the cursor back on its default, "No, exit" on trust and bypass, and before 2.1.283 it could take an arrow and Enter sent together against the old cursor (its source and changelog). A row never reached is prompt_changed, Enter unsent. A screen that moved on to another question took the selection as the answer itself — codex's update offer acts on its digit — and gets no Enter.

Content cannot tell one appearance of a prompt from the next: the same command asked for twice draws the same card. So every read of a pane, the poller's and the answer route's, goes through prompt-instances.ts, which gives each appearance an id (ParsedPrompt.promptId, optional on the wire): a new one when a prompt appears, when its question or options change, and once it has been answered. Clients send it back, and a card with an id that is no longer current is a 409 prompt_gone. After pressing, an answer holds the pane's write queue until the screen changes (up to 1.5s), because two phones both read the prompt before the agent repainted and the second key approved the next prompt; a screen still unchanged after that is refused as already answered until the pane shows anything else.

Writes to one pane take turns. /prompt, /answer and /keys run through one queue per pane (pane-writes.ts), so each sees the screen the one before it left: two phones' messages were typed into each other before either Enter, and a key-bar Up landed between an answer's read and its Enter and confirmed No, exit. Reads stay outside the queue; the poller never waits on a write. A person at the terminal does not queue, so a message reads the screen again before its Enter, and if the menu or its lit row moved it presses nothing and answers 409 prompt_changed, the text left typed; to a shell at its prompt the same happens if anything else has taken the terminal.

Check who owns the terminal before typing. Before any write to an agent, by either path and whatever herdr says its status is, prompt.ts reads the visible screen: if the prompt parser finds a menu whose highlighted row is not a text field, nothing is typed and /api/panes/:id/prompt answers 409 prompt_open; a menu whose lit row is a field is typed into. herdr reports a new agent unknown for about 3s with its trust menu drawn, and agent.prompt typed into it then and pressed Enter on No, exit. Whether a pane is an agent is asked of herdr's pane.get too, not only the 3s mirror, which still lists a just-started agent as a shell. pane.get lags as well: it names the agent 215–285ms after the trust menu is drawn. So a pane neither calls an agent skips the read only when its shell alone has the terminal — pane.process_info's foreground process group is the shell's pid and holds nothing else; a program has a group of its own within 50ms of Enter (measured on 0.9.1). Any other program holding the terminal is read like an agent. An agent herdr calls blocked whose screen shows nothing the parser recognises is not typed at either: 409 prompt_unrecognised, since most of what an agent waits on is a menu, and Claude Code's unnumbered ones move on j/k in the text while Enter takes the lit row. The frame says so (PaneFrame.unrecognised) and both clients show the screen with keys (screen-card.ts). That shell's Enter waits on the same question asked again after the pause, so a program started as the message arrived does not receive it. Text typed ahead of a command the shell has not read yet cannot be told apart, since only the terminal's input queue holds it.

Full control, gated by a passcode. pane.send_text is arbitrary shell execution as you, so a method allowlist was never the boundary. The boundary is the loopback listener — the bind, and a Host allowlist that refuses anything but 127.0.0.1, localhost or [::1] before routing, so a DNS-rebinding page cannot reach it — plus the passcode, or possession of a paired device's secret over the encrypted relay. An owner's own reverse proxy (tailscale serve for Web Push) is let through only by naming it in SHAHI_ALLOWED_HOSTS, and a malformed entry stops startup. Missing passcode configuration and non-loopback binds prevent startup. Given that, file reads are scoped to $HOME and the OS temp directory (tmpdir(), which on macOS is /var/folders/… and not /tmp) for tidiness rather than security.

Never log pane.read output. It contains whatever is on your terminals.

A phone reaches a box two ways, and typing an address is not one of them. The relay is the default and pairing is a relay act — a shahi://pair code carries the relay, the box id and a one-time secret, and nothing else. SSH is the alternative for someone who wants no third party in the path: it opens a tunnel and points the same HTTP client at 127.0.0.1:<forwarded>, so it is the relay's only peer in dispatch(). The typed tailnet address that used to be a third option is gone (2026-09-04): it was never a transport of its own — SSH rides on the very code path it used — and keeping it meant a second pairing route, an exposed bind, and an NSAllowsArbitraryLoads to defend. A box with RELAY_URL= empty now mints no codes and is reached over SSH with the passcode.

Both clients speak Shahi routes, never herdr methods. POST /api/panes/:id/prompt, /answer, /keys, /api/workspaces, /api/agents/start — the sidecar translates, so a herdr rename or a change in how a prompt is submitted never reaches an App Store binary that cannot be updated on the same day. /api/rpc still exists for debugging; neither app may call it — and cannot: the server answers 403 to any request carrying x-shahi-api, which every app request does.

The app and the sidecar negotiate a contract version. SHAHI_API_VERSION in shared/ is the number; GET /api/meta (unauthenticated) says what the server speaks, every request carries x-shahi-api, and a mismatch is a 426 whose text says which side to update. Both clients map a 426 to IncompatibleServerError: the web client shows "Update needed" with the server's words and stops its live stream while that notice is up, as the native app does, and its pane view shows the words instead of retrying. Both clients hold the notice until a request succeeds, ignoring pushed frames and socket state meanwhile: over the relay the stream is attached without a version check, and a dashboard pushed while /api/session answered 426 once wiped the notice into a LIVE agent list on the phone. Bump the number when a route or payload changes in a way an older client would misread — not for additions.

The plugin's startup hook installs a service; it is not the service. herdr's [[startup]] commands are one-shot by contract — "not supervised daemons" — so plugin/shahi.ts setup renders a LaunchAgent or a systemd user unit, (re)starts it, and exits. The OS supervises the approved manager in the plugin's state directory. It runs immutable, verified service builds and requests the newest compatible approved build after a plugin reinstall. Secrets live in herdr's per-plugin config directory, named by SHAHI_ENV_FILE, never in the checkout. The service follows the socket of whichever herdr ran the hook last. The service's environment carries RELAY_URL (Shahi's relay) unless the .env has the key, because a fresh install that ended at "no address to give a phone yet" was the whole onboarding problem: with the relay the first QR works from anywhere. The default is in code, not written to the user's file — on disk it would be every install's trust anchor for life, and the relay could never move. RELAY_URL= empty means direct-only. herdr 0.8.2, where this was measured, had no menu for plugin actions (the CLI and a bound key are the ways in), herdr's notifications are off by default, and herdr plugin install cannot run the startup hook (build commands get no plugin context), so the pair popup runs the setup itself when the service is missing: "install, then pair" is the whole flow and the first run's output is on a screen a person is looking at. herdr closes a popup the moment its command exits, so the popup holds everything it printed until Enter — the passcode and the lingering warning before the QR, and any failure before it closes. herdr notification show is sent too, without the passcode digits — a toast is every attached client — and a startup hook that fails is toasted as well, because herdr ignores its exit status. A lost passcode is replaced by the reset-passcode action, which prints the new one once to the plugin log. plugin/bun.sh installs bun during herdr plugin install only. herdr has no uninstall or disable hook, and the service no longer needs the checkout it deletes, so the manager asks herdr every 30 seconds whether the plugin is still installed and enabled for the configuration root it was installed from, and on a "no" confirmed five seconds later removes its own service. Anything short of a clear answer leaves it running. The uninstall action remains the immediate path: service first, then herdr plugin uninstall.

A phone is introduced by a code, and can be revoked. bun run server/scripts/pair.ts prints a single-use, ten-minute code as a QR; the app scans it, checks the server's identity against the code, and receives a session bound to a per-device row that Settings can revoke — immediately, on the next request and on the open socket, and a phone that was offline is told on its next relay connection by a sealed bye keyed from the revoked row's secret. That is why revoked rows keep their secrets; never delete one without replacing that mechanism. The passcode stays as the fallback and is not a device. See docs/pairing.md. The security posture of the whole surface, what was fixed and what is deferred to whom, is in docs/security-review.md.

A phone reaches a box from anywhere through a relay that reads nothing. docs/relay.md is the protocol and shared/src/relay.ts its shapes. The box dials out (RELAY_URL) and proves an Ed25519 key whose hash is its serverId; phones are multiplexed onto that one socket by link number; and above the relay every frame is sealed with shared/src/e2e.ts, keyed from the pairing secret or a per-device secret that never travels — the relay sees connection metadata, sizes and timing; content stays encrypted. cd relay && bunx wrangler deploy runs one; the pairing code carries its address, and the app prefers it. The plaintext hello proves nothing: a link must send a valid sealed message within fifteen seconds before the sidecar issues its session or attaches its stream. Both clients send a sealed watch/unwatch immediately after deriving device-session keys. Relay controls are bounded and validated before dispatch; a malformed relay response must never crash local HTTP/SSH access.

Measured: 203ms for a request through Cloudflare's edge, 557ms for a first hello that wakes a cold Durable Object. Three things learned the day it first ran, all now in the protocol: the hello is a binary frame, because the relay forwards data and drops phone text; a pairing link may read /api/meta before it claims, because the phone checks the box's id first; and the box pings, because a Durable Object cannot — a box silent for five minutes is dropped. The e2e.ts construction had its second review on 2026-09-02 (docs/security-review.md, R1–R7): the box no longer dies on a hello with a low-order point, open refuses a gap in the counter so a relay cannot silently drop a frame, an empty pairing secret is refused rather than degrading to unauthenticated DH, the database is 0600 in a 0700 directory, and /api/meta over the relay names no versions. The relay became the plugin's default way in with plugin 0.2.0, ahead of an outside review of the construction, because the relay reads nothing and RELAY_URL= opts out; an outside review would change the envelope, not that default (docs/connectivity.md).

Logout revokes a session on the server. Signed cookies contain a random session nonce; revoked token hashes persist in the sidecar database until expiry. The running server must supply that database to Auth. Offline owner scripts may still sign short-lived tokens. Browser code delivered by getshahi.dev is trusted with active sessions and remembered secrets; keep the entire origin free of third-party scripts. Both public Workers disable workers.dev and preview hostnames. See docs/security-assessment-2026-09-05.md for the remediation.

The reader is pushed, and polls only to recover. While a phone watches a pane the server watches that pane's transcript file and sends log_changed (size only, no content) the moment it grows; the reader fetches its tail then. The 2.5s poll stays as the backstop for a dropped socket or a missed file event. fs.watch alone is not enough — it can miss — so a 1s size check backs it (transcript-watch.ts). The watched pane's transcript path is looked up again every 3s and the watcher moves when it changes — after /clear, a new codex or Cursor session, or a codex process exit — and the move is pushed as log_changed. While no file is found yet, frames trigger an immediate lookup; a lookup that finds nothing keeps the current watch.

The mirror is re-snapshotted every 3s. Events alone drift: pane.updated does not report status transitions, and 18 of 18 panes were wrong after a few minutes of events-only updates.

Polling is adaptive and priority-ordered: 400ms watched, 2s active, 15s background, and watched panes sort first. It backs off entirely when no client is connected, so a sleeping phone costs nothing.

Things that only appear on a phone

Each of these was reported by a person, not caught by a test, and the tests that now cover them are named after the symptom.

  • iOS lays the keyboard over the page, it does not shrink the viewport. The app compensates from visualViewport — but only when a keyboard is genuinely open. Reacting to every visualViewport change meant Safari's collapsing toolbar resized the app mid-flick, which felt like the page fighting your finger.
  • A translateY(0) is still a transform, and a transformed ancestor changes how everything inside it scrolls. The compensation is applied only while the keyboard is open.
  • iOS does not reliably deliver taps to non-interactive elements. An onClick on an <img> works everywhere except the phone. Use a button.
  • WebKit renders a <button> through a box of its own, and an image inside one collapses to nothing without appearance: none and explicit sizing.
  • Notification does not exist on iOS outside an installed app — touching it threw during render and left a blank page in every Safari tab.
  • A service worker bypasses page.route. In WebKit, completely. This is why the suite blocks service workers and why a fuse fails any write that leaves for a host other than the stub: mocked writes once reached real agents.
  • A touchable around a ScrollView stops it scrolling, and so does nesting vertical inside horizontal. The native Screen was a Pressable (hold to copy) around a horizontal ScrollView around a vertical one: on an 88-row terminal it showed the top 42 rows, no prompt, and no drag moved it either way (simulator, 20 October 2026; XCUITest saw the same stillness in September and could not say why). The vertical view is now outside, bounded by the region, the hold lives on the text inside (CopyOnHold hands it down), and a terminal opens at its bottom and follows it until scrolled up. Code blocks and tool output in Read had the same wrapper.
  • A FlatList's content size changes while you swipe through it, as cells above the fold get measured for the first time. The reader used onContentSizeChange → scrollToEnd to follow new output, gated on a following flag that the throttled onScroll only cleared 200ms into a swipe — so a swipe up was snapped back to the tail before it had gone anywhere. Three swipes, three snaps, and the keep-your-place flow failed about one run in three on a loaded machine. A drag now ends following the instant it begins (onScrollBeginDrag); the position is re-derived from the first handled scroll event. Same shape for restoring a remembered place: it now ends when the anchor is seen at the top, not on a 1.5s timer that a slow measure could outlast.

Rules with reasons behind them

Break one of these and the app regresses quietly, which is the worst kind.

The service worker caches the shell and never /api. A cached dashboard would show agent states that are hours old, and a stale agent list is worse than an honest failure. Assets are hashed, so they are cached forever; the HTML is network-first with a 1.5s grace, because cache-first means every deploy takes two launches to appear. Normal launches were in fact served cache-first from 80c2a8b until the pre-public-release review, contradicting this paragraph; they are network-first again. The cache is named after its release: a hash of every built file, which web/sw-build.ts stamps into sw.js at build time, so there is no hand-bumped version constant to forget. Each release precaches every file it ships, the lazily loaded terminal chunk, the PDF viewer and the pdf.js worker included (about 2.7 MB uncompressed), because the first visit once cached a page whose JavaScript was not there, and a page left open across a deploy could not open the terminal or a PDF. Activation keeps the newest earlier complete release, for pages still running it, and deletes older ones, so at most two are cached. A response of type text/html is never stored as an asset: a sidecar answers a missing path with the app's HTML and a 200.

The app compares its own bundle against the served one whenever it comes to the foreground, and reloads if they differ when it is safe to do so. Drafts, attachments, pending or in-flight sends and uploads, and open dialogs defer the reload — in every conversation held in the draft store, not only the one on screen, which is all it checked until the pre-public-release review. A home-screen app is resumed far more often than launched — iOS keeps one alive for days — so without this a fix can go unseen indefinitely, and every conversation turns into "are you sure you reloaded?". A lazily loaded chunk that fails to load shows an in-place "could not be loaded" notice with Try again; if the server names a newer bundle, the update banner appears instead of the error screen.

The reader reads a window, not a file. A transcript is indexed once by the byte offset of the line that produced each message — two numbers per message against a parsed object — and a poll reads only that byte range. This replaced parsing the whole JSONL to slice twelve messages off the end, which had gone from reasonable (4.9MB, 516 messages) to 208MB of resident memory per pane opened (38MB, 2,391 messages). Measured after: a six-pane sweep costs 111MB where it cost 369MB, and re-polling a pane you opened earlier costs 1MB where it cost 154MB. Two properties of normalise make windowing sound and both are load bearing: whether a row produces a message depends on that row alone, and an orphaned tool_result already renders nothing rather than something wrong.

Codex transcript reads are now indexed. Byte ranges and matching tool output ranges are indexed incrementally; unchanged tail requests parse only their window. codex-index.test.ts covers append/truncate/replacement, partial UTF-8 records, pagination and bounded LRU retention. The dashboard's per-pane summaries (preview and lastMessageAt) are cached by transcript path, inode, size and mtime, and pruned each round to the panes that exist, so an unchanged transcript costs one stat per round and never touches the readers' 64-entry index LRUs; a session with more than 64 agent panes used to re-parse transcripts every 3s. The LRUs still bound the readers' own indexes. Finding the transcript is cached too: the dashboard reuses a pane's lookup for the same agent, session and status for up to 15s, because a Codex or Cursor pane without a reported session costs a herdr call and an lsof each time, and builds follow every store change. The reader's route and the transcript watcher always look afresh.

The reader polls the tail, not the page. Web polls the last 12 messages; native polls 60. merge keeps loaded history. Both bridge a disjoint tail within a bounded larger fetch when the same transcript grew while the client was away. Every fetched message is compared, not only the last: a tool call is written before its result, and when an agent runs two calls in parallel the first one's result lands after later messages exist, so a native merge that compared only the last left that call on "Still running." for good. The comparison uses a signature computed once per message object, so an unchanged poll still serialises nothing. With an ETag on the endpoint, an unchanged conversation costs 224 bytes on the wire instead of 15KB gzipped — and it polls every 2.5 seconds, forever, on whatever connection the phone is on. On the box it costs a stat: each pane keeps its last four pages and their ETags while the transcript's inode, size and mtime are unchanged (transcriptPage), because re-reading a page of screenshots on every poll held hundreds of megabytes.

Everything text-shaped is gzipped at the edge of the request handler, in one place, with compressed bytes cached for immutable assets. Nothing was compressed at all until it was measured: a cold launch pulled 640KB.

Never re-render the conversation when nothing changed. The poll compares a signature and returns the same array identity if it matches. A quiet session was otherwise rebuilding the entire reader, images and all, on a timer — which is most of what made it feel unsteady.

Callbacks passed to components that poll must be stable. onUnavailable was inline once; the pane re-renders on every frame, so the reader's polling effect was torn down and rebuilt every 400ms, refetching the transcript each time.

A native module rejects with an Exception subclass that overrides reason. Measured on expo-modules-core 57: promise.reject(code, description) reaches JavaScript as <code>: undefined reason (at …), because the message is built from Exception.reason, which only a subclass sets. Every SSH failure read as the app's generic fallback until SshTunnelModule's TunnelException did this.

Tests for routes live in mobile/src/screens/, never mobile/src/app/. Every file under src/app/ is a route, and Metro bundles it — expo-router's ignore pattern spares only +api, +html and +native-intent — so a *.test.tsx there broke the Release build.

A screen that sees a 401 calls useSession().unauthorized(), never signOut(). ComputerSession decides whether access really ended. A relay link is its device, so a 401 there does; an SSH computer ignores one while its sign-in is in flight or before any sign-in produced a cookie, and otherwise asks /api/auth/status about the cookie it holds now. A refused cookie triggers a new sign-in with the saved passcode; a refused passcode keeps the computer and shows the error. A reader poll that raced an SSH re-login used to sign out on its 401, which erased the saved computer with its password or key. signOut() is for the person's own sign-out.

The native app reconnects a healthy link on background → active, or on a real network change. iOS reports inactive for Control Center, Notification Center, the app switcher and system alerts; reconnecting on the way back from those dropped healthy relay links and failed sends that had probably reached herdr. A network reconnects once per change of network or of whether it is usable, not on each step of reachability. A failed SSH tunnel separately retries after one second, doubling to thirty. A refused host key, login, forwarding request, passcode or contract version waits for the person; retrying bad credentials can trigger the SSH server’s lockout.

What is not done

  • Live dictation needs physical-device acceptance before release. It uses Apple's SpeechTranscriber on supported iOS 26+ iPhones, currently in English. Tests of synthesized speech on a Mac and component tests do not prove iPhone microphone capture, interruption recovery, recognition quality or battery use. See docs/voice-input.md for the device checklist.
  • Terminal input already queued before a shell starts an agent cannot be inspected. The sidecar checks the foreground process before typing and the screen before Enter. A command still waiting in the terminal input queue can pass both checks and later launch a trust menu. The B4 manual recheck reproduced this race; the sidecar cannot make that queue visible. Do not describe this as fully solved.
  • Production zone settings need checks outside the build. On 26 September Always Use HTTPS and minimum TLS 1.2 were enabled through the signed-in dashboard. Public probes confirmed redirects and refused TLS 1.0/1.1 on the site, relay and review hosts. The CLI token still lacks zone-settings access. See docs/relay.md; a Worker deployment or HSTS header alone proves neither.

Stated plainly, because a vague gaps list is worse than none.

  • Native push is untested end to end — but the code is complete. The whole path is wired: a Settings toggle calls enablePush(), which registers an Expo token with /api/push/expo; the server sends on the transition to blocked and drops DeviceNotRegistered tokens; the root layout routes a tapped notification to its computer, pane and occupant. The toggle restores its saved state and can unregister notifications as well as enable them. What is missing is not code but a device: the simulator returns Device.isDevice === false and refuses to mint a token, so the only place this can be proven is a real iPhone. device_expo_push_token is still empty; the first token to land there is the proof. Do not go looking for missing wiring — flip the toggle on the phone. Expo receipt polling (dropping a token whose failure only shows in the receipt, not the ticket) is deliberately not built: it is premature for a table with zero rows, and the common invalid-token case is already handled at ticket time. See docs/notifications.md.
  • Native automation complements simulator and device checks. What bun run test:mobile, the simulator runs in e2e/native/ (paired through the encrypted hosted fixture) and the XCUITest harness in mobile/uitests/ cover is the answer — the counts used to be written here and rotted within weeks, so they are not any more. None of the simulator runs is in CI. The reader is now proven by pane.test.tsx — echo, working state, coalesced refresh, concurrent fetches, a 401 handed to unauthorized(), the restore guard — each checked by mutation: dropping the code fails exactly the test named for it. web/ has a broader browser suite, the native poller and behavior requiring hardware still need manual checks. One seam was moved to make the SSH and push tests possible: push.ts loads expo-notifications with an inline require rather than import(), which Metro defers identically and Jest can actually execute.
  • The refresh problem is not root-caused. The owner reports needing to refresh the page; four plausible causes were fixed (a render crash with no boundary, a WebKit-only crash on Notification, a Screen tab left blank when a pane's real size arrived after its first frame — the terminal is now resized in place and repainted — and a locally served app that drew nothing for up to 15s while its first auth check was pending, which now says "Opening Shahi…") and none is confirmed to be the one. If it recurs, what matters is which of three shapes it takes — blank, frozen-with-stale-data, or claiming LIVE while not updating.
  • The plugin has been installed on five Linux distributions, by hand, once. OrbStack VMs, 2026-09-04. Ubuntu 26.04 by herdr plugin link; Debian 12, Fedora 44 and Arch by the documented herdr plugin install iYassr/shahi. All four reached the same end state: startup hook, systemd user service, /api/meta answering, relay.connected: true. Debian and Fedora needed no workaround at all. Arch needed several — every one of them the distro's, not ours (pacman -Sy is a partial upgrade; it broke curl, which broke pacman). Two bugs came out of it that nothing else could find: bunPath writing bun's temporary node shim into ExecStart=, and serviceFor assuming Linux means systemd. Both fixed, both with tests named after the symptom.

    Alpine is the honest exception. busybox init, OpenRC, and no systemd in the repositories at all — apk search -x systemd returns nothing, so this is not a missing dependency but a different world. Everything else works there: the sidecar runs on musl, attaches to herdr, serves /api/meta and reaches the relay, all verified by running the unit's own ExecStart by hand. Only supervision is missing. serviceFor returns an unsupervised service instead of failing with Executable not found in $PATH: "systemctl", and setup still writes the secrets and stages the approved release, then hands over the exact command a unit would have run, for the box's own init to keep running; status and pair work. It used to refuse before any of that, which left every verb, status included, failing before a secret existed. An OpenRC branch would not be a port of the user unit — OpenRC has no per-user services — so it is deliberately not written.

    None of this is automated, which is the argument for a CI job: both bugs were a first install away, and nobody will remember to do it by hand twice.

  • Codex output is now read in full, against a captured corpus — but only what that corpus held. Every record, block and event type across the owner's real transcripts (79 Claude sessions, 23 codex rollouts) was enumerated and classified rendered-or-dropped; the dropped-but-real buckets were closed (codex reasoning, MCP, web search, native apply_patch; Claude model switches and away_summary/model_refusal_fallback notes). Codex 0.151 then moved all four of those codex shapes into item_completed items, and the reader dropped them silently until a second census in September 2026: 65 rollouts, codex 0.151 to 0.155, with 7,397 Reasoning, 1,258 McpToolCall, 558 FileChange and 8 WebSearch items and not one legacy event. Both forms are read now. What remains unproven is a codex tool shape that never appeared in those rollouts — it degrades to dropped, never guessed, so the failure is silence, not invention. Re-run the census (server/lib/*-log.test.ts document each shape) when a new agent version or a new tool lands; the 0.151 change is what skipping it costs.
  • The relay has no CI of its own beyond bun test relay under wrangler dev. The box↔relay↔phone loop was proven by hand against the deployed Worker (a fake phone in bun, then the app on a simulator paired by a shahi://pair link and running a shell command). A live job that deploys to a preview Worker and runs the fake phone is the obvious next step; it needs a Cloudflare token in CI.
  • agent.prompt is not exercised against a real agent in CI. The live suite proves it refuses a non-agent pane with a code; the runners have no claude or codex to prompt. On a machine that has one, SHAHI_HERDR_LIVE_AGENT=1 adds a test that starts a claude in the scratch workspace and proves the agent path end to end. The terminal path is proven everywhere.
  • Request timing instrumentation is operational, not tap-to-render. The sidecar records bounded route-template counts and latency histograms in /api/diagnostics; private JSON logs and local alert transitions rotate beside its database. Relay metadata and service incident alerts are documented in docs/operations.md. Never add terminal contents, raw paths, credentials or raw error messages to these records.
  • WebKit is not Safari. It is the closest thing available on a Linux box and it has earned its place, but the phone remains the only place some faults appear. docs/verify-on-device.md is the on-device checklist for those.

Testing

The suite runs against e2e/stub/server.ts, which speaks the same contract with no herdr behind it and records writes instead of performing them. Tests choose a situation rather than waiting for one:

await scenario(page, "waiting");   // three blocked agents, every run
await scenario(page, "empty");     // nothing running
await scenario(page, "crowded");   // twenty-eight agents

Every test starts from the same place: an automatic fixture in e2e/fixtures.ts (freshStub) resets the stub to busy first, which also clears the recorded writes, the contract range and the control handshake. A test that needs another situation still chooses it; order no longer matters. Leaving the stub on empty once failed ten tests at their first row: they had passed only because of what ran before them. The reset also means a contract override a failed test left behind cannot turn the next one into a 426.

Two engines, both required: Chromium is fast and catches logic, WebKit catches what the phone would. A handful of read-only checks run against the real server behind --project=live, because contract drift is the one thing a stub cannot notice.

Never point the suite at the live server for anything that writes. That mistake typed into somebody's session once already.

The stray-write fixture also captures the configured Sentry envelope locally; it never forwards test incidents to the production project. The deliberate render-error test checks the real SDK payload after privacy filtering. Keep the exact ingest destination check and the fuse for every other external write.

A dead stub is reported as a dead stub. Every test takes the server's pulse before it runs and again if it failed, because a run once produced one real failure followed by 78 connection errors and nothing said which was which. Now the first test to find the server gone fails saying so, and the rest are skipped — they did not run, and calling that 78 regressions is worse than saying nothing. The marker is a file (e2e/.server-gone, cleared by global-setup.ts) because Playwright replaces the worker process after a failure and module state goes with it.

When CI runs is the owner's policy (October 2026). Changes are committed and pushed straight to master, without pull requests, after bun run typecheck and bun run test locally; CI does not run on push. Nothing is released and no CI is started until the owner asks to tag a release: a computer release (Beta or Stable), a TestFlight upload, or an App Store submission. When they do, the whole suite runs where that release needs it: a Stable computer release (release.yml calls it on the exact commit), every signed phone update (mobile-update.yml), and by hand (gh workflow run ci.yml --ref master) on the exact commit before an App Store production submission. A Beta computer release only builds and smoke-tests its package; promoting it to Stable runs everything and re-verifies that package. Pull requests do not start it either (Dependabot rebases its PRs on every push to master, and each rebase was a full run): gh workflow run ci.yml --ref <branch> before merging one.

The suite: bun run typecheck — which includes the Expo app, the only automatic check that the two clients have not drifted apart — then the unit tests, the relay suite (test:relay) and the app's own (test:mobile), then a web build and the stub, hosted and PWA suites in both Playwright engines. Upgrade/recovery jobs exercise packaged releases on Linux and macOS, Intel and ARM. The long stub suite runs each engine on its own runner, with one worker and an isolated stub per job. Do not raise workers against the mutable shared scenario. CI required must continue to require every matrix result, including new jobs. CI runs the pinned actionlint checker as well as the workflow policy tests. Traces from a failing run are uploaded as an artifact. The stub suite writes its HTML report to e2e/playwright-report/, the hosted configs still to playwright-report/ at the root; both are ignored at any depth, because a failed --project=live run copies the passcode login and real session responses into it.

And a real herdr. server/lib/herdr-live.test.ts runs the adapter and the sidecar against a headless herdr: the protocol pin, snapshot shapes, the mirror and dashboard projection, pane.read in every form the app uses, a prompt typed into a scratch shell and read back (including 260 KB), occupant identity, pane.process_info, every key-bar name, the event stream, and the HTTP routes including the 426 gate. CI runs it five times per push — against v0.9.0, the minimum supported release, through v0.9.5, all pinned by tag and by the SHA-256 of their herdr-linux-x86_64 asset, and against whatever herdr's own installer hands out today, read from herdr.dev/latest.json (version and checksum) and cross-checked against GitHub — and nightly against the newest prerelease, checked only against its own release's digest, and against today's stable (herdr-preview.yml), so a stable herdr is tested the night it ships rather than at Shahi's next release. Every herdr in CI is installed by .github/scripts/install-herdr.sh, which refuses a binary whose bytes do not match GitHub's digest for the asset (and the pinned one, when given), and never runs it; herdr's own install.sh is no longer piped into sh. A failed nightly files an issue rather than failing a push, and that issue is filed by a separate report job that has only issues: write, no checkout, and runs nothing but gh; the job that runs the preview is contents: read. Every checkout in every workflow sets persist-credentials: false, which .github/workflows.test.ts enforces. The live suite writes only into a workspace it creates and closes, on a herdr you point it at explicitly — and that herdr must be a named session:

A named session isolates panes, not installed startup hooks. Always use a fresh XDG_CONFIG_HOME as well, so no plugins are installed in the test configuration. On 2026-09-18, starting a named session under the normal config ran Shahi’s startup hook and repointed the production service at the test socket. Keep the normal HOME so installed agents remain available; do not copy plugins into the test configuration. Use the same configuration root when stopping the named session.

test_config_root=$(mktemp -d /tmp/shahi-live.XXXXXX)
XDG_CONFIG_HOME="$test_config_root" herdr --session shahi-ci server &
export HERDR_SOCKET_PATH="$test_config_root/herdr/sessions/shahi-ci/herdr.sock"
SHAHI_HERDR_LIVE=1 bun test server/lib/herdr-live.test.ts
XDG_CONFIG_HOME="$test_config_root" herdr session stop shahi-ci
unset HERDR_SOCKET_PATH

The root is short on purpose. A plain mktemp -d on macOS lands under $TMPDIR (/var/folders/…/T/), which makes the session's herdr-client.sock path 105 bytes, over the 104 a macOS socket address holds, and herdr refuses to start.

server/lib/herdr-live-guard.ts enforces the named session: the suite refuses a socket that is not …/herdr/sessions/<name>/herdr.sock, and a session that holds the pane it runs in. Its guard used to be "HERDR_SOCKET_PATH is set", which proves nothing inside a herdr pane — herdr sets it in every pane, to its own socket — so SHAHI_HERDR_LIVE=1 typed in the owner's pane would have written into the owner's session.

HERDR_SOCKET_PATH=/tmp/x.sock herdr server is not isolation, and this was learned the expensive way: a second server on a new socket restores the default session's persisted state, and with resume_agents_on_restore (the default) it re-launches every agent in it — measured here as four duplicate claude --resume processes of the owner's live sessions, sitting in PTYs under the scratch server until it was stopped. A named session has its own directory under ~/.config/herdr/sessions/ and starts with nothing in it.

A newer stable protocol fails the pinned job on purpose: regenerate (bun run gen:types), read the diff, and bump the pin here and in ci.yml. That is a prompt to developers, never a gate for users: the service accepts herdr by protocol (protocol 22 or newer), not by version, and the update manager chooses releases the same way. Both matched exact versions until 0.3.30, and herdr 0.9.4 and 0.9.5 (same protocol) were refused on every computer that installed them, while the nightly preview job, whose expectation was the version list itself, and the agent journeys, which skip the service's gate, all passed. Every live check now expects "connected". Each version in release.json (HERDR_SUPPORT) needs a pinned job, even when it is also stable; the workflow policy test enforces this. Add herdr's current stable before each computer release: managers up to 0.3.29 install only a release naming the computer's exact herdr. Move the one-line installer to it too (site/herdr-pin.json, then a site release); site/install.test.ts holds the pin to the newest version listed. A pinned tag needs its digest beside it in the herdr matrix's include: gh api repos/herdrdev/herdr/releases/tags/vX.Y.Z --jq '.assets[] | select(.name=="herdr-linux-x86_64") | .digest'.

Hosted tests default to 7472 and 7572, configurable through HOSTED_PORT and HOSTED_SECOND_PORT; their stubs also bind each port + 1. PWA tests use fixed 7472/7473 and 7672. Something else listening on a required port fails the suite. Video playback runs in Chromium; the bundled WebKit cannot reliably play the media on these runners. Range delivery is checked in both engines. About one PWA run in twenty (measured 2026-09-23: 3 of 26, on a loaded machine) fails two or four tests with page.goto: Could not connect to the server: wrangler dev lost its local worker for a second ("Error inside ProxyWorker … Network connection lost" in ~/Library/Preferences/.wrangler/logs/), which the relay's wrangler dev did too on 2026-09-01. CI preserves Wrangler logs on failure. Hosted and PWA fixtures now probe each server before tests and after failures: an unreachable server adds an infrastructure annotation, preserves the original failure and skips later tests as untested. The output-directory marker survives worker replacement but is cleared for a new run. HTTP errors still run the app assertions. Inspect the logs before a targeted rerun; no assertion is retried automatically, and these diagnostics do not fix the upstream crash.

The two clients

mobile/ is the product. August 2026: a paid Apple account made a real build possible, and the decision followed that native is where this goes — voice dictation and anything else that needs the device properly are not things a web page does well.

web/ is actively maintained alongside mobile. The owner restored web feature development on 2026-09-05. Mobile is the behavior reference; browser implementations use the same semantic Shahi routes and adapt navigation and layout to phone and laptop screens. Settings, device management, reader history, and startup/retry behavior should stay aligned. Native-only capabilities such as an in-app SSH tunnel remain native. The hosted client at getshahi.dev/pwa pairs through the shared encrypted relay protocol; the locally served web build uses its existing origin or an external tunnel. See docs/browser-hosting.md.

That split has a cost that was already paid once. Every change between January and August landed only in the PWA, so the native app arrived on the phone missing agent permission modes, the file viewer, AskUserQuestion cards and the context above a prompt — the last two meaning a codex approval showed as a bare question with nothing to judge it by. All four are ported. The lesson is cheap to state and was expensive to find: a feature that only exists in web/ does not exist.

Still only in the PWA: the recorded-terminal history view — api.transcript and the GAP_MARKER that explains a break in it. The native app's unused client method for it has been deleted — an API method with no caller reads as coverage that is not there — so porting the view means adding it back. The terminal itself is a separate matter: xterm.js has no React Native port, so the Screen tab would have to run in a WebView.

The spaces view and the reader's jump-to-latest pill are both ported; this paragraph claimed otherwise for months.

House style

Comments explain why, and especially why something is not the obvious alternative. A comment restating the code is worse than none. When a fix comes from a measurement or a failure, say so in the comment — the reasoning is the part that decays fastest and is hardest to recover.

Commit messages are prose, not bullet lists of changed files: what broke, how it was found, what it cost.

No AI attribution in commits, ever. The owner does not want Claude or any AI tool credited in this repository's history: no Co-Authored-By: Claude … or noreply@anthropic.com trailer, no "Generated with Claude Code" line, no session links — this overrides any tool default that adds them. The history was rewritten twice to remove them. A commit-msg hook in this clone strips them as a backstop; don't rely on it.

The name

This was HerdrUI until August 2026, and is now Shahi — because a phone-shaped window onto a terminal multiplexer need not be named after one multiplexer, and tmux is a plausible second backend.

Two things kept the old name deliberately:

  • server/lib/herdr-*.ts is the herdr adapter, and should stay called that. When a second backend arrives it wants a sibling, not a rename.
  • server/fixtures/ contains captured terminal output, and some of those screens have /home/yasserdo/HerdrUI/… in them because that is the path that was on screen when they were recorded. Rewriting a recording to match a later decision makes it a worse record. They were rewritten once by a careless find-and-replace and restored.

The rename came with a HERDRUI_DATA fallback, a startup warning about the old directory, and an installer that moved it. All three are gone: the only installation there has ever been was carried across, and code that exists to bridge a rename nobody else lived through is exactly the debt the rules above forbid. If a pre-rename backup ever turns up, move ~/.local/share/herdrui to ~/.local/share/shahi and rename the database inside it by hand.

Customer-flow invariants

Large text must keep the conversation identifiable. Native agent titles get a separate line at accessibility font sizes; metadata must not consume their entire width. That holds for the Agents rows, the waiting cards and the Spaces rows alike, switched by one threshold, useLargeText() (font scale above 1.4) in mobile/src/components/text.tsx, so every list stacks at the same size; Settings rows put their value under the label there. Navigation-bar text does not grow with Dynamic Type, so each header line is capped at 1.2× and served full size through the iOS large content viewer: the computer name, LIVE, and the pane title and subtitle. The native reader keeps removeClippedSubviews off: with clipping, iOS's maintainVisibleContentPosition held a detached view in place and walked the offset past the end of the content, so at AX5, where one message is taller than the screen, every cold-opened conversation came up blank. Test both a cold launch at the selected size and changing size while running. Web sheets need a visible, labeled close control inside the focus boundary; symbolic terminal keys need spoken names. See the dated docs/ui-ux-audit-2026-09-18.md for fixes and verification limits. Native UI text uses @/components/text beneath TypographyProvider: font-scale changes replace only text hosts to invalidate stale iOS measurements. Never key the navigator or screen by font scale; doing so loses drafts and position.

Finding and opening work should agree across clients. Agent search matches conversation, space, folder and provider while preserving the selected filter. Native local Markdown links use the authenticated file viewer for absolute or home-relative computer paths, never phone file:// URLs. Text and image previews are supported; unsupported formats and unavailable files need readable feedback. Keep advanced connection details collapsed and highest-permission cautions visible even before selection.

Creating must also make the result readable. Workspace, tab and agent creation await SessionStore.resyncAfterMutation() before returning success. A snapshot already in flight can predate the write, so wait for it and then request a fresh one. Otherwise the client opens a real new pane and receives a false 404. Web retries when a later authoritative session first reports that pane; do not turn genuine missing panes into an endless polling loop. Keep readable agent labels separate from herdr’s restricted internal names. Space ids are reused after a restart (see the measured herdr behavior), and the retry record does not survive one, so an agent start names the space its sheet showed (workspaceLabel, optional): a space that is gone is a 404 and one with another name a 409 workspace_changed. Both clients pin the creation form to the space it opened for and never reopens by itself for the id's next owner.

Drafts are private, bounded, and memory-only. Web scopes drafts by server and device grant; native scopes them by the stable computer API object. Pane IDs alone are not unique across computers or across occupants over time. endedPanes drops an ended occupant’s draft and uncertain request ID, including background computers. An agent exiting into the same shell drops the reader but can keep the same terminal’s draft. Each scope retains at most 20 panes; web retains at most eight scopes. Both clients evict the least recently used draft, empty ones before typed ones, and never one whose send is running, whose uncertain send kept its operation ID, or whose pane is on screen: every opened pane creates an entry, so evicting by age discarded a typed reply after twenty other agents were checked, and dropping a retained ID lets a retyped message go out twice. Only such protected drafts can take a scope past 20, and the next new pane shrinks it back once they settle. Navigation and background/resume can preserve a draft, but reload/process termination cannot. Clear the scope on logout or revocation. A pending send retains its operation ID, and a late receipt must not clear a newer draft or change a dismissed screen. Native disables editing during the pending send; web preserves subsequent edits.

Uploads negotiate bounded, resumable transfer. Updated relay clients ask /api/uploads/limits and send up to 32 MiB as sequential 64 KiB requests. Keep 1 MiB encrypted frames, bounded queues and the 64 KiB/s byte rate. Chunk routes share the HTTP file-work admission limit and bulk-pacing rules. Transfers are owned by a device/session, journal offsets before acknowledgment, reject conflicting retries and finalize once after SHA-256 verification. Never replay uncertain chat writes. One active transfer per device/two per computer bounds storage. Clients mint a fresh id for every upload and run one at a time, so beginning a transfer discards the same device's or session's unfinished, non-finalizing one — an app kill or a lost cancel used to block that phone for the rest of the hour — and the periodic sweep discards any unfinished transfer idle for 10 minutes; one hour remains the hard cap. Completed receipts are capped at 128 per computer within their hour, with a refusal that says when a place opens. Keep partials outside projects. Multipart uploads are written 0600 in a 0700 directory (chmodded if older), and the multipart route checks authorization again after the body arrives, since revocation can land while a slow body is still coming. Revocation/logout discard unfinished transfers; completed or finalizing ones remain because their files may already appear in a conversation. Direct cancellation aborts its request; relay responses arriving after cancellation are ignored. The shared helper reads bounded ranges on both clients and supports progress/cancellation. Process termination requires file reselection. Old computers retain 761 KiB relay uploads; SSH remains 32 MiB. Do not raise rates based on a small-message load test.

Reader state belongs to its connection. Late history responses from a previous computer must not repopulate cleared caches. Preserve the anchor, offset and loaded history window on return; Latest must reach the actual end. Restoration must yield to deliberate keyboard/focus scrolling as well as touch. Native Read/Screen controls stay in the content layout so long navigation titles cannot cover their touch targets. Apply terminal and transcript responses independently, and never gate Screen rendering on transcript loading. A failed refresh must keep cached conversation messages visible. Connectivity changes preserve the computer API identity and its drafts; recovery never sends a draft automatically. See docs/mobile-recovery-2026-09-22.md. The native reader's memory — place, conversation, terminal place and Read/Screen — is keyed by that ComputerSession API object, like drafts. It used to be cleared whenever the connection's credential changed, which happens on every SSH re-login and every switch to another computer, so both threw away every remembered place; now it survives both and never crosses computers.

Reader state also belongs to its transcript. A herdr pane outlives the conversation in it, and Codex and Cursor number messages by position, so every transcript has a message 0 and a merge by id showed two sessions as one thread. Their message ids are now <sessionId>:codex-<row> and <sessionId>:cursor-<n>, so ids never match across transcripts, and /session's sessionId changes whenever the pane's transcript does; clients may compare it. Every open Codex or Cursor pane resets its cached page once when an updated sidecar first answers. The web reader replaces the view — not merges it — when a page's sessionId or path differs from the one on screen, resetting the history offset, the unseen count and the scroll place, and discards a late "Load earlier" page from the previous transcript. The native reader does the same, and its remembered conversation records which transcript (session id plus path) it came from.

For dated evidence and remaining physical-device gaps, see docs/customer-journeys-2026-09-18.md and the reports linked there. Keep test counts in dated reports, not permanent development instructions.

Review fixes, September 2026

Prompts and agent starts carry client-generated operation IDs. The sidecar retains the in-flight promise and its outcome for ten minutes: successes, and failures whose write may already have reached herdr. A failure that provably reached nothing is not kept, so a retry under the same ID runs again (server/lib/herdr-delivery.ts): a socket that would not open (ENOENT, ECONNREFUSED, EACCES, ENOTSOCK) or an oversized unwritten request (EMSGSIZE), or a herdr refusal made before it acts (agent_blocked, agent_not_ready, agent_not_found, pane_not_found, invalid_*). Read-only herdr calls never count as delivery, so a prompt refused with prompt_open after only reading the screen can be retried once the menu is gone. Anything else counts as delivered: a replayed error is the safe mistake, a repeated write is not. Retrying an uncertain operation must reuse its ID; a new ID means a new action. This is process-local retry protection, not a claim of exactly-once execution across a server crash. Agent startup uses the shared 325-second client deadline and disables Bun's per-request idle timeout only after authentication and validation.

Push registrations now belong to their device or passcode session. Old unowned registrations are discarded on upgrade; enable notifications again. Revocation and server-side logout remove the owner's registrations on both push channels.

The relay records operational metadata in Analytics Engine. Keep privacy copy in docs/privacy-policy.md and site/public/privacy.html aligned with the fields in relay/src/telemetry.ts and the configured retention; do not claim that the relay stores nothing.

Hosted browser releases use bun run build:site and the existing Cloudflare site configuration. Keep /pwa routing and service-worker scope isolated from marketing assets; every same-origin page remains in the browser trust boundary. Do not load third-party scripts or cache decrypted session data. The reviewed, bundled Sentry SDK is the sole client diagnostics exception: it must pass the allowlist in shared/src/diagnostics.ts before transport, and native crashes must pass mobile/plugins/sentry-native.swift. Keep replay, screenshots, breadcrumbs, console capture, automatic tracing and metrics disabled. Settings opt-out must apply to native and JavaScript reports. Never attach conversation content, raw errors, paths, account IDs, connection IDs or credentials. An error's type and message are sent through redactErrorMessage (and its Swift twin, sentry-redaction.swift, held to the same error-redaction-vectors.json): dropping messages whole left a fatal crash with nothing to fix it from. Reader incidents are deduplicated and gated on an active connected view. Upload debug artifacts from the exact shipped build and keep the scoped token in the releases environment; see docs/observability.md. Browser pairing is session-only unless remembering is explicitly selected.

Relay recovery must survive suspended timers. The computer's relay watchdog runs through connected, handshaking and retry states. A delayed tick after sleep replaces the socket even if a buffered pong arrived first. Keep retries bounded in rate but unlimited in count, preserve pairing identity, and never replay uncertain writes. relay-recovery.test.ts covers stuck sockets and lifecycle races; relay-resume.test.ts suspends only a disposable child process, never the user's computer or live service.

For the five-VM regression results and deployed-relay fault checks, see docs/relay-recovery-vms-2026-09-18.md. Live probes require explicit opt-in and must use disposable identities and uniquely named test services.

Private documents and credentials

Keep signed forms, France/ANSSI declarations, signatures, email exports, personal contact/address details, reviewer passwords, pairing identities and signing keys outside the repository, including ignored project folders. Use a private folder elsewhere on the owner's computer. Never copy these into documentation, fixtures, screenshots, build contexts, commits or public artifacts. Public documentation may describe procedures and current release limitations, but not private correspondence or completed forms. Check staged paths and content before pushing; ignore rules do not protect files already tracked by Git. The public Expo update verification certificate is not a private signing key and may remain tracked.

TestFlight feedback fixes, September 2026

Reader recognizes explicit HTTP(S) URLs in prose through shared/src/web-links.ts. Keep code literal, preserve URL punctuation within the address, and leave local file links on the authenticated file-viewer path. Native ExternalLink opens on tap and copies only the URL on hold; a hold must not also open the browser. ReaderLinkTests checks the native text responder and paste result against the encrypted fixture. Unit event calls alone do not prove selectable-text gestures.

For the current Claude/Codex mode, session-ordering and Reader checks, see docs/agent-compatibility-2026-09-27.md. Known agents start with explicit Ask me flags even when an older client omits its mode. Unknown mode ids fail before a tab is created. Codex's two asking modes explicitly select the user as approval reviewer, so an inherited auto-review setting cannot change what the picker promises.

New-agent clients derive an internal control name from the retained operation ID, so old servers also avoid globally colliding default names. Keep the operation ID and internal name stable on uncertain retries; display labels remain separate. Cursor CLI Read mode uses its exact reported session or the pane process's open store.db to find JSONL under .cursor/projects/*/agent-transcripts/. Never select a transcript by folder recency. Missing recorded tool outputs are explicit. Cursor user turns show their <user_query> text and exported attachment labels; <timestamp>, <dynamic_tools> and other wrapped context are dropped, because every user bubble had shown them as something the person typed.

PDFs use local PDFKit on iOS and a lazily loaded PDF.js canvas renderer on the web. Never upload documents to a third-party viewer. iOS shares a protected temporary local copy and deletes it when the share sheet finishes. This native module requires a new binary, not only an over-the-air JavaScript update. File downloads use authenticated sequential ranges of 512 KiB, retain the 25 MiB ceiling and reject changed file versions. Older small-file responses still work.

Conversation order follows starts and messages. Both clients default to the newest of the observed conversation start and latest message, with explicitly pinned conversations above the regular list. A new empty conversation therefore appears at the top immediately. PaneInstances persists startedAt for agents first observed after its initial snapshot; learning the first session id later, redetection and resuming that same session do not bump it. The initial snapshot is a baseline, not evidence that existing agents just started. Waiting cards stay in chronological position outside Inbox. The server supplies optional lastMessageAt from each pane's exact transcript; Cursor lacks message timestamps and uses that transcript's modification time. Missing dates sort last, stably. Never use terminal repaint, focus or status-change times as chat activity. Connected clients receive changed transcript summaries even when herdr metadata is unchanged. This additive field requires an updated sidecar for dated ordering; older servers remain readable without invented timestamps.

Launch acceptance is not composer readiness. Live herdr 0.9.1 can return agent_started with launch_pending before accepting input. The start adapter waits on agent.get for that same pane, within the existing startup deadline; never launch again to wait for readiness. The web Read tab remains available when a fresh conversation has no transcript yet, so later output can be opened.

Boundaries established by the September 26 review

Routes and saved state belong to their computer. Native pane, space and creation routes carry the computer id through lib/navigate.ts; useOwnedRoute refuses another computer and dismisses after commit. Unscoped links adopt the current computer once. A switch uses resetTo, which dismisses the old stack: keying a Stack does not clear expo-router's adopted navigation state. Cold links without a valid parent route start at the list; unknown paths use +not-found. The generated sitemap is disabled. Web history entries carry their computer too.

Each saved computer owns its status and name. Availability includes transport, request failures and herdr's backend state. Waiting counts for unavailable computers are last-known counts. Duplicate names are distinguished; a custom alias survives hostname updates. Adding a computer keeps the previous selection for Cancel and cold launch. Never send drafts or answers automatically when a link recovers.

A pane id is not a conversation. PaneInstances persists terminal/agent/session ownership in the sidecar database; a resumed conversation can retain its identity across restart, while a restored shell starts afresh. Terminal history has a transcript_owner. Optional instanceId guards writes with pane_replaced; a delivered prompt retry still returns its stored receipt. endedPanes ignores versionless startup snapshots, which are not evidence that all panes ended.

The current prompt wins. Shared prompt state accepts the snapshot's parse, including null, and uses promptId plus content to distinguish identical questions asked again. A refused prompt_gone or prompt_changed closes the stale card and refreshes the session. The server re-pushes blocked prompts after connecting and rechecks 250 ms after an answer/refusal. An optional option textInput marks only measured editable rows; choosing one focuses the composer and is not an answer. Old clients/servers can omit additive fields. Never infer a field from words like “No, and tell Claude”: isTextField uses the measured position and shape.

Native HTTP caching must stay disabled at the native layer. Expo SDK 57 fetch drops the per-request cache option. ShahiHttpCache empties URLCache.shared before setting disk and memory capacity to zero; reversing that order detached Cache.db while leaving old login bodies/cookies in it. The server independently uses Cache-Control: no-store for API responses without an explicit cache policy.

Keychain migration drains the old service on every read and write. An older TestFlight build can be installed between newer builds. Current-service values win; saved computers merge by id. A failed old-service deletion is retried. Only the current service holds reviewed host keys; a legacy pin must be shown before login. Pairing and SSH login use a private connection so a background snapshot cannot redirect a second computer's credentials. Pair links accept scheme/host without case; the relay must be an ASCII origin and claim errors stay with their code.

Pages are bounded by bytes as well as message counts. fitPage cuts oversized text/thinking blocks without splitting a surrogate pair and limits the page to 512 KB, leaving older messages for Load earlier. Native falls back only on 404; other errors remain visible and a 413 backs off transcript polling for 30 seconds. Indexes validate the last 64 indexed bytes before append, serialize builds per path, normalize malformed fields defensively and fall back row by row on failure. A single poller listener dispatches per-pane watcher wakes.

File checks belong to the handle that is read. /api/file opens nonblocking, then uses fstat on that same handle, refusing FIFOs and other non-files without holding a file-work slot forever. Refusals distinguish not_a_file, outside_roots, not_found, file_too_large and range_not_satisfiable; suffix/open-ended ranges are supported. A 0.3.6 relay response lacks range headers, so clients retry once without a range; only a file within one relay body can use that fallback.

Runtime behavior needs measurement. Use realPath/realPathSync for user paths: Bun realpath treats a backslash as a separator on macOS. The listener explicitly sets reusePort: false. A process killed by signal has signalCode but a null exitCode. Startup failures should print one useful line, not a minified bundle. Trust forwarded-for only for an explicitly allowed proxy Host, at its last hop; validate Host ports before URL parsing. Count byte limits in encoded bytes and truncate names by grapheme, never half an emoji. Open another program's SQLite only through openReadOnly (foreign-sqlite.ts). Codex and OpenCode keep their databases in WAL mode and remove the -wal when they close them. With no -wal, macOS's SQLite refuses a read-only open, and the error comes from the first statement, not the constructor. Linux opens it, so CI cannot see this; bun run test on a Mac can. The hand-started Codex fix read "No messages yet" on this Mac whenever Codex had its index closed.

Push is bounded and owned. Each device or passcode session holds one registration per channel; SSH renews its token after sign-in and expired sessions lose theirs. Both channels use high priority and a one-hour TTL. Web uses a per-pane topic; Expo sends batches of 100. Bounded retries exclude uncertain timeouts. Logs contain fixed outcomes, never tokens or text. Native installs its foreground handler at launch. A delivered notification is not withdrawn when the question is answered. state_change_seq is a session-wide snapshot counter measured on herdr 0.9.1; push uses status changes missed between samples without duplicating event notices.

Nothing between the computer and the phone reads a question. With push-actions, a relay-paired phone sends a 32-byte push key with its Expo registration; the server keeps it only for a device, as a column of that row, so every path that drops the registration drops the key. Notifications for it are sealed (push-seal.ts: AES-256-GCM, random nonce, associated data binding the server and pane ids); Expo and Apple see only CONTENT_FREE words, the routing ids, the key id and the box. The Notification Service Extension (mobile/plugins/notification-service, added by a config plugin, entitled only to the shared keychain group) opens it, shows the question and offers up to three answers as actions; it strips any category or answer it did not open. Labels in the box are the parser's exact labels, because /answer compares them; what does not fit is dropped, never cut. Actions open the app (.foreground): background actions were measured to be killed or suspended before an answer could land. The app posts what a card posts, and the server's checks decide. SSH and passcode sessions keep the plain notification. simctl push runs no service extension (measured on iOS 26.5), so a sealed payload pushed that way shows the content-free words; the Swift is checked against the server's known-answer vector by tests/run.sh.

Retry outcomes differ. An unwritten request can run again, as can a certain agent-start failure whose tab was closed. A prompt_changed after typing retains its outcome under the same id to prevent duplicate text. Folder/workspace refusals precede recording. Keep optional guards additive and error sentences suitable for already-shipped clients. Recovery control reads let native version notices clear when a computer update completes.

Installation messages must name the right herdr session. Use herdrCli for instructions outside a pane. Build hooks lack HERDR context, so reinstalls recover it from the service environment. Setup/status authenticate readiness with this install's key and allow 30 seconds. Re-enable runs no startup hook: use restart/pair or restart herdr. The manager backs failed starts off from 3 seconds to 5 minutes. An unsupervised service already answering is kept; that detection has a macOS harness test, not fresh Alpine verification.

UI invariants cross clients. Use shared paneTitle; trim only leading blank lines and trailing whitespace from drafts. Shell/Screen input disables capitals and autocorrect. File labels use the basename. Native controls have 44-point minimum targets; hide the reader from accessibility under Screen and close modals on loss of focus. PHPicker needs no prior library permission. Web terminal output must not capture Tab or Escape. Mark sending before receipt, working only after it.

site/privacy.test.ts requires all review-environment expiry disclosures to match demo/wrangler.toml. relay/test/protocol-doc.test.ts checks hello versions and close-code ownership. Keep these checks in bun run test and test:relay.

Live provider checks, September 27

docs/provider-modes-2026-09-27.md records the real-work checks and their limits. Discover herdr's cursor through cursor-agent; cursor launches the editor.

Current Codex folder trust can sit above 25 empty rows. Trim empty terminal padding before scanning. Its second choice depends on how Codex started (measured on 0.157.1, 2026-09-29): "Back to Agent Command Center" / esc back when attached to the shared daemon, "Quit" / esc quit on its own server, which is how Shahi starts every Codex. Codex also asks "Update available" before anything else whenever a newer release exists, with herdr reporting it idle. Both forms and the update menu are measured shapes in provider-prompts.ts; without them a phone-started Codex refused every message behind a menu it showed no card for. Cursor trust/commands have letter shortcuts, and OpenCode permission menus have horizontal choices whose cursor is a distinct background colour. Preserve ANSI through parsing and through both write guards; never assume OpenCode's first choice is still selected. Unknown highlight shapes remain unparsed. Existing clients send index/label; fresh server reads choose keys.

herdr 0.9.1 can report idle/done while current Codex, Cursor or Antigravity menus wait, and reports Codex's startup menus (update, model migration, hooks review) idle. The write guard refuses a message behind any menu the parser finds, so an agent's menu gets its answer buttons unless herdr says the agent is working (Poller.#asks); a pane herdr names no agent in still needs its blocked, as a shell may show a menu an earlier program left. The exact measured provider shapes in provider-prompts.ts still count as blocked outright. The dashboard projects those cards as waiting, and clears them when the screen changes. Until a new agent has a conversation, both clients show its screen with keys in Read, since its startup screens (sign-in, terms, trust) come before any transcript. docs/agent-screens.md lists every screen Claude Code and codex draw outside a conversation and what Shahi does with each; server/fixtures/startup/ holds the captures and agent-screens.test.ts holds each to "a refused message always has something to answer with". When either agent updates, run bun run server/scripts/screen-census.ts: it redraws them in a scratch named session with scratch agent configuration and exits 1 on any card that changed. Cursor's Skip opens a feedback field. Typing replaces its entire hint. Before Enter, verify the exact replacement and unchanged screen above it. Wrapped or multiline feedback stays conservative and may require Enter from the key bar.

All four Codex modes set approvals_reviewer="user". Besides making reviewer ownership explicit, on 0.157.1 this per-invocation configuration uses an embedded server. Without it Full auto and bypass attached to the shared daemon, losing this terminal's hook environment and exact process-file Reader lookup. Never repair that by selecting a rollout by working folder or modification time. A Codex a person starts by hand gets the shared daemon too (codex-cli 0.160: no session reported, no rollout open in the pane), and read "Nothing to read yet" (TestFlight build 39). Its pane title, "<thread name> | <label>", names the thread: rolloutFromTitle takes one only when exactly one unarchived thread has that name in that exact folder. The label is the folder's own name, or the git repository's when the folder is inside one. A subfolder of a repository failed until the agent journeys found it. The folder narrows a name and is never evidence alone; an unnamed or shared name finds nothing. Codex 0.163 blinks "[ ! ] Action Required | " (and "[ . ] …") in front of the title while it asks, adds "● " while its microphone listens and a spinner frame while it works; herdr passes them through, so withoutAgentStatus (agent-titles.ts) strips them for the row's name, the dashboard signature and this lookup. The same shared process reports every hand-started thread's session as the pane that first started it, so a reported session can be another pane's conversation (one pane on the owner's Linux computer read another's, 28 October 2026). When the title unambiguously names a different thread than a reported, named one, titleContradicts refuses both: Reader shows nothing rather than the wrong conversation. A thread too new to have a name never contradicts. Read as a name, the prefix hid the conversation for as long as the question was open (simulator run, 20 October 2026).

Read must match Screen, October 1

Screen is the ground truth: whatever an agent drew, the person saw. Reader went months without the newest reply of a backgrounded conversation, Claude Code's task list, its subagents and every message typed mid-turn, all in files on the computer, because every test compared Reader with expectations its authors wrote. docs/reader-parity-2026-10-01.md has the evidence. So:

  • Every shape an agent writes has a decision in transcript-shapes.ts (shown, state, followed, dropped, with the reason), held to the readers by its test. bun run server/scripts/transcript-census.ts --source fails on any shape without one: run it when an agent updates and before a release. "State" marks what the agent keeps on Screen beyond the record (task list, subagents); ask that question of every new tool, not only "can this record be displayed".
  • bun run server/scripts/reader-parity.ts compares Read with Screen for every live agent pane: newest prose, task list, overall prose. Read-only, prints counts; --show prints Screen lines Read lacks to the terminal, so use it only on panes whose content you may read, and never redirect it to a file. Run it before a release while an agent works through a multi-step task. A Reader change is verified against Screen, on the simulator or the phone (device check 23), not only against fixtures.
  • A Claude that parked its conversation as a background job shows that job: Reader follows the process record's parkedJobId before herdr's session, because the job reports itself with the daemon's HERDR_PANE_ID, which belongs to whichever pane first started the daemon (parkedSession).
  • A message typed while Claude works is a queued_command attachment, not a user row; it is the person's message. A <task-notification> row is never the person's, status or not.
  • bun run test:agents (server/scripts/agent-journeys.ts) starts every installed agent Reader supports (Claude, Codex, Cursor, OpenCode, Antigravity) both ways people start them: from Shahi, and by hand in a shell. It sends each a one-word message and checks that Reader, its page and the agent list's preview hold the reply, and keep holding it for a minute; then it asks the agent to run touch, answers its question from a parsed card as the phone would, and requires Reader to keep the conversation while the question is open. An agent its own account stops (signed out, a provider refusing) is a skip, not a failure. It runs in a scratch named session, but the agents are signed in, so every journey costs a couple of short replies. A pane's shell resolves the agent from the person's profile, not from the script's PATH, and OpenCode's and Cursor's configuration live under XDG_CONFIG_HOME, so the scratch root links theirs in. Two October failures passed everything else. Codex 0.160 started by hand reported nothing. On a Mac, its index then went unreadable whenever Codex closed it. Fixtures cannot see either, and an agent Shahi starts takes a different path. Run it on macOS and Linux when an agent or herdr updates, before a computer release, and after installing one. The owner's computers run it hourly whenever something changed and at least daily; failures open a GitHub issue.

Reader providers, September 27

docs/reader-providers-2026-09-27.md records Reader coverage for the five installed agents. conversation-summary.ts selects providers explicitly; an unknown agent's reported UUID must never fall through to Claude's transcript or image reader. Claude, Cursor and Codex file lookups validate canonical containment and refuse ambiguous copies. Process fallbacks never choose a newest file.

OpenCode 1.18.32 uses a shared SQLite database with message/part projections. Select only herdr's reported ses_ ID, query read-only in bounded windows, and include both the database and WAL in change detection. Images use authenticated session/part references, never base64 in Reader pages or remote URL fetches. Undo must change the Reader's existing path identity so API 5 clients clear retained history. Include the durable removal-event sequence as well: undo and a new prompt can prune the old branch and clear revert between two polls. Unknown older schemas without that event history remain unavailable. Its TUI integration retains the previous session at home until the next first prompt; do not infer a session change from terminal prose.

OpenCode 2 (2.2.0, 27 October 2026) writes session_v2 and typed session_message rows instead, after copying every 1.x session across the first time it opens the database; Reader found no conversation for any of them until the agent journeys ran on Linux the next day. opencode-v2-log.ts reads them, from OpenCode's own schema and TUI at that tag, and findOpenCodeTranscript picks whichever projection holds the session's newest write, so a person who goes back to 1.x is still read. Its undo deletes the undone rows when committed and records no event, so the reader notices fewer rows at or below the highest seq it last read (in memory, so an undo while the sidecar was stopped is missed). Attachments are named, not shown: Reader has no route to its media: blobs yet. npm's opencode-ai still served 1.19.4 that day; OpenCode's own installer serves 2.x.

Antigravity 1.2.11 writes the documented transcript under its brain UUID. Its unique open presence/<UUID>.lock identifies the current conversation when no hook ID is available; conversation database handles may include previous or child sessions and are not sufficient. Only an immediately following result with the next step index can complete a singular tool call. Internal metadata and unknown records are omitted.

Cursor's exporter rewrites its transcript and removes prior end-of-turn notes. Failure/cancellation notes must therefore invalidate retained positional history when replaced. Existing clients already reset on transcript path changes; keep the actual reported session ID exact. Bound tool fields as well as prose: the generic page fitter only shortens text and thinking blocks.

herdr integration install opencode writes the provider's normal config even with a scratch XDG_CONFIG_HOME. Do not use it to prepare isolated tests. Copy only the required integration files to the disposable provider config; never copy herdr's plugin registry or restart a user's service.

Agent plan usage, October 2026

Settings' Agents section shows Claude Code's and Codex's 5-hour and weekly limits (/api/plan-usage, capability plan-usage, server/lib/plan-usage.ts). Codex writes rate_limits into every token_count event, so the newest rollout reading is the account's; label windows by window_minutes, since a Pro plan reported only a weekly window as its primary. Claude Code writes its limits nowhere on disk; its documented status line input carries them, so Shahi installs a status line only when the person turns the switch on, keeps only the limits and the time, runs the status line they had before with the same input, and puts that one back when switched off. Never read Claude's credentials or call its usage endpoint instead. A status line hides Claude Code's footer hints; herdr's working, blocked and done states were measured identical with and without one (2.1.286, herdr 0.9.1). Tests that turn it on must point CLAUDE_CONFIG_DIR at a scratch folder. A reading from before its window reset is shown as reset, not as current.

Composer shortcuts, October 2026

A draft that is one /word opens a picker of the pane's agent's commands, and an agent waiting on the next message offers / and four quick replies (shared/src/slash-commands.ts, shared/src/quick-replies.ts, both clients' composer-shortcuts components). Both only put text where typing goes: the picker inserts /name and sends nothing, and a chip goes through the same send as the Send button, operation id and refusals included, landing in the composer if delivery is uncertain so Send retries it under that id. Chips hide, never insert, while anything is typed or attached, and stay away for REPLY_SETTLE_MS after a send unless herdr reports the agent at work.

The built-in lists are measured, not remembered (Claude Code 2.1.288, codex 0.160.0, cursor-agent 2026.09.28, OpenCode 1.18.33, Antigravity 1.2.12). Leave out every command that opens a full-screen view: after Claude's /usage (/cost is now its alias) the next message was accepted by herdr and never reached Claude, and /status, /config and /help open the same view. /model draws a numbered menu that herdr calls done, not blocked; the poller offers it as a card and /answer works. A partial name is dangerous to send: Claude's and codex's autocomplete take the Enter and run their top match (/hel ran /help), so the picker always inserts the whole name. A slash command does not start the native optimistic Working: /context answers at once and the indicator stayed for its ten-minute backstop.

Custom commands (/api/panes/:id/commands, capability commands) are Claude Code's only: commands/**/*.md named with : per subfolder, skills/*/SKILL.md by frontmatter name or folder and hidden by user-invocable: false, the description the frontmatter's or the first line, personal over project, from the configuration directory and from the agent's folder up to its repository root. Codex 0.160.0 has no custom prompts left to read. Names and descriptions only, read from each file's first 4 KiB through a non-blocking handle, inside the folder they were found in, within count and visit budgets.

Runaway usage, October 2026

A Durable Object is billed while anything in it is pending. Never use AbortSignal.timeout in one: its timer outlives the request it guarded, and the monitor was billed 15 s for every half-second check (operations/src/deadline.ts). The relay's alarms stay ALARM_FLOOR_MS apart, measured from the last alarm and not from now, so neither a loop nor a busy object can spin or starve them; see relay/src/limits.ts for the September storm that made this a rule. The monitor's relay rate incidents read Cloudflare's invocation counts, not relay code. Durable Object overage bills in whole millions, so one GB-s over the allowance is $12.50. The review container is billed as allocated around the clock; basic starves its CPU (measured in demo/wrangler.toml), so size it by CPU, not memory.

Private operations statistics

The private operations dashboard's concurrency values are five-minute sampled estimates. Aggregate the last observation per computer before summing; do not sum independent per-computer maxima as a fleet peak. Phone admission does not establish an authenticated device session. /stats?view=usage retains the admin bearer gate; its seven-day Cloudflare usage is cached separately for fifteen minutes. Account allowances are shared across projects. The 30-day cost model is a planning subtotal, not an invoice: retain its exclusions, conservative request weighting, checked pricing date and failure/unknown states. See docs/operations.md.

Reader activity presentation, September 28

shared/src/reader-rows.ts projects API messages into Reader rows for both clients. Consecutive agent records share one collapsed Activity disclosure beneath their written responses; user and system messages end that group. Routine tools and thinking stay inspectable there. Questions, failed steps and message images stay visible, and deduplicated tool file links and result images remain outside the disclosure. Preserve raw messages for pagination, echo reconciliation and caches.

Keep prose rows individually keyed by their original message IDs: a single giant virtualized turn breaks native paragraph anchors. Reuse unchanged projected rows and retain activity keys across appends and prepends. A new transcript must reset disclosure state even when it reuses message IDs. A busy-to-finished transition collapses activity once; the user can reopen it afterward. One current activity status replaces the duplicate working footer. Unknown tools get neutral status, never guessed prose or fabricated results.

Subagents are rows of their own, never activity steps (subagent on an Agent or Task call with a description and prompt). A background call's launch receipt is internal metadata, so the server sends "Started in the background." in its place; its <task-notification> row is a system note carrying notice.toolUseId, and readerRows folds a notice into the call with that id for its state and report. Unmatched notices — background commands, monitors — stay notes. Claude's task list comes from its own store, <config>/tasks/<sessionId>/<n>.json (claude-tasks.ts), on tail pages only, because rebuilding it from TaskCreate/TaskUpdate fails whenever a task predates the loaded page; without one, clients use the latest loaded TodoWrite.

Claude Reader setup, September 28

herdr's terminal detection names an agent but not its session; only the agent's integration hook reports that, and Reader needs it. The managed service keeps the integrations of Reader's agents installed (herdr-integrations.ts): once per start, after herdr answers, it installs a missing one for an agent present on the computer and updates an outdated one. Nobody types a command. It lives in the service, not the plugin's startup hook, because "Update computer" replaces the service and only a plugin reinstall replaces the hook. It runs only with SHAHI_MANAGER_ROOT and a connected herdr, because the live suite and the release smoke tests start the sidecar with the developer's real home, and an install writes the agent's real settings. An integration once seen installed and later missing was removed by someone; it stays removed. Discovery asks about every manifest kind: installedAgents caches one answer for the New Agent sheet as well.

A Claude that started before its integration was installed never reports a session, and nothing on the computer identifies its transcript exactly: Claude holds no transcript open, and sessions/<pid>.json is undocumented and not written by every process (measured on 2.1.284: none for fresh processes, even after a prompt). Its one disagreement with the hook was a conversation parked as a background job: the record keeps its old sessionId and names the job in parkedJobId, whose jobs/<id>/state.json has the right session. Followed that way it named the right conversation for all six live panes. So the person names it (claude-choice.ts, capability conversation-choice): GET /api/panes/:id/conversations lists the transcripts in Claude's folder for the process's cwd (every non-alphanumeric character becomes -), newest first, minus sessions other panes hold, with the pid record's session marked likely as a hint only; POST /api/panes/:id/conversation accepts only an offered id. The choice is held in memory against that pane's terminal id and Claude pid, and ends when either changes or the hook reports; chosenSession checks the pid in the pane's foreground processes, not only ones named claude, because a tool shares its group. The page says chosen: true, part of its ETag, so clients offer "Choose another". Never pick from the folder without the person.

Missing identity and a missing transcript have distinct 404 codes. Neither is an error in either client: every new Claude conversation has no transcript until its first message (none after 12s at the prompt, measured on 2.1.284), so both are a waiting state that keeps polling. The plain 404 from older servers remains supported. The plugin must carry CLAUDE_CONFIG_DIR into the launchd/systemd environment; .env keys reach loadConfig, not process.env. An explicit private .env value wins over the setup shell, including an empty value that restores Claude's default folder. Restart regenerates the service definition; this startup change requires an updated plugin installation. Exercise Reader messages and images in fresh processes launched with rendered service environments, not only tests setting process.env directly.

Conversation previews expose content before a choice is made, so they must use the same canonical transcript lookup as Reader. Reject root escapes, leaf aliases for another UUID, and distinct copies of one UUID before reading the preview. Directory aliases naming the same canonical transcript remain valid.

Do not identify a Claude conversation from sessions/<pid>.json alone. A live 2.1.283 process had a matching process start time but an older session than its SessionStart hook, because it had parked the conversation as a background job. This metadata cannot safely replace hook identity; it only marks a choice likely.

Codex's transcript index must name an existing file with the exact session UUID; stale entries fall through to the UUID-only search, never a working-directory or most-recent-session guess.

Dictation, September 29

docs/voice-input.md is the design and its measurements. Dictation uses only Apple's SpeechTranscriber (iOS 26), streamed live from the microphone; an iPhone that cannot run it gets no microphone button. Do not add a fallback engine, DictationTranscriber or a downloaded model without new measurements: the older Apple model measured several times the error rate, and the Whisper build downloaded 191 MB for worse accuracy and no live text. SpeechTranscriber accepts no custom vocabulary. fastResults is on (first live words 4.1 s → 1.0 s, final text unchanged, modules/dictation/tests/run.sh). Everything that ends a dictation keeps its words in the draft (interruptions, background, the time limit, leaving the screen); nothing sends. Audio is never written to disk. LiveTranscription.swift stays platform-neutral so the Mac harness drives the real engine; iOS audio lives in DictationSession.swift.

Changes view, October 2026

server/lib/changes.ts lists what changed since the last commit in the repository holding a pane's folder (paneFolder: herdr's foreground_cwd, else cwd) and diffs one listed file (capability changes). Read-only: never add commit, stage or discard without a new review. Opening the tab must run nothing the repository configured. Measured on Git 2.54, status and diff HEAD ran core.fsmonitor, a filter's process, a diff driver's command and the post-index-change hook; every call overrides those (filter drivers by the names non-global settings define), passes --no-ext-diff --no-textconv, refuses lazy fetches, runs with GIT_OPTIONAL_LOCKS=0 so an agent's index lock is never taken, and gets a rebuilt environment. -c diff.external= without --no-ext-diff makes Git fail, so keep both. A client path reaches Git only after it matches Git's own fresh listing, with literal pathspecs. The top folder must pass the file viewer's roots. One 12-second deadline per request, output to a private file cut at a byte limit (piped children fail under bun test on macOS). Diffs are cut at 3,000 lines or 256 KB and long lines at 1,000 characters, saying how much is missing; the native sheet also ends rows past 4,000 points (a precaution against iOS's largest bitmap, not measured). Never poll it: refresh on opening, on request and when the agent's turn ends.

Interface languages, October 2026

The browser and native interfaces support English, Arabic and Spanish, with a saved choice or the device language. Translate authored interface copy only; agent prose, questions, choice labels, names, paths, code and terminal output keep their original text. Pass opaque values through translation placeholders. Changing language must preserve connections, pairing identity and unsent drafts; do not remount navigators or force an RTL restart.

Native render translations must depend on useI18n().t and .locale. Expo enables React Compiler: a render calling the global ui() or currentUiLocale() can stay cached in English after a switch, even when the component reads context. This was caught on a Release simulator, not in Jest. Use UiText or a translator bound from the hook, and pass the hook's locale to shared display helpers. Reserve the global helper for events outside rendering. React Native mirrors logical left/right text alignment under Yoga RTL; flipping it again in UiText mirrors it twice. Verify actual Arabic layout and changing labels in the native language smoke, alongside unchanged raw content and drafts.

The native session context publishes an immutable control snapshot rather than the mutable ControlSession instance. Compiler caches keyed by that instance could retain its initial null handshake and hide updates and capabilities. Copy handshake/error/pending state on provider renders, keep bound actions stable, and retain snapshot identity when those fields and the selected computer are unchanged. The update lifecycle must pass in a compiled Release app; ordinary component tests alone do not exercise this cache behavior.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.