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
In this repository3 other files it gives its agents.
- AGENTS.mdAGENTS.md
- mobile/AGENTS.mdAGENTS.md
- mobile/CLAUDE.mdCLAUDE.md
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
- Distinctive: line 64
Keep components modular and concerns clearly separated.
- Distinctive: line 65
Prefer established, well-maintained libraries when they reduce overall complexity or improve reliability.
- Very specific: line 162
Every pane herdr restores comes back with a new terminal_id (w3:p1's term_65c5ac306c4425 as term_65c5ac360bcd31).
- Very specific: line 311
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 …
- Very specific: line 621
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 …
- Very specific: line 659
Always Use HTTPS and minimum TLS 1.2 were enabled through the signed-in dashboard.
- Very specific: line 704
All four reached the same end state: startup hook, systemd user service, /api/meta answering, relay.connected: true.
- Very specific: line 772
Every test starts from the same place: an automatic fixture in e2e/fixtures.ts (freshStub) resets the stub to busy first, which also clears …
- Distinctive: line 793
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 …
- Distinctive: line 969
Commit messages are prose, not bullet lists of changed files: what broke, how it was found, what it cost.
- Distinctive: line 988
When a second backend arrives it wants a sibling, not a rename.
- Distinctive: line 1031
Keep advanced connection details collapsed and highest-permission cautions visible even before selection.
- Very specific: line 1153
Do not load third-party scripts or cache decrypted session data.
- Distinctive: line 1182
Keep signed forms, France/ANSSI declarations, signatures, email exports, personal contact/address details, reviewer passwords, pairing …
- Very specific: line 1195
Keep code literal, preserve URL punctuation within the address, and leave local file links on the authenticated file-viewer path.
- Distinctive: line 1234
Never use terminal repaint, focus or status-change times as chat activity.
- Very specific: line 1307
Runtime behavior needs measurement: Use realPath/realPathSync for user paths: Bun realpath treats a backslash as a separator on macOS.
- Very specific: line 1356
Installation messages must name the right herdr session: Use herdrCli for instructions outside a pane.
- Very specific: line 1364
UI invariants cross clients: Use shared paneTitle; trim only leading blank lines and trailing whitespace from drafts.
- Very specific: line 1403
Read, since its startup screens (sign-in, terms, trust) come before any transcript.
- Very specific: line 1414
All four Codex modes set approvals_reviewer="user".
- Very specific: line 1448
Every shape an agent writes has a decision in transcript-shapes.ts (shown, state, followed, dropped, with the reason), held to the readers …
- Distinctive: line 1458
Run it before a release while an agent works through a multi-step task.
≈ “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.
Files
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 toolingherdr-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 servesbun 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.mdfor 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.writetakes only what the kernel's buffer holds — 8192 bytes on a macOS unix socket — so the rest must follow fromdrain. 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/promptanswers 413 above 256 KB. revisioncannot detect output changes. It tracked structural changes only: four polls returnedrevision: 0while 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 ignoreslines— 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 readsvisibleevery tick andrecentonce per pane, to seed history from before it was watching; the recorder builds the rest. - Output cannot be re-flowed.
recent_unwrappedreturns 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$HOMEinstead, 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 (folderProblemindirs.ts) and answer a readable 400 before herdr is asked. - Key names are strict.
shift+tabis accepted,S-Tabis not — it answersinvalid_key. Every name in the key bar has been sent to a live pane. - Agent detection needs an interactive shell.
bash -lcfound 2 of 4;bash -icfinds 4, because~/.bashrcis 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.startraces the shell it needs. The pane exists before its shell does, so starting immediately fails withagent_pane_busy. The server owns the retry (startAgentInTab), and clients call one route. The kind is checked againstserver.agent_manifestsbefore a tab is made, and a start that certainly failed — herdr refused before acting, oragent.getsays 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.promptrefuses 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 withagent_blockedbefore any input is sent", and a blocked agent is exactly the one you type a free-text answer into. Soprompt.tssends blocked agents, shells and unknown programs the terminal sequence —pane.send_text, 200ms, Enter — and falls back to it if herdr answersagent_blockedunder 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 on1. Yes, typing "no" then Enter ran the command. The text-field rows areType something.(the question tool) andTell 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 differentlyignores typing. Shells at their prompt are unaffected. Once typed into, a field's label is its text, soisTextFieldknows the question tool's by its place aboveChat about this. While a field has the cursor a digit is typed into it (measured on 2.1.282), so/answersendsUpbefore the digit there.- Claude Code's folder-trust question is an unnumbered menu, and its
default quits.
❯ No, exitoverYes, I trust this folder, no digits,Enter to confirmbeneath. Measured on a live pane: a digit does nothing there;up,down,j,kand the key bar'sUp/Downall move the cursor; several keys in onepane.send_keysland in order; and herdr reports the paneunknownfor a few seconds afteragent.startbeforeblocked. The parser anchors this shape on the confirm hint, and the server walks the cursor to answer it (answer.ts). A menu read during thatunknownwindow is kept, and the poller asks herdr's status again on an unchanged screen, so the card gains its buttons once herdr saysblocked; 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'sterm_65c5ac306c4425asterm_65c5ac360bcd31). A pane id is therefore not a conversation. The sidecar names each pane's occupant (PaneInstancesinherdr-pane.ts, sent asDashboardPane.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 409pane_replaced, and both clients forget a pane's draft, uncertain send and remembered conversation when its occupant ends (endedPanesinshared/src/pane-instance.ts), and keep the occupant with pins and notifications. agent_sessionoutlives its agent. A pane herdr restores without its agent (resume off, or the agent missing from the restarted server's PATH) comes back withagent: nulland the oldagent_sessionstill set. Read it only throughagentSessionOfinherdr-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 --jsonandherdr plugin config-dir <id>read the registry and work without a server;config-dirresolves for any id, installed or not.disable,enableanduninstallneed a running server;disableleaves the entry withenabled: false, anduninstalldrops 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 everyvisualViewportchange 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
onClickon 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 withoutappearance: noneand explicit sizing. Notificationdoes 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 (
CopyOnHoldhands 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 → scrollToEndto follow new output, gated on afollowingflag that the throttledonScrollonly 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.mdfor 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 toblockedand dropsDeviceNotRegisteredtokens; 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 returnsDevice.isDevice === falseand refuses to mint a token, so the only place this can be proven is a real iPhone.device_expo_push_tokenis 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. Seedocs/notifications.md. - Native automation complements simulator and device checks. What
bun run test:mobile, the simulator runs ine2e/native/(paired through the encrypted hosted fixture) and the XCUITest harness inmobile/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 bypane.test.tsx— echo, working state, coalesced refresh, concurrent fetches, a 401 handed tounauthorized(), 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.tsloadsexpo-notificationswith an inlinerequirerather thanimport(), 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 documentedherdr plugin install iYassr/shahi. All four reached the same end state: startup hook, systemd user service,/api/metaanswering,relay.connected: true. Debian and Fedora needed no workaround at all. Arch needed several — every one of them the distro's, not ours (pacman -Syis a partial upgrade; it broke curl, which broke pacman). Two bugs came out of it that nothing else could find:bunPathwriting bun's temporary node shim intoExecStart=, andserviceForassuming 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 systemdreturns 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/metaand reaches the relay, all verified by running the unit's own ExecStart by hand. Only supervision is missing.serviceForreturns an unsupervised service instead of failing withExecutable 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;statusandpairwork. It used to refuse before any of that, which left every verb,statusincluded, 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 andaway_summary/model_refusal_fallbacknotes). Codex 0.151 then moved all four of those codex shapes intoitem_completeditems, and the reader dropped them silently until a second census in September 2026: 65 rollouts, codex 0.151 to 0.155, with 7,397Reasoning, 1,258McpToolCall, 558FileChangeand 8WebSearchitems 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.tsdocument 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 relayunderwrangler dev. The box↔relay↔phone loop was proven by hand against the deployed Worker (a fake phone inbun, then the app on a simulator paired by ashahi://pairlink 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.promptis 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=1adds 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 indocs/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.mdis 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 agentsEvery 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_PATHThe 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-*.tsis 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 --sourcefails 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.tscompares Read with Screen for every live agent pane: newest prose, task list, overall prose. Read-only, prints counts;--showprints 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
parkedJobIdbefore herdr's session, because the job reports itself with the daemon'sHERDR_PANE_ID, which belongs to whichever pane first started the daemon (parkedSession). - A message typed while Claude works is a
queued_commandattachment, 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 runtouch, 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.
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
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.

