Skip to content

feat: history from the agent's own log, not a guess at its screen - #9

Merged
lntvan166 merged 22 commits into
mainfrom
feat/journal-history
Aug 20, 2026
Merged

feat: history from the agent's own log, not a guess at its screen#9
lntvan166 merged 22 commits into
mainfrom
feat/journal-history

Conversation

@lntvan166

Copy link
Copy Markdown
Owner

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.md already measured the dead end against herdr 0.8.0: ~35 ms per line past the viewport, 300 lines 10.7 s (past HERDR_TIMEOUT_MS), and 500/1000/2000 lines each ~15.8 s while returning less than visible returns 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 on AgentInfo.agent_session — on the agent.list call paddock already makes, so the pane.list rule in CLAUDE.md is 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.md decision 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_result is 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 a user record with string content is not always a person typing. The session id — a filesystem key — never crosses the socket; Agent gains only hasJournal: boolean.

Measurements, not assumptions

  • Paging is lossless and non-overlapping, verified through the reader against real logs: a 37.1 MB log paged in 458 pages at limit 7 and 96 at limit 50, both byte-identical to a single whole-file parse (17,179 lines); likewise 5.4 MB (3,601) and 1.5 MB (531). ~74 chunk boundaries crossed, no turn lost or duplicated.
  • Exposure closed, measured across the four largest real logs (5,573 entries, 584 user turns): zero <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.
  • Typed prose survives: replace <old-name> with <new-name> everywhere and run `git push origin <your-branch-name>` come through intact — an earlier stripper truncated them to replace and run `git push origin.
  • Guards exercised live: bad cursor 400, unknown agent 404, cross-origin 403 (decision 17's gate covers the new POST automatically).

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-stop becomes redundant once this merges.

Known and not addressed here

  • The first tap duplicates the live screen. Page one anchors at the file's end, so the newest turns are what the pane already shows, rendered as prose above it. A consequence of the deliberate "one continuous scroll, no divider" choice; worth revisiting with a labelled divider.
  • A phone check is outstanding — verified in a desktop browser only.
  • CLI --demo still cannot render the terminal pane (/output, /prompt, /answer are registered only with herdr actions), a gap docs/roadmap.md already tracks. /history answers correctly there; the screenshot has to come from the static demo bundle meanwhile.

Checklist

  • make check && make check-clean && make test pass — 1008 tests, 0 fail (861 before this branch)
  • If this adds a test: I broke the thing it guards and watched it fail — mutation passes on every guard that matters: path containment, the uuid gate, menu stripping, the tool-result exclusion, the injected-block stripper, and the strip-before-cap call order
  • If this touches measurement or performance claims: the number is in the commit message
  • No hostnames, home paths, usernames, employer terms or real agent names — including in comments and commit messages; all fixtures and demo content invented

lntvan166 and others added 22 commits August 20, 2026 20:42
… 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>
Copilot AI lite review requested due to automatic review settings August 20, 2026 17:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@lntvan166
lntvan166 merged commit 1d807d1 into main Aug 20, 2026
1 check passed
@lntvan166
lntvan166 deleted the feat/journal-history branch August 20, 2026 17:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants