See the whole herdr session, not just its agents - #14
Conversation
…herdr paddock can watch and answer agents but cannot see the structure they live in, and cannot rename, create, spawn into, or close anything. This is the approved design for that, sliced into six phases that each ship on their own. Every non-obvious decision rests on a measurement against a live herdr 0.8.2, recorded in the doc rather than asserted. Four of them changed the shape: - an agent's name is a STORED override, not a derivation from the workspace label. Clearing it does not restore a herdr name, so space rename and agent rename are independent and "reset to default" would be a lie. - pane.read is a superset of agent.read - same bytes, same speed, and it works on a pane with no harness where agent.read returns agent_not_found. So a shell and an agent are one pane at two moments, not two renderers. - shell panes keep real scrollback (400 lines in 2ms); agent panes are on the alternate screen and cost ~35ms/line. The shell path is the cheap one. - session.snapshot returns the whole tree in one ~15ms call, and its shapes are already in herdr's schema, so the feature sits inside the drift guarantee instead of outside it. The load-bearing constraint: agents stay in the store, shells live only in the tree and the terminal view. Widening Agent into Pane would drag stateless panes through the delta path and the notifier - the one call site the roadmap already flags as unguarded, where a mistake kills notifications silently. What was measured is separated from what was not: five open questions are named as probe tasks rather than guessed at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phases 1-3 of the design: see the whole herdr session, and open any pane including one with no agent. Rename, create, spawn and close are a second plan, so this one ships on its own and makes an agent-less space visible for the first time. Task 1 is a probe, not code. Matching a herdr event on the wrong spelling fails silently and forever, so the six structural subscribe/deliver name pairs are measured and written down before anything subscribes to them. Two traps are called out where an implementer would otherwise hit them: applyMessage falls through to treating any unknown frame as a delta and reads msg.upserted, so tree-stale needs a guard placed BEFORE that line and a test for exactly it; and App.tsx resolves the open pane out of the agent list, which would bounce a shell pane straight back to the dashboard. Task 13 exists only to guard the design's load-bearing invariant - a pane with no agent stays out of the store - because nothing else fails if that breaks, and what it protects is notification delivery. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ong one fails silently
… paddock drops Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a payload-free `tree-stale` ServerMessage variant so the Spaces screen can be told the herdr session tree changed without replicating the tree onto the delta path. Hub.queueTreeStale() sends it immediately (nothing to coalesce). Client tracks it as ClientState.treeStaleAt, handled before the delta fall-through in applyMessage so an unhandled variant cannot fall through into `for (const a of msg.upserted)`. Also updates three existing test fixtures that construct ClientState literals, since treeStaleAt is now a required field.
Subscribe to herdr's six structural events (workspace/tab created/closed/renamed) using their measured delivered spellings, and fire Supervisor's new onTreeStale hook for those plus the three pane lifecycle events paddock already subscribes to. workspace.updated and workspace.metadata_updated are deliberately excluded, since a space's rollup agent_status moves on those alone and subscribing would turn invalidation into a refetch on every agent state change. Wired in index.ts's non-demo branch only: onTreeStale: () => hub.queueTreeStale().
…them are A merged (flat) space row now carries data-pane-row/data-state, since it IS both the space and its single pane — and, for the same reason, shows that pane's own name/title (falling back to the space label) rather than always the space label, which is what a bare shell pane needs to be identified by. Also wires #/spaces routing (App.tsx, route.ts), a header entry point beside Settings (HostHeader.tsx), and Spaces-screen CSS reusing existing tokens only.
The pane-identity fallback from the last round made a merged row's .space-name show the PANE's name/title instead of the space's label, which hid the space's own label on most rows (six of seven spaces are 1:1:1) — and workspace.rename writes exactly that field, so renaming would have looked like it did nothing on any shell-backed merged row, the same defect the design doc cites as the reason pane.rename is refused (§7.1). .space-name now always shows the space's label. The pane's own identity renders as a secondary .space-alias line, only when it differs from the label, so the common case (name matches space) stays a single line. The ⋯ button's aria-label now matches whatever the row visibly shows.
… ruinous pane.read (pane_id) is added alongside agent.read (target) rather than unifying them: a shell pane sits on the normal screen, where recent_unwrapped is ~2ms for 400 lines, but agent.read refuses that same source on a non-idle agent to avoid physically scrolling a live pane. Keeping the proven agent.read path untouched preserves that guard. resolveSource widens to accept a null state (a shell) and always answers recent_unwrapped for it, above the existing per-AgentState logic. The new POST /api/panes/:id/output route validates the id against the session tree (deps.readTree), since a shell pane is deliberately absent from AgentStore. Also updates the HerdrActions test mocks in action-routes.test.ts and origin-gate.test.ts with a readPane stub, required once the interface gained the new member.
…bare 500
The pane can close between the tree-validation snapshot and the
pane.read that follows it -- exactly the window this route's own
design creates, since a shell pane lives in the tree and not in the
store. Wrap deps.readTree() and actions.readPane() in the same
try/catch every sibling action route and /api/spaces already use, so
a herdr failure there answers {ok:false, detail} with 502 instead of
Hono's default plain-text 500. The 404 (unknown pane) and 409 (pane
has an agent) outcomes stay distinct, deliberate returns inside the
try, not folded into the catch.
…m the agent's controls A shell and an agent are one pane at two moments: open a shell, type `claude`, and the same pane_id becomes an agent. So the transcript, the ANSI pass, the scroll handling and the read loop move into PaneTerminal and work for any pane, while the prompt options, keypad, state dot, journal and reply box stay in AgentTerminal, which now composes it. There is one transcript renderer, not two. App resolves a pane from the session tree when the id is not in `agents` — a shell is deliberately absent from the store, so the old lookup bounced it back to a dashboard that can never show it. A shell poll cannot be validated against the store, so the route pays a session.snapshot (~17-19ms) on top of the pane.read (~2ms) — about ten times an agent poll's herdr work. The rate is matched rather than the interval: a 1s floor, with the ceiling and the doubling untouched. The route has no `unchanged` answer either, so "changed" is decided from a client-side digest; without that a quiet shell would poll at its floor forever. Measured live: 1001, 2039, 4026, 8032, 10039 ms. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ow opens at all Three fixes from review round 1. `harness` is the only discriminator between the two cases, and App was not consulting it. On a cold deep link — the primary entry path, since a notification links straight into a pane — `agents` is empty until the websocket snapshot lands, so the tree can resolve an AGENT pane first. PaneTerminal mounted on it, took the pane route's 409, and put "use /api/agents/:id/output" on the operator's screen. Now the shell branch requires `harness === null`, and a pane whose harness the store has not caught up with HOLDS rather than falling through to a dashboard that cannot list it. The other direction too: a 409 is a promotion in flight, not a failure. api.ts throws RequestFailed carrying the status (an Error subclass, so every existing catch is unchanged), and the opening read keeps the transcript and raises the existing stalled marker instead of the banner. Merged rows — six spaces in seven are 1:1:1 — rendered no anchor at all, so the commonest space shape had no route into the terminal. The label block is now a link, with the actions button a SIBLING of it: a button inside an anchor is invalid and unreachable by keyboard. The anchor is a real flex item rather than display:contents, so the gaps between marker, name and state are tappable. And a successful read that brings no new screen now clears `error`. It returned before `apply` ever ran, so the banner persisted indefinitely on a quiet pane while every read underneath it was succeeding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nst shell-pane leakage
…one live rule `toState` was byte-identical in `herdr/tree.ts` and `herdr/adapter.ts`. herdr's fifth `agent_status` would have reached one file and not the other, and the divergence is visible: the same pane showing a state on the dashboard and none in Spaces. Exported from the adapter, imported by the tree. `queueTreeStale` did not queue — it sent immediately, sitting directly beneath a `queue()` that coalesces. Renamed `sendTreeStale`, and the comment now says why the immediacy matters: this frame reliably beats the agent delta for the same herdr event. `resolveSource(null, _)` was reachable only from a test. `readPane` hardcoded the same answer and carried a comment asserting a call it never made, so the rule was written down twice. It now asks, which makes the test load-bearing. A pure structural event fired `onTreeStale` and returned without advancing `eventAt`, so `/api/health` read the link as quiet while the browser counted the same frame as liveness. And the call is now guarded locally: it sits ahead of the `pane_closed` branch that feeds the notifier, and was safe only because the callee happens to be trivial. Nothing proved the structural events were SUBSCRIBED. Deleting `...STRUCTURAL_SUBSCRIPTIONS` left all three tree-invalidation tests green, because `handleEvent` matches delivered names regardless. herdr would simply never send `workspace_renamed` again. The new test asserts the dotted subscribe spellings are asked for and the underscored ones are not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lling rows A live promotion discarded the transcript. `promoting` was added for the cold deep link and is right there, but it also fires when the operator types `claude` into a shell they are WATCHING — and the ordering guarantees it fires first, because `sendTreeStale` goes out immediately while the agent delta waits on the hub's coalesce window plus a supervisor refresh. So a live screen was replaced by a bare "Opening…" for a few hundred milliseconds. `PaneTerminal`'s 409 handling already rides out exactly that moment with the transcript kept; the hold was pre-empting a better answer. It is now conditional on nothing having been painted for the pane, which is the cold link and only the cold link. `.space-count` rendered "1 tabs" — wrong grammar, and the wrong unit for a space that is structured because one tab holds several panes, which is what `pane.split` produces. It now says whichever count explains the sub-rows, and pluralises. Both `⋯` buttons are gone. They were `disabled` with `aria-label="Actions for X"` on every row and would stay inert until a later plan wired them: an announced action that cannot happen is a mislabelled control, which CLAUDE.md rates worse than none. They were also already diverging from their visible text on a structured shell row — "bash" on screen, `w3:p1` announced. The visible-affordance requirement that put them there carries forward in a note at the top of SpaceRow.tsx, so the next author does not reach for a long-press. The Spaces glyph was `▦` (U+25A6) on the only route into #/spaces, where a tofu box would be a button whose label is a rendering failure — the lesson AgentTerminal already records from `␣`. Drawn as `SpacesIcon` instead. Two comments in PaneTerminal lied about the shell read path: both claimed `visible` only, when a shell asks for 400 unstripped-ANSI lines on every poll. Corrected, and the consequence the SHELL_MIN_REFRESH_MS reasoning never covered is now recorded beside it — a quiet shell is cheap for herdr and not cheap for the phone, and the pane-route digest that would fix it is named as follow-up work rather than done here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed as such `docs/gotchas.md` still credited `AgentTerminal` with seeding from the screen cache. It is `PaneTerminal`, and it is keyed by pane id — the whole reason a shell paints instantly too. The design's §9.1 says `tab.create` returns a bare `TabInfo`, so the new pane must be found by re-reading the snapshot. The probe on this branch measured otherwise: the result is an envelope carrying `root_pane` alongside `tab`, so the pane id comes back directly. §14.8 is accurate — `TabInfo` the type really has no `pane_id` — it is §9.1's inference from that which is wrong. Corrected in place, ahead of the paragraph rather than after it, and nothing deleted. The paragraph pre-empts its own objection convincingly enough that a later reader would implement the snapshot re-read feeling well-informed, and a note read second would not stop them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…to fix it Six items came back from driving the shipped screen against a real herd. Two were defects in the design doc's own reasoning, and those are recorded as such rather than quietly corrected. The worst was mine twice over. §14.7 measured that herdr initialises an agent's name to the SLUG of its workspace label. §6's merged row then compared the two strings for exact equality, so every row printed its own title twice, once de-spaced - the exact redundancy §14.7 exists to record. And the reason that row shows the space label at all was to stop a future rename landing on an inert control; fixing that introduced this. Also corrected in place: §8 claimed a stable key prevents the remount when a shell becomes an agent. It does not - React remounts on an element TYPE change, and AgentTerminal is not PaneTerminal. The transcript survives via pane-cache; scroll and revealed history do not. The restructure that would fix it properly was considered and refused, because it risks App.tsx's documented reason for keying per agent. Recorded rather than fixed here: a shell pane shipped read-only, because §8 promised it text input and no route was ever built. pane.send_text exists and is one route; xterm.js was refused at ~80KB gzipped on a 102KB bundle, in a repo that rejected a 76KB font on the same grounds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t as an agent
pane.send_text and pane.send_keys are the mirror of the agent path's
agent.prompt / agent.send_keys, wired up behind two new routes,
POST /api/panes/:id/text and POST /api/panes/:id/key, modelled on
/api/panes/:id/output's validation shape: 404 with no readTree, 404 for
an unknown pane, 409 pointing at the agent route when the pane has a
harness, and a try/catch turning any herdr throw into {ok:false,detail}
502 without ever collapsing a deliberate outcome into an error.
/key reuses the existing isNavKey allowlist rather than a second one,
and refuses anything outside it with 400 before it ever reaches herdr.
/text refuses text over the existing MAX_TEXT_LEN rather than
truncating it, since a shortened command is a different command.
Closes design doc §16.3: the shell case was promised plain text input
in §8 and never got a route.
… shell
sendPaneText/sendPaneKey in api.ts point the terminal's reply box and keypad
at the pane routes Task 2 added, rather than the agent-only sendText/sendKey.
Both use readJson (not the act convention the agent-side pair uses): the pane
routes' whole success body is {ok:true}, so a refusal rejects with the
server's detail exactly like every other read here, instead of resolving
through a failure shape there is no screen to attach to.
PaneTerminal grows two injected senders, sendText/sendKey, present only for
a pane with no agent. When they're supplied it renders its own reply box and
a keypad toggle, wired to pane.send_text/pane.send_keys — the mirror of an
agent's agent.prompt/agent.send_keys, because a shell is being typed AT and
an agent is being asked a question. Neither sender pushes a screen back on
success (the pane route has none to give); the next poll, paused only for
the round trip, picks up what the command produced.
The keypad itself moved out of AgentTerminal and into an exported Keypad
component in PaneTerminal, so the shell path renders the SAME grid an agent
gets rather than a second implementation that could drift from it.
AgentTerminal keeps its own senders untouched; only the grid's markup moved.
Closes design doc §16.3.
…er it App.tsx rendered the shell's PaneTerminal with load/minIntervalMs only, never sendText/sendKey, so an operator opening a real shell saw no reply box and no keypad even though both existed in the component and were exercised only by tests that injected fakes directly. Added sendShellText/sendShellKey, memoised on the pane id the same way loadPane already is, and wired them in. That wiring exposed a second latent bug: sendPaneText/sendPaneKey reject on a refusal (readJson's convention), but PaneTerminal's submitShellText/ pressShellKey had no try/catch around the injected sender, so a real 404/409/ 502 would have been an unhandled rejection instead of surfaced feedback. Wrapped both in try/catch. Moved Keypad out of PaneTerminal.tsx into src/web/components/ui/Keypad.tsx, where CLAUDE.md says paddock's own shared primitives live -- markup, classes, and behaviour unchanged, only its address. tests/pane-deep-link.test.tsx's shell deep-link test asserted .term-reply is null, which is the assumption this task overturns; flipped to assert the reply box and keypad ARE present, and added a test that mounts App itself and proves a real Send reaches the stubbed /text route -- the App -> PaneTerminal -> api.ts path nothing previously covered. Strengthened the "verbatim" test in shell-terminal.test.tsx with leading and trailing whitespace, which the prior value had none of to prove trim() wasn't happening.
Two defects behind one report (§16.4): the agent branch always went to the dashboard, discarding where the operator came from, and the shell branch hard-coded #/spaces regardless of origin. Both are replaced by one origin-aware rule instead of two literals: App records the real hashchange that opened a pane (in a ref on App itself, so it survives the shell->agent promotion remount) and returns there, defaulting to the dashboard whenever no origin was recorded — a cold deep link, a pasted URL, or a reload never lands on a Spaces screen the operator never visited. Also: Spaces.tsx's unclassed <button>Back</button> and the same bare button in App.tsx's pane-error branch both now use the shared term-back treatment, labelled for their actual destination. tests/pane-deep-link.test.tsx's cold-shell-deep-link assertion is flipped from "Back to spaces" to "Back to agents": that was pinning the hard-coded bug, not intended behaviour.
Decision 20 records the xterm.js refusal from §16.3: what interactive shell panes asked for, what an emulator buys (cursor addressing, resize, full-screen programs), what it costs (~80 KB gzipped on a measured 102.45 KB bundle, against decisions 5 and 6's reasoning), what shipped instead (pane.send_text / pane.send_keys behind the existing reply box and closed NavKey allowlist), and the condition for revisiting. Also corrects TreePane.title's doc comment in the one payload contract (src/shared/types.ts): it no longer claims to be "a shell's only label" — a shell is labelled by cwd now (§16.6), because a pane at a prompt has a title that IS the prompt, which labels nothing and puts a hostname on screen. title remains an agent pane's fallback label and the pane's own terminal-view text.
`pane.send_text` does not submit. The reply box is single-line, the only
Enter in the app is the keypad's, and the keypad's stored default is
`hidden` — so the shipped first-run flow was: open a shell, type `ls`, tap
a button labelled Send, and watch the text sit on the prompt line.
`POST /api/panes/:id/text` now takes `{text, submit?}` and performs the
whole operator act: `pane.send_text` then `pane.send_keys(["enter"])`. One
tap is one command run, in one round trip. The half-landed case — text on
the line, key refused — is reported as `typed, but not run: <reason>` and
never as success, because an operator told "failed" retypes a command that
is already there.
Also here, because they are the same code path:
- A 409 on the WRITE path was printing `use /api/agents/:id/text` at the
operator. The read path 40 lines up says why that must not happen; the
write path now mirrors it — stalled, plus "this pane is now an agent".
- `shellFeedback` was typed `ActionResult` with an unreachable "Sent."
branch. It is a failure or nothing, and says so.
- `.term-note.warn` / `.term-error.warn` had no colour rule in scope, so a
failed send rendered in body colour.
- `SHELL_MIN_REFRESH_MS` budgeted its `session.snapshot` per poll; every
keystroke pays the same guard, now recorded.
Tests: the route issues the key, `submit: true` survives every hop to the
wire, and a shell runs a command with NO stored preference at all — the
default configuration, which is the one the old tests could not see.
Two duplications the last round left half-finished. `shellLabel` was private to `SpaceRow.tsx` while `App.tsx` reached for `title ?? name ?? paneId` on its own, so the same shell pane read `project` in the list and `operator@dev-box:~` in the header it opened — the hostname §16.6 removed from the row, one screen deeper, feeding an `aria-label` too. The rule is now `pane-label.ts`, used by both, and `SpaceRow`'s two copies of the pane-label expression are one call each. The keypad's TOGGLE was duplicated verbatim between `AgentTerminal` and `PaneTerminal` — same markup, same hidden -> compact -> full cycle, same `writePref` — differing only in the state variable's name, and the WCAG 2.5.3 reasoning lived in one copy while the other pointed at it. It is now `KeypadToggle` in `ui/Keypad.tsx` with the reasoning carried across, and `PRIMARY_KEYS`/`SECONDARY_KEYS` are module-private again as they were before the move.
`--demo` leaves `readTree` unset, so `GET /api/spaces` 404s honestly — and `HostHeader` rendered the Spaces button anyway, in the mode the README says screenshots come from. `routes.ts` already records this defect class for `/ack`'s Dismiss button. The snapshot frame now states the capability (`spaces`), the store holds it, and the header takes `onOpenSpaces: (() => void) | null` — absent control rather than one whose only outcome is an error screen. Derived from what the server says it can do, never from a demo flag and never from anything about the device. A frame that omits the field (the browser-only demo backend) says nothing and gets no control, which is also the right answer. A synthetic demo tree stays deliberately out of scope.
`.host-settings-btn` was still a `⚙` codepoint at a hand-picked `font-size: 1rem` — with an OFF_SCALE exemption to carry it — while the control now beside it in the same cluster renders a drawn glyph. Two controls in one header rendering their labels by two different systems is worse than one that is merely plain. `SettingsIcon` joins the module (ten glyphs, header count updated), the stylesheet loses the font-size and gains the same flex centring as its neighbour, and `tokens.test.ts`'s OFF_SCALE loses the entry — that list's own comment warns against stale rows. Also: `tests/ui-icons.test.tsx` walked a closed list of eight while the module had nine, leaving `SpacesIcon` — on the only route into the new screen — with its decorative / `currentColor` / no-hardcoded-hex invariants unguarded. The list is complete now and a new test fails if it drifts from the module's exports. And `.space-name`'s comment no longer cites the `⋯` button that was removed.
- `AgentTerminal`'s `load` carries `agent.state` in its dependency list even though the body never reads it: it is how a state change asks the transcript to re-read. Only a comment stood between that and a cleanup. A test now flips the state through a real re-render and asserts a second read — verified red by deleting the dep. - `shell-terminal` and `pane-deep-link` cleared their keypad/rate prefs at the END of a test body, which a failing assertion never reaches. Moved to `afterEach`, the shape `terminal-render` already used. The default-configuration test now ASSERTS no pref is stored rather than arranging it, so a leak from another file fails there. - `App`'s origin-recording listener registered after `useAgentRoute`'s, so correctness rested on React 18 batching the queued `setId` before a ref read. It is declared before the route hooks now; effects run in declaration order.
- `architecture.md` had no `herdr/tree.ts` row, no tree edge in the dependency block, and an actions list four routes long that predated `/history`, `GET /api/spaces` and all three `POST /api/panes/:id/*`. CLAUDE.md sends every session there for the architecture rules, and rule 2 promises a protocol change touches three files — a map missing a module cannot deliver that. Every route is now listed, with the pane routes' 404/409 semantics stated. - Decision 21 records why the session tree is read on demand and never replicated into `state/store.ts`: the store is what feeds the notifier, and a browse feature does not belong on that path. With the costs it accepts and what would change it. - Decision 20 claimed the shipped shell covered "pressing Ctrl-C to stop one" and "type a command, interrupt one". `C-c` is outside `NAV_KEYS` and `tests/pane-input.test.ts` pins that it is refused 400. Struck, with the gap stated and a note that widening the allowlist is its own decision — not something to do to make a paragraph true. - Design §16.4 said a shell pane "returned to `#/spaces` correctly". It was hard-coded, which is the whole reason a cold deep link went somewhere the operator had never been. Dated correction added in place; the wrong sentence stays visible, as §9.1 argues it must.
`DEFAULTS.keypad` is `hidden`, under a comment reading "visible by default". That line is the one the review cited as the shipped defect's evidence: with the pad hidden and Send not submitting, a first run could type but not run. Corrected, with what this default must not decide.
Round 2 — the six review findings, fixedDriving the shipped screen against a real herd produced six complaints. All six are fixed on this branch (13 commits, Two of the six were defects in this design's own reasoning, and are recorded as such in 1 & 6 — the screen said the wrong things. 2 — shell panes were read-only. xterm.js was refused, and the reasoning is recorded as decision 20. It buys cursor addressing, resize, and full-screen programs; it costs roughly 80 KB gzipped on top of a measured 102.45 KB bundle, in a repo that rejected a 76 KB webfont because it would be the largest payload on a slow link. Typing 3 — back went to the wrong place, in the wrong clothes. Two defects behind one report: an agent pane always returned to the dashboard, discarding where you opened it from, and the shell branch hard-coded Also fixed: a shell's label put a hostname on screen. It was labelled by its terminal title, which for a pane at a prompt is the prompt. It is now labelled by its folder — and The one that mattered mostA whole-round review caught a Critical that three earlier reviews missed: the shell's Every test passed because the tests called
Verified end-to-end in a browser against a throwaway pane in the default configuration: the command executed, keypad untouched, tab closed afterwards. Known gaps, named rather than discovered
Create, rename and close for spaces and tabs is the next branch, designed in 🤖 Generated with Claude Code |
What and why
paddock could only ever see agents. Any herdr pane without a coding-agent harness was dropped by
toAgentand was invisible — and so was any space containing only such panes. This adds the read half of spaces-and-tabs management: the session tree, a screen to browse it, and the ability to open a pane that has no agent.Design:
docs/design/2026-08-25-spaces-and-tabs-management-design.md. Plan:docs/plans/2026-08-25-spaces-and-tabs-read.md. Phases 1–3 of six; rename, create, spawn and close are a second plan.Four measurements shaped it, all against a live herdr 0.8.2 (§14 of the design records them):
nameis a stored override, not a derivation. herdr writes it once at agent start from the workspace label, then leaves it alone. Clearing it does not restore a herdr name — the key disappears. So space rename and agent rename are independent, and a "reset to default" control would be a lie.pane.readis a superset ofagent.read— byte-identical content, indistinguishable speed, and it works on a pane with no harness whereagent.readreturnsagent_not_found. So a shell and an agent are one pane at two moments, not two renderers. Agents deliberately stay onagent.readanyway: it refusesrecent_unwrappedon a non-idle agent, a herdr-side guard against scrolling a live pane that we would otherwise have to maintain ourselves.state === nullis the discriminator.session.snapshotreturns the whole tree in one ~15 ms call, and its shapes are already in herdr's schema — so the feature sits inside the drift guarantee instead of outside it.The load-bearing constraint: agents stay in
AgentStore; panes with no agent live only in the tree and the terminal view. WideningAgentintoPanewould drag stateless panes through the delta path and the notifier — the call sitedocs/roadmap.mdflags as unguarded, where a mistake silently kills notifications.tests/shell-panes-stay-out.test.tsguards that line.The tree is read on demand and invalidated by structural herdr events only (
workspace.created/closed/renamed,tab.created/closed/renamed), never byworkspace.updated, whose rollup status moves on every agent state change. Verified live:tree-stalearrived ~400 ms after a tab was created, and a 55-second window containing a real agent state change produced zero. Subscribe/deliver name pairs were measured before anything subscribed to them —docs/probes/2026-08-25-structural-events.md— because matching the wrong spelling fails silently and forever.AgentTerminal.tsxsplits 1050 → 608 lines, with the transcript and read loop moving to a reusablePaneTerminal. Not cosmetic: that is the seam the shell case falls on. No pre-existing terminal test was edited, which is the evidence the split moved code rather than changed behaviour.Old
#/agent/<id>links keep parsing forever — Telegram messages already sent still resolve.Known gaps, recorded not discovered
--demo, so it is currently un-screenshottable under this repo's own README rule. Needs a synthetic demo tree.docs/architecture.mdanddocs/decisions.mdhave no entry for this feature yet; the design doc carries the reasoning meanwhile.tab.createdoes return the new pane id, on the envelope'sroot_pane. §14.8 is accurate —TabInfothe type genuinely has none; only §9.1's inference was wrong.Checklist
make check && make check-clean && make testpass — 1255 tests, 0 failuresif (!rawAgent.agent) return nullleft it green, because the fixture's agent list never contained the shell pane. It now supplies that row inline in all three falsy shapes and goes red without the guard. Same red-then-green done for the structural-subscription assertion, which previously allowed the whole subscription list to be deleted with a green suite.check-cleanclean; fixtures use the invented names only🤖 Generated with Claude Code