feat: history from the agent's own log, not a guess at its screen - #9
Merged
Conversation
… screen paddock reconstructs scrollback by diffing viewport snapshots, which its own header calls a viewer rather than a recorder: an agent nobody had open has no history at all, and a scroll bigger than half a screen is a gap. That cannot be improved by asking herdr harder — a coding agent sits on the alternate screen, which has no scrollback ring, so the bytes were never retained. The roadmap already measured the dead end: 500/1000/2000 lines each ~15.8s, returning LESS than `visible` returns in 2ms. The history is on disk instead. Claude Code writes every turn to ~/.claude/projects/<mangled-cwd>/<session-uuid>.jsonl, and herdr hands us the uuid on AgentInfo.agent_session — verified live against herdr 0.8.2, protocol 20, on the agent.list call paddock already makes, so the pane.list rule is untouched. Measured on one real session: 1.5 MB, 729 records, 40 minutes. Scope is narrow on purpose: "Show earlier" goes deeper and stops having gaps. No conversation view. Six decisions carry the weight — the client only ever sees lines, journal and reconstruction never coexist for one agent, menus are stripped from journal lines so a stale prompt cannot read as the live one, tool_result is never served because that is where file contents and secrets live, the session id stays server-side, and a missing journal is quiet in the UI but loud on the host. Design only. No implementation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`^C` signals the whole foreground process group, so cloudflared dies at the same moment paddock's teardown runs. Two paths then reported the same event: `teardown` printed its closing line, and the race watching `child.exited` printed `cloudflared exited 143 — the URL is gone` beside it, followed by the held cloudflared tail as the diagnosis. 143 is the signal the operator sent, and the URL going away is what they asked for. Worse, on a tunnel that had been up for half an hour the tail is whatever cloudflared last happened to say — its SUCCESSFUL startup connectivity prechecks — presented as the explanation of a crash. The one message that mattered was buried in it. The run already knows: `stopping` is set synchronously by `teardown`. Gate the failure branch on it, and take the exit status from `teardown`, which answers with the outcome it already had. Nothing is silenced. cloudflared's own shutdown lines still print live — `teardown` drops the display first, so the log sink is pass-through again — `tunnel closed` is still the closing report, and a kill that FAILED still warns, still names the command to check by hand, and still ends the run non-zero. Quieting a diagnosis is only safe on the one path where there is nothing to diagnose. Two tests, both watched fail first: a requested stop reports no failure and no tail, and a requested stop whose kill was refused still exits non-zero — the one thing the quieting must not take with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nine tasks, each ending in an independently testable deliverable and its own commit: generate agent_session, hasJournal on the wire with session ids off it, the registry, path containment plus a bounded tail reader, text shaping, the Claude adapter, the route, the terminal view, and a demo journal so --demo can still take the screenshots. Written against docs/design/2026-08-20-journal-history-design.md. Tests come first in every task, with the mutation pass house rule 4 asks for on each of the three guards that matter: containment, menu stripping, and the tool-result exclusion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the generator now emits HerdrAgentSession (recording the harness session associated with a pane) and adds the optional field to HerdrAgentRaw. herdr has sent this field since 0.8.2; without it in the declared shape, src/server/journal/ has no way to find the harness's session log. the schema-drift tests move agent_session from ignored to declared. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The terminal view needs to know WHICH history source to use for a given agent, without ever receiving the session id that source is keyed by — a session id is a filesystem key, and paddock does not hand filesystem keys to the browser. Agent.hasJournal is a required boolean derived in the adapter via an INJECTED predicate (AdaptContext.hasJournal), not a direct import of journal/: adapter.ts sits on the herdr axis, journal/ sits on the harness axis, and importing across them would fuse the two permanently. Defaults to false, which is exactly "paddock reads no journals" until a later task wires in the real predicate. sessionRefs() is a separate, server-only export (pane_id -> session) so the session id never has a path onto the wire type by construction. Making hasJournal required (not optional) surfaced every place an Agent literal is built without it, via tsc — 18 files, all fixed with hasJournal: false except the new adapter tests exercising the predicate itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The journal module is the entry point for reading a coding agent's own session log. This commit establishes the types, registry logic, and the adapter pattern that routes each harness to its corresponding adapter. The registry is the single decision site: adding a harness requires one entry in ADAPTERS plus its adapter module — never a new condition in the route layer or client. Stub the Claude adapter with Task 6 to complete the implementation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a session id arriving over the wire becomes a filesystem path, so it is hostile input before it ever touches disk. isSessionId rejects anything that is not a canonical uuid before a single filesystem call is made, and containedRealpath resolves both the candidate and the root with realpath and compares the resolved forms, because a symlink inside the root is exactly how a path that looks contained stops being contained. tailChunk bounds a single request to MAX_TAIL_BYTES so paging backwards through a huge log stays cheap one page at a time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… must not be silent Review found two gaps in the containment work. First, the ".." escape test never created its target, so realpath threw ENOENT and the function returned null from the missing-file catch before the containment comparison ran at all — a regression that broke .. specifically, while leaving symlink containment intact, would have gone undetected. The test now writes the escape target first, so the prefix check is what rejects it; the mutation check (containedRealpath -> return real) now turns this test red alongside the symlink test, where before only the symlink test caught it. Second, containedRealpath resolved the root and the candidate inside one try/catch, so a root that fails to resolve — a misconfigured CLAUDE_CONFIG_DIR, a permissions problem, a disk error — was indistinguishable from the ordinary "this session has no journal". The two resolutions are now separate: a candidate that doesn't exist still returns null quietly, but a root that doesn't resolve logs loudly before returning null, so the caller-facing behavior is unchanged but the failure is no longer invisible on the host. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…served This is the exposure decision for served history: ANSI is stripped, an already-answered menu is stripped so it cannot blend into the live screen and read as the current prompt, tool calls collapse to name + short hint (never their output), and everything is clamped so no unbounded string reaches the wire. Mutation-checked stripMenu by neutering it to a no-op — both menu tests went red, confirming they actually exercise the guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The brief's anchored ^...$ regexes only matched when the ENTIRE turn text
was one bare option line. A real prompt is a question plus two or more
option lines, so the guard essentially never fired on real journal data —
exactly the hazard it exists to prevent. Rewritten to operate line by
line: split, drop lines shaped like options, rejoin.
Also folds in review findings on the same regexes: an ASCII `>` cursor is
treated like `❯`; the 60-char label cap is gone (length must not decide
whether a row is an option); `)` is accepted as a separator alongside `.`;
a lettered option ("a. Yes") only counts as an option with a cursor
present, since a bare lettered row is too easily real prose; and a cursor
sitting on non-option prose ("❯ npm install") is kept rather than
stripped, since deleting real content is worse than one stray glyph.
Re-ran the mutation check with the fix in place: neutering stripMenu to a
no-op turned 7 tests red, including the new multi-line case, confirming
the guard now exercises the real hazard rather than the toy one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the Task 3 stub with a real locate/parse. locate resolves a session uuid against each configured journal root without ever touching disk on a malformed id, and containedRealpath keeps a resolved path pinned inside that root. parse turns Claude Code's private JSONL record shape into JournalEntry turns: text and tool_use become one assistant turn, thinking blocks and sidechain (subagent) records are dropped, and a user record is only a real turn when its content is a string — a list is tool-result traffic wearing the user role, and letting it through would both fabricate hundreds of "you" turns and leak whatever the tool result carried (the fixture's fake SECRET_TOKEN is exactly that case). One unparseable line costs itself, not the rest of the file, since a tail read can start mid-record. verifiedAgainst now names the harness version this shape was checked against instead of the stub's "unverified", because the format is undocumented and this string is the only record of when it was last confirmed. Mutation-checked per house rule 4: making the user branch also accept a list turned the SECRET_TOKEN and list-is-not-a-message tests red (and only those two); reverting brought the suite back to green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review found that toEntry dereferenced each assistant content-array
element without checking it was an object first. A record like
{"content":[null,{"type":"text","text":"hi"}]} is valid JSON, so
JSON.parse succeeds and the loop then throws on `p.type` with p ===
null. parse() only wrapped the JSON.parse call in try/catch, so that
throw propagated out of the exported parse() and cost the operator
their entire history — directly contradicting both types.ts's
contract ("Unknown records are ignored, never fatal") and this
module's own header ("one unparseable line is skipped rather than
costing the file"). Reproduced the crash directly against the
pre-fix code: TypeError: null is not an object (evaluating 'p.type'),
unhandled, at claude.ts:92 via parse() at claude.ts:64.
Fixed at both levels, since they guard different things: toEntry is
now called inside the same per-record try (so any throw from a shape
nobody has planned for costs only that record, which is the general
guarantee this format's private, unversioned nature requires), and
the content-part loop now skips any element that isn't an object
before touching its fields (so the known null-element case is handled
precisely, not just caught).
Also addressed: locate's readdir catch treated every failure as an
ordinary miss. Split on error code the same way containedRealpath
already does for its root argument — ENOENT stays a quiet miss, but
anything else (e.g. an unreadable CLAUDE_CONFIG_DIR) now gets a
console.error so it leaves a diagnostic trail instead of presenting
as "no journal here".
Extended the existing fixture and test file rather than replacing
them, so prior assertions (entries[0], entries[1], entries.at(-1))
keep the same meaning. New coverage: a null content-array element
whose good sibling still survives, a record with no message at all,
and a user record whose content is a number — all asserted to
produce no entry and not throw, plus a fixed-length check on the
whole fixture's output.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Exposes the journal reader (Task 3-6) over HTTP. The route is registered unconditionally, like /ack, because it reads a file and never touches herdr — gating it on the herdr actions dependency would repeat the /ack mistake: the one feature that works without herdr being the one visibly broken in --demo. The cursor travels in the POST body, never a query string, so it never lands in an edge access log. supervisor.ts now injects hasJournal (via journal/registry's hasAdapter) into toAgents and captures agent.list's session refs each reconcile, so routes.ts can resolve an agent id to the herdr session read() needs without ever importing herdr itself. Live-verified against a running herdr 0.8.2 with real Claude Code panes: a real pane returned source:"journal" with real lines from its own session log, and paging backward with the returned cursor produced the preceding turns with an earlier cursor — confirms the byte-offset paging actually walks the file rather than repeating a page. An unknown agent id 404s and a non-digit cursor 400s, also live. Every pane on this machine happened to be a Claude Code harness, so the "reconstruction" fallback for a non-journal pane could not be exercised against a live herdr; it is covered instead by two of the seven route tests with an injected reader. The brief's test file needed one change from its verbatim text: the harness() helper's default-valued `page` parameter had no type annotation, so tsc inferred a type from the "journal" literal default and rejected the later "reconstruction" literals passed to it. Annotated as JournalPage to fix, with no change to what the tests assert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ust say so
Pagination reported `hasMore` from BYTE truncation while cutting the page by
TURN count, which lost history three ways: a file the window covered whole
said "no more" after `.slice(-limit)` had dropped the rest; a record
straddling the tail-read boundary was dropped twice, as a partial head here
and a corrupt tail on the next page; and every entry between the chunk start
and the last `limit` of them was never revisited, because the cursor pointed
at the chunk rather than at where the limit actually cut.
The cursor is now a record boundary. The walk goes backwards from `end` —
always exact, being either the real file size or a cursor this function
issued — and stops when `limit` entries are collected or the chunk's lines run
out, so the cursor is the first byte of whichever record was consumed last.
Measuring forward from `startByte` cannot do this: a tail read begins at an
arbitrary byte, so it splits multi-byte characters, and U+FFFD re-encodes to a
different length than the bytes it replaced — measured at +2 to +4 per cut,
which puts the cursor inside a record instead of on it.
One window has no boundary to offer: a record wider than MAX_TAIL_BYTES fills
it with no "\n" to cut on, and Claude Code writes several-hundred-KB
tool_result records routinely. Stepping to the window's start is the only way
past a record a bounded read cannot serve, so that is what it does — and it
now says so. Serving that as an empty page with `hasMore: true` and no detail
is a "show earlier" tap that does nothing, hides every turn recorded before
the oversized record, and logs nothing anywhere.
Also: the read inside `tailChunk` is caught alongside the existing `.size`
catch. `size` only stats the file, so a log rotated or made unreadable between
the two threw out of the route as a 500 with no detail — a broken dashboard
where "no history for this agent" was meant.
Tests, in a new tests/journal-read.test.ts because the route's own tests fake
the reader and cannot express a losslessness property: pages concatenated
oldest-first equal one whole-file parse, under and over the window; hasMore is
true whenever earlier turns remain; every cursor is a real record boundary on
a file whose cut is forced mid-character; an oversized record is reported and
paged past; an unreadable log is a detail, not a throw. The unreadable case
uses a directory rather than `chmod 0o000`, which root ignores — that would
pass locally and fail in a CI running as root.
bun test 967 pass, 0 fail, 100 files
make check clean make check-clean clean
Verified live against herdr 0.8.2 with seven real panes, not fixtures:
- 15 real session logs, 33 KB to 37 MB, each paged to the beginning: 194
pages, 43,456 lines, no stall, every cursor a true record boundary, and
each file's pages equal to a whole-file parse minus exactly the records
too wide to serve. Two of the fifteen hit the oversized-record branch —
13 and 15 pages of it — so that path is ordinary, not hypothetical.
- All seven live agents paged to the beginning through POST
/api/agents/:id/history, cursors strictly descending.
- One 5.4 MB log, 17 pages through the route, byte-identical to a single
whole-file parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wires the phone up to the history route from the previous task: an agent with a journal fetches earlier turns from POST /api/agents/:id/history instead of replaying client-side reconstruction, while a plain shell pane keeps exactly the behaviour it had before. The two sources never coexist for one agent (design decision 18) — a journal-capable agent's "Show earlier" state is separate from the reconstructed-history state, not merged with it. The journal page size is its own constant (20 turns), distinct from the reconstructed path's line-counted HISTORY_PAGE, because a turn routinely flattens to several lines and reusing that number would dump 250+ lines on a phone in one tap. source: "reconstruction" is read as "fall back silently," never as an error, since that is the server's normal way of saying it has no journal for this agent. Verified against a live herdr and a live paddock instance through headless Chrome (CDP): tapping "Show earlier" twice grew the pane and fetched exactly one POST to /history per tap, with the scroll distance from the bottom held constant (229px before and after both taps) rather than jumping, and zero live option buttons ever rendered from journal content. A real phone check remains outstanding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review of the previous commit found the fallback it documented did not exist: `revealed` and the button's onClick both gated on the static `agent.hasJournal` prop, never on the per-request `source` the server actually returned. A hasJournal-true pane whose session ref went missing, or whose file was deleted or unreadable, got `source: "reconstruction", lines: []` back and latched `journalDone` — hiding "Show earlier" forever while `revealed` stayed pinned to empty `journalLines`, with `history.settled` never read. Silent and permanent data loss on the one feature this task exists to add. Fixed by deciding the render source per pane from what the server said (`journalFellBack`), not from the hint: on `source: "reconstruction"` the pane now falls back to the reconstructed path entirely and permanently, granting the first page of it immediately so the triggering tap is not wasted. Two more findings from the same review, fixed alongside since they touch the same handler: a rejected `/history` request no longer latches `journalDone` or hides the button — it surfaces through `feedback`, the same channel a failed key press or reply already uses, and leaves the cursor untouched so a retry asks for the same page. And a double-tap on the button now fires exactly one request, guarded by a ref checked synchronously in the handler rather than by state a re-render has not caught up with yet. docs/decisions.md decision 18 and docs/roadmap.md's addendum are corrected to describe the hint/answer split and the surfaced-failure case, rather than asserting a silence the code did not have. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
README screenshots come from --demo, and until now every seeded demo agent had hasJournal: false, so "Show earlier" reading a real session log had nothing to show there. One agent (flaky-test-fix) now claims a journal and the demo backend's /history branch answers it with a short invented transcript, source: "journal" — matching the wire shape the real route produces and the field the client actually keys its routing on. Every other seeded agent is unchanged.
CLAUDE.md names `paddock --demo` as where README screenshots come from, but its /history route always answered source: "reconstruction" — demo mode never constructs a Supervisor, so sessionFor had nothing to ask and the real journal reader got session: null every time, regardless of any agent's hasJournal flag. The static-build demo (web/demo/backend.ts) fixed for its own host in the previous commit was a real gap on its own, but it did not close this one: they are two independent demo backends. server/demo.ts now exports demoJournalPage/demoSessionFor, the demo's own answer for /history, confined entirely to index.ts's DEMO branch so a demo run never touches a real session log. The invented transcript and the id of the one agent that gets it now live in shared/demo-history.ts, imported by both demo hosts, so they tell the same story instead of two that could drift. Verified live: `bun src/server/index.ts --demo` now answers source: "journal" with the shared transcript for flaky-test-fix and source: "reconstruction" for every other seeded agent. Also found and confirmed pre-existing (not introduced here, not fixed here): CLI --demo's /output, /prompt and /answer are only registered when HerdrActions is present, which demo mode never sets — already documented in docs/roadmap.md as the "approve path" gap — so the terminal pane itself, and therefore the Show earlier button, cannot render in a browser against CLI --demo regardless of this fix. The static-build demo has no such gap and is where the button is actually screenshotable today; the /history contract itself is confirmed correct on both hosts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the adapter served any `user` record whose content was a string, on the rule "a STRING is a person typing". the harness writes into that same field. measured against the three largest session logs on this machine: 733 such records would have been served, 176 carrying a `<result>` body (subagent and tool result text), 180 carrying `<task-notification>`/`<output-file>` blocks, and 453 carrying an absolute home path. 278 of them ran past MAX_TEXT_CHARS and were truncated to 4 KB of that and served anyway. design decision 4 promises prose only and tool results never. two mechanisms, because one is not enough. a NAMED LIST covers the shapes the harness is known to inject — result, task-notification, output-file, system-reminder, local-command-stdout, and the command-name/message/args triple a slash command expands to. a SHAPE RULE covers the rest: hooks, plugins and future harness versions write into the same field with vocabularies nobody has listed, and they all name their blocks in kebab- or snake-case, while the markup in a message a person wrote is html or jsx — a single lowercase word, or pascalcase. so `<task-notification>` and `<observed_from_primary_session>` go and `<div>`, `<span>`, `<AgentRow>` stay. stripped, then dropped if stripping empties the record. a record that was only an injected block has nothing left worth showing and must not become a bare "you" row; a record that is a typed message with a block appended — the ordinary shape, since the harness appends to whatever was said — is worth keeping minus the block. and stripping happens before the text cap, not after, or a block wider than 4 KB is simply truncated and served. this subsumes the deferred isSidechain finding. that flag is top-level only, and a `<result>` block is exactly how a subagent's output reaches a record the flag cannot see. after the fix, on the same logs: no served user record contains an injected element of any kind, and the 453 home paths fall to 12 — all of them in prose with no markup at all. those are not redacted, deliberately. that is content the operator typed and asked to see; a scrubber over it would mangle real messages while doing nothing about the secret a person can type directly, and it would move the bound from the KIND of content served to the content itself, which is what "bounded at the source" exists to avoid. three smaller corrections ride along, all in the same file: - `summariseTool` no longer reads `pattern`. a search pattern is operator-supplied text that routinely embeds the very secret being searched for, so it is dropped rather than bounded — truncating a secret still serves its prefix. - `summariseTools` collapses a run of the same tool to one `×N` token. `types.ts` and the design both promised `Bash ×3` while repeats rendered `Bash · x · Bash · y`; a run rather than a total, so the line still reads as a sequence of what happened. - journal stamps use the host's local clock. they sit inches above a live screen showing local time, and two clocks an inch apart differing by the utc offset is a reader mis-ordering their own session. docs/decisions.md 18 and the design's §2 said the reconstructed path "is switched off for that agent" while the client still merges every poll into it. the code is right and the sentences were wrong: `history.ts` can only commit a line it watched scroll off, so a buffer switched off at the source would be empty at the exact moment the fallback needs it. both now say the journal is the only source RENDERED, and that the buffer keeps accumulating for the fallback. the fixture is invented per house rule 2 and its paths are `/path/to/…` placeholders — its first draft used literal home paths and `make check-clean` caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`read.ts` interpolated `String(err)` into `detail` at two sites, and
`routes.ts` returns `{ ok: true, ...page }` verbatim. a bun/node filesystem
error stringifies with the path it failed on, so a rotated or deleted log
turned an ordinary miss into `ENOENT: … open '<home>/.claude/projects/…'`
travelling to the browser — the operator's home path, username and project
layout. decision 5 says a filesystem key never reaches a client that cannot
need one, and a path inside an error message is the same key by another route.
both sites now send a fixed, non-interpolated phrase. the raw error is not
swallowed: it goes to `console.error` on the host, which is the side that can
act on a path.
the test asserts equality rather than containment, and that is not
fastidiousness — interpolation APPENDS, so a leaking detail still contains the
phrase. the mutation pass proved it: with `toContain` the mutant survived,
because bun's EISDIR stringifies without its path.
two more, in the same area:
- `before` is clamped to the file size. the route validates its FORMAT
(digits only), which says nothing about its range, and a cursor from a log
that has since been compacted or rotated sits past the current end — one
round trip per 512 KB of bytes that do not exist before any record is
reached. clamping costs no history, because the file's size is where a tail
read starts anyway.
- `journalMissesSeen` is bounded and clearable. it never forgot, so an agent
whose journal came back could never be reported again if it later broke a
second time — and "history silently stopped going deeper" is invisible
without that line. it also never shrank, holding one string per agent id
ever seen, and agent ids do not repeat across harness restarts. a successful
page clears the agent's entry; the set evicts fifo at 256.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`journalLines`, `journalCursor`, `journalDone` and `journalFellBack` were component state in `AgentTerminal` — which is remounted per agent and on every navigation, which is the entire reason `pane-cache.ts` exists. six taps of history were therefore thrown away the moment the operator went back to the list and reopened the pane, and the only way to see them again was six more taps and six more posts. the reconstructed path this feature replaces keeps its scrollback across exactly that journey, so this was a regression for the agents the feature was built for. all four now live in `pane-cache` as one `JournalState` keyed by agent id, and are evicted by the existing `prunePanes` so a closed pane's history does not linger. `cursor` and `done` matter as much as `lines`: without them a reopened pane re-fetches page one and prepends it to nothing. `fellBack` matters because it is the pane's permanent answer to "is there a journal here" — held in the component, every navigation asked the server again and got the same no. the component keeps a `useState` mirror purely to re-render. every write goes through `updateJournal`, which patches a function of the previous CACHED value rather than the previous rendered one, so an update applied from a promise callback that closed over an older render cannot drift from the cache. lines, cursor and done move in one write for the same reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the shape rule shipped in fd484d4 applied its opener-to-end truncation to shape-matched names as well as named ones, and an angle-bracket placeholder is ordinary developer prose. "replace <old-name> with <new-name> everywhere" became "replace ". "run `git push origin <your-branch-name>` and then open the PR" became "run `git push origin ". "if a<b_c and c>d then continue" became "if a". that is the operator's actual instruction deleted, and it is far more common than the `<router-view>` case the comment conceded. the design says plainly that over-stripping real prose is the worse of the two failures. so the two mechanisms are now asymmetric on purpose. the NAMED list may still take an opener's whole remainder, because a truncated `<result>` really does mean the rest of the record is machine output. the SHAPE rule may not: it fires only on a balanced pair or a self-closing element, since an unbalanced kebab- or snake-cased bracket in a typed message is overwhelmingly a placeholder. nothing else changes — a balanced `<some-future-hook>…</some- future-hook>` still goes, body and all. the comment is amended to state the loss rather than understate it: which case can take the remainder of a message and why that is right there, which case is balanced-only, and that a balanced quote of a real framework tag still loses that element AND everything between the tags. docs/decisions.md did not record the shape rule at all and now does, including that it is an inference rather than a contract. and one test did not guard its site. "stripping happens before the text cap" called `stripInjected` in the test body, so it exercised text.ts rather than the adapter's call ORDER — mutating claude.ts to clamp first left 53 tests green. the ordering assertion moves to the call site, with a shape chosen so the orders diverge visibly: an oversize block FIRST and the typed message after it, so clamping first cuts inside the block, takes the closing tag and the message with it, and drops the record entirely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
"Show earlier" reconstructed scrollback by diffing successive viewport snapshots.
src/web/history.ts's own header calls it a viewer, not a recorder, and the two limits that follow are structural: an agent nobody had open has no history at all, and a scroll bigger than half a screen is recorded as a gap.Asking herdr for more cannot fix it. A coding agent's pane runs on the terminal's alternate screen, which keeps nothing behind the viewport — every such pane reports
scroll.max_offset_from_bottom: 0.docs/roadmap.mdalready measured the dead end against herdr 0.8.0: ~35 ms per line past the viewport, 300 lines 10.7 s (pastHERDR_TIMEOUT_MS), and 500/1000/2000 lines each ~15.8 s while returning less thanvisiblereturns in 2 ms. The bytes were never retained.The history is on disk instead. Claude Code writes every turn to
~/.claude/projects/<mangled-cwd>/<session-uuid>.jsonl, and herdr hands us the uuid onAgentInfo.agent_session— on theagent.listcall paddock already makes, so thepane.listrule inCLAUDE.mdis untouched. Verified live against herdr 0.8.2, protocol 20.Design:
docs/design/2026-08-20-journal-history-design.md· Plan:docs/plans/2026-08-20-journal-history.md· Reasoning:docs/decisions.mddecision 18.Scope, deliberately narrow
"Show earlier" goes deeper and stops having gaps. No conversation view. Where a journal is readable it is the only source above the live screen; where one is not, nothing changes from today. One tap fetches 20 turns — a turn is several lines, so 50 would land 250+ lines at once on a phone.
What is served, and what never is
tool_resultis never served: that is where file contents, command output, and any secret the agent handled live. A tool call becomes one line (▸ Bash · run tests). Thinking blocks and subagent traffic are dropped. Harness-injected blocks (<result>,<task-notification>,<system-reminder>, …) are stripped from typed messages, because auserrecord with string content is not always a person typing. The session id — a filesystem key — never crosses the socket;Agentgains onlyhasJournal: boolean.Measurements, not assumptions
<result>bodies, zero<task-notification>/<output-file>, zero<system-reminder>. Before the fix, the same logs would have served 176 tool-result bodies and 180 harness blocks.replace <old-name> with <new-name> everywhereandrun `git push origin <your-branch-name>`come through intact — an earlier stripper truncated them toreplaceandrun `git push origin.Note on the first commit
5f79f38("a shutdown you asked for is not a tunnel that failed") is not part of this feature — it was already committed on the working tree when this branch was cut, and rides along by request.fix/quiet-requested-tunnel-stopbecomes redundant once this merges.Known and not addressed here
--demostill cannot render the terminal pane (/output,/prompt,/answerare registered only with herdr actions), a gapdocs/roadmap.mdalready tracks./historyanswers correctly there; the screenshot has to come from the static demo bundle meanwhile.Checklist
make check && make check-clean && make testpass — 1008 tests, 0 fail (861 before this branch)