Skip to content

Read a file the session named: the wire and the daemon - #55

Merged
karngyan merged 23 commits into
mainfrom
feat/file-peek
Aug 11, 2026
Merged

Read a file the session named: the wire and the daemon#55
karngyan merged 23 commits into
mainfrom
feat/file-peek

Conversation

@karngyan

Copy link
Copy Markdown
Contributor

Phase 1 of "click a path in terminal output, read the file". The daemon and the
wire only: no UI yet, and nothing here is reachable from a browser until phase 2
puts a client in front of it.

An agent session names files constantly, and reading one means leaving the
terminal. On a phone over the relay there is no window to leave to.

Why this is a wire message and not an endpoint

The relay forwards exactly one piece of HTTP, and it is pairing. A
GET /api/file would work perfectly on loopback and be invisible from the
device this feature exists for. peek is the precedent and the same reasoning
produced it.

Two numbers follow from that and shape everything: a WebSocket message is capped
at 1 MiB, and file bytes share one Noise channel with terminal output.

What lands

  • 0x02, a third binary frame type, for file content
  • stat/stats — does this batch of paths exist, one message per hovered line
  • read/file/eof — a ref is minted, content streams under it, eof ends it
  • cancel — abandon one
  • Path resolution against the session's live cwd, with ~ expansion and symlink
    resolution, all daemon-side because only the daemon knows where a session is
  • Classification from content rather than extension; text and images only
  • spec/protocol.md, which is the contract phase 2 is written from

Chunks are 32 KiB with one in flight. That bounds queued memory, keeps a read
from ever filling the 256-frame outbox by itself, and keeps a keystroke from
queueing behind more than one frame.

Two things worth reading the code for

The pump waits for room; it never treats a full outbox as the client's fault.
enqueue drops a connection that fills the outbox, which is right for producers
with nowhere to wait. A file read is the one producer that has somewhere. Without
this, opening a file during a burst of agent output is an intermittent
disconnect that blames the read.

Non-regular files are refused from a stat, before anything opens them.
Opening a fifo unparks whatever is blocked on the other end, so a shell running
cmd > /tmp/p loses its pipe and dies of SIGPIPE — a click on a link killing a
user's process. A serial device asserts DTR. A tty can become this daemon's
controlling terminal, and a session leader with none of its own is exactly the
process shape that picks one up. The open still carries O_NONBLOCK and
O_NOCTTY and re-checks on the descriptor, because the pre-open stat is a
filter and the descriptor is the authority.

Authority

This grants none that does not already exist. A client that can send read can
send spawn and run cat, and the daemon runs as the user either way, so reads
are unfenced by decision rather than omission. Nothing here writes.

Tests

make test-go, go test -race ./internal/daemon/, pnpm vitest run
(1281 passing) and pnpm run lint, all green after the rebase onto main.

testdata/wire/control.json grew nine cases and is decoded by both suites, which
is the only thing that catches a field name or a discriminator renamed
symmetrically in Go — a round-trip test cannot, since one struct tag governs both
directions.

Design: docs/superpowers/specs/2026-08-11-file-peek-design.md
Plan: docs/superpowers/plans/2026-08-11-file-peek-phase-1-wire-and-daemon.md

Next

Phase 2 is the browser: client methods, the link provider with wrapped-line
handling, and a plain-text viewer. Phase 3 adds Shiki, images and the cache.

🤖 Generated with Claude Code

karngyan and others added 23 commits August 11, 2026 19:25
An agent session names files constantly and reading one means leaving the
terminal. On a phone over the relay there is no window to leave to.

The design: paths in terminal output verify on hover and open in a modal.
It rides the wire protocol rather than a new HTTP endpoint, because the
relay forwards exactly one piece of HTTP and it is pairing, so an
/api/file would work on loopback and be invisible from the device the
feature exists for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine tasks, each with its failing test first: the 0x02 frame type, the six
control messages, the shared conformance fixture and its TypeScript half,
path resolution, stat, content classification, the paced chunk pump, cancel
and teardown, and spec/protocol.md.

The pump's pacing is the part worth reading twice. The outbox drops a
connection at 256 queued frames and an 8 MiB file is exactly 256 chunks, so
one chunk in flight is what keeps a read from killing the connection it is
answering.

Also records two error codes the design named in prose but left out of its
list: busy and unsupported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pacing on its own only stops the pump outrunning the writer. It does not
stop the other case: the outbox is full because the terminal is busy, the
pump's enqueue hits the backlog branch, and the connection dies. Opening a
file during a burst of agent output would have been an intermittent
disconnect that blamed the read.

The pump now waits for room instead, through enqueueWait. It is the one
producer with somewhere to wait, and the wait is bounded by the writer's own
timeout rather than by a timeout of its own. Two unit tests pin it: waiting
does not fail the connection, and the wait ends when the connection does.

Also adds a WaitGroup so no pump outlives serve, and records why closing a
file under a running read is safe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
File content rides the binary half of the protocol rather than a base64
field on a control message. A 32 KiB chunk costs 43 KiB once base64 has
had its way with it inside JSON, and the client already has a decoder for
the [type][ref][payload] layout, so a third type byte is the whole cost.

FrameFile is daemon to client only. Nothing here reads a file the client
sends, so the direction is a property of the frame, not something a later
check has to remember. DecodeBinary admits it by name, which keeps the
type byte a closed set: a frame nobody defined still fails to decode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 1's implementer found protocol.ts hand-mirrors the frame bytes and
decodeBinary guards on a closed set of two, so it throws on 0x02. Nothing
sends one until phase 2, but the constant belongs with the rest of the
TypeScript mirror. Nothing cross-checks the two languages' frame bytes, so
both suites now pin the literal.

Also: a bare go test ./... cannot compile in a fresh worktree, because
web/embed.go and relay/embed.go embed gitignored dist dirs. make test-go
builds them first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six control messages for reading a file a session names: stat asks whether
paths exist and stats answers one entry per path in order, read starts a
stream and file states its terms, eof ends it and cancel abandons it.

Stat is plural because the caller is: a hovered terminal line carries
several path candidates, and one message per candidate is one round trip
per candidate. PathEntry.Path echoes what was asked rather than what it
resolved to, so a client can match answers to the text it matched them
from; File.Path is the resolved path instead, because a symlink means the
file you get is not always the one you clicked.

Nothing consumes these yet. The daemon handlers come later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The stats decode path was covered by nothing. The empty-list test only
encodes, and the round-trip table skipped Stats, so the discriminator case
and the deref case — the two sites that fail at runtime rather than at
compile time — were exercised by no test, and PathEntry by none at all.

A populated Stats round-trips cleanly; only the nil-Entries one cannot,
because MarshalJSON normalises it to [] and it decodes back non-nil. The
earlier omission treated that asymmetry as if it ruled out the type.

Verified by mutation: a typo'd discriminator, a deleted deref case and a
field dropped from the wire all fail this test now. A tag renamed on both
sides still passes, which is what the golden fixture is for, so the comment
claims field survival rather than tag spelling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fixture is the only thing that catches a field name or discriminator
renamed symmetrically: one Go struct tag governs both encode and decode,
so a round trip stays green while the daemon speaks a dialect no browser
parses. Eight cases now write the spelling down outside the Go types, and
the TypeScript half annotates each one with its interface, so a rename on
that side fails to compile.

The frame byte gets the same treatment by hand. protocol.ts mirrors it
and nothing cross-checks the two languages, so decodeBinary threw on a
0x02 frame; it now admits one, and both suites assert the literal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
stat resolves each path against the session's live cwd and answers with
one entry per path, in the order asked. A path that is missing,
unresolvable, or under a directory this user cannot search is exists
false rather than an error: "no" is the ordinary answer here, and the
hover that asked simply does not underline. The entry echoes the text
that was asked about, because the client cannot reproduce the daemon's
resolution and needs to match answers to the candidates it sent.

Only an unknown session and a batch over maxStatPaths are errors, both
correlated by reqId. The arm reads Info().Cwd and nothing else off the
session, so a hover never moves LastActive and never reorders the list
under the reader's pointer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 5's review found the invariant stated everywhere and tested nowhere,
while peek has had a test for it since the CI flake that produced the quiet
helper. The sessions list orders by lastActive, so either verb touching it
reorders the list under a resting pointer, with no error anywhere to trace.

Task 9 gains the test for both verbs at once. It passes without any
production change; it exists so a later touch copied into either arm is
caught.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A read opens the file, mints a ref from the same counter attachments use,
answers with file, and streams the content on 0x02 frames until eof.

The pump keeps one chunk in flight: it enqueues, waits for the writer to
have written that chunk, and only then reads the next. An 8 MiB file is
256 chunks, which is exactly outboxDepth, so a pump that queued as fast
as it read would fill the outbox by itself and drop the connection it was
answering.

And it waits for room rather than treating a full outbox as a fault. The
outbox fills for reasons that have nothing to do with the read — an agent
session redrawing at speed — and enqueue's verdict there would turn
opening a file during a burst of output into an intermittent disconnect
whose stack trace named the read. A pump is the one producer with
somewhere to wait, and the wait needs no timeout: a peer that has really
stopped reading stalls the writer, which trips writeTimeout, which
cancels the context the wait selects on.

closeAll ends every read and then waits for every pump, so no goroutine
holding a file descriptor outlives serve.

The test client's read limit goes up to the daemon's own. coder/websocket
defaults a client to 32 KiB, which is under a chunk plus its five-byte
header and under what any real peer enforces: a browser has no limit and
the relay leg allows 2 MiB. The harness was refusing frames every real
client accepts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g pump

Two ways the pump could still be dropped, or fail to stop, on a busy
terminal.

The eof went out through sendControl, which is enqueue, which drops a
client on a full outbox. So a read could wait politely for room across
all 256 chunks of an 8 MiB file and then lose the connection on the one
frame that says the file is finished, because the session refilled the
slot the writer had just freed. sendControlWait puts that frame on the
same waiting path every chunk already took. The file frame answering the
read stays on enqueue: it is sent on the read loop, where a full outbox
does mean the client stopped draining.

And the wait for room could not see the read ending. sendChunk waits
twice, and only the second wait watched r.done, so with a full outbox a
cancel would close the file and return while the pump stayed parked
holding an encoded chunk — delivered under a ref the client had already
torn down, as soon as a slot freed. closeAll had the same hole from the
other side: its wait was finite only because serve cancels the context on
the line above, which is a correctness property resting on the order of
two lines in a defer. enqueueWaitFor takes the producer's own end signal,
so endRead now reaches both waits and closeAll stands on its own.

fileRead drops its sync.Once. endRead's delete under c.mu already elects
one caller per ref, so the Once guarded nothing and implied a second
releaser existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A cancel goes through endRead rather than releasing the read itself, so the
single-owner election that lets release be a bare close still holds: a second
cancel, or one that crosses the pump's own endRead, would otherwise close a
closed channel.

A ref this connection does not hold is ignored, not refused. A read finishing
and a client cancelling it cross on the wire routinely, and a race the client
cannot avoid must not be an error it has to handle.

The comment on the arm states the fact a client has to live with: a chunk the
outbox has already accepted still goes out, because nothing can un-queue a frame
the writer is about to take. So a 0x02 frame, or an eof, can arrive for a ref
the client has cancelled, and must be discarded rather than treated as a
protocol violation.

The cap is pinned twice. Over the wire, where three reads race two pumps, and
again against startRead directly with two reads already held, which is the
version that says which rule broke when it breaks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uld transcribe

A cancel stops a stream but cannot un-send one, and the pump parks in
selects that may take the outbox arm even after the read was cancelled, so
a chunk or an eof can be queued after the cancel was handled. A client
discards by ref. That is a wire rule the browser has to implement, and it
existed only as a Go comment.

Three comments contradicted it, all written verbatim from this plan, and
task 9 transcribes comments into spec/protocol.md. Step 3a corrects them
first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three comments claimed more than the code does, and the spec was about to
inherit all three.

The cancel arm said "the chunk already in the pump's hands", singular. endRead
releases the read before it closes the file, and the pump's wait for the writer
offers the acknowledgement on one arm and the cancelled read on the other, so a
pump that takes the acknowledgement can loop round, read again from a file whose
close has not landed, and queue a further chunk. A client author reading the
singular could reasonably tolerate exactly one late frame.

TestCancelStopsAReadAndReleasesItsRef claimed to prove no eof arrives for a
cancelled read. Its body never asserted that, and per the same arm an eof under
a cancelled ref is legal to receive: the "was this cancelled" check is
check-then-act ahead of the same kind of select. The comment now says the slot
is the observable half, and that the absence of a late eof or chunk is
deliberately not asserted, because it is a race rather than a rule.

TestClosingAConnectionEndsItsReads claimed to assert the pump ended rather than
the slot. It asserts neither: the cap is per connection, so a fresh connection
reading successfully holds even if the dropped one leaked both. The assertion is
liveness, and the property the test is named for is carried by the race build
walking the CloseNow teardown.

Finally, stat and read are pinned as not activity in a session, the gap the
review of task 5 named and left here. The sessions list orders by lastActive, so
either verb touching it would reorder the list under the pointer resting on it.
Only two places stamp it, Session.Write and the pty read loop, so what the test
catches is either arm reaching one of them. It reuses quiet from peek_test.go
rather than sampling the stamp, because the stamp moves on every chunk read back
from the pty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The protocol spec is the contract rather than a description of one, so a message
that is not in it does not exist as far as the next reader is concerned. Phase 2
writes the browser client against this file.

The three verbs and their three answers go into the tables, 0x02 goes into the
binary frame list with the note that its ref is a read's handle rather than an
attachment's, and a new section beside Previews carries the rules a client
cannot infer: who resolves a path and how, that stats echoes the text it was
asked about in order while file reports what was actually opened, the 32 KiB
chunk with one in flight, the 8 MiB text cap that truncates and the 4 MiB image
cap that refuses, two reads per connection, and the seven refusal codes.

The rule that only existed in a Go comment is the reason this matters most: a
cancel stops a stream but cannot un-send one, so a client must discard 0x02
frames and an eof naming a ref it has already cancelled. Written down here it
lands in the client the first time; left in the daemon it would have been found
in a browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…el is silent

Review of the Reading files section, all five findings against code that already
exists.

PathEntry.Size and PathEntry.Mtime carry omitempty, unlike the deviceList
timestamps the entry sentence was modelled on, so an empty file arrives with no
size key at all. A client whose type declares size required reads undefined off
an ordinary empty file, which is why absent-means-zero is now stated on the wire
rather than left to be discovered.

The 32-path cap is a refusal with bad_path, not a clamp, and the section
immediately above says the opposite rule for peek's bytes: clamped rather than
refused. A reader who took the analogy home would batch forty candidates off a
long wrapped line and underline none of them. The contrast is now drawn
explicitly, with the reason a clamp cannot work here: a client cannot tell which
of its candidates went unanswered.

Symlinks were documented in the wire types and in neither place a client author
reads. Both verbs follow them, stat describes the target and read opens it, and
file.path is what makes a viewer able to say the file on screen is not the path
that was clicked.

eof said "that read has sent every byte". The pump breaks out of its loop on any
read error and sends eof anyway, so a partial delivery looks exactly like a
complete one. It now says the stream ended, and points a client at counting
bytes against file.size.

And a cancel is answered by nothing at all, on success as much as for a ref the
daemon never held. Alongside it, the case a client hits most: a viewer closed
before file arrives has no ref yet, so it holds the reqId as abandoned and
cancels when the ref lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects the last round introduced, all prose.

The eof paragraph told a client to compare the bytes it received against
file.size. That check misfires on exactly the files this feature exists to open:
file.size is a snapshot from the stat before the stream starts, and the pump
reads to the end of the file or to the cap rather than to that number, so a log
or an agent transcript still being appended to delivers more bytes than
file.size and one truncated on disk delivers fewer. A client implementing the
old sentence literally would report a protocol error on a healthy read of a live
file. It is a heuristic worth showing a reader, and the paragraph now says so.

The reason given for refusing rather than clamping an oversized stat was not
true of this message: entries echo the requested text and come back in order, so
a client could work out which candidates a clamp had dropped. The rule was
right, only the justification overstated. The real difference is who chose the
number, and that 32 is already far more than a hovered line offers.

And "nothing answers a cancel, not on success, where the stream stopping is the
whole of the reply" read as a promise that nothing further arrives under a
cancelled ref, which the paragraph two below contradicts and the code with it. It
now points forward: the stream stops rather than stops dead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pre-open stat was never a guard. A path replaced between the stat and
os.Open lands the open on a fifo, which parks in the kernel until a writer
arrives. That open runs on the connection's read loop, so the connection
answers nothing else, never reaches its own teardown, and Shutdown cancels a
context nothing in that path waits on. The open now carries O_NONBLOCK and the
type checks run on the descriptor's own Stat, which closes the window rather
than narrowing it. The codes a client sees are unchanged, including is_dir for
a directory this user may not read.

Four branches had no coverage:

  - The frame type and the ref file content arrives under. readUntil discards
    both, so a daemon emitting file bytes as terminal output passed the whole
    suite. readUntilFrames keeps the header, beside readUntil rather than in
    place of it, because ninety-odd call sites want the payload alone.
  - The image cap. Both sides of it, one byte apart, so the test pins where
    the line is.
  - Exactly maxStatPaths, so turning the ceiling's > into a >= is caught.
  - The busy refusal closing its own descriptor. heldRead also closes the
    descriptors its callers never ended.

statEntry's comment said a caller learns whether a path is readable. It learns
whether the path is there: a file with no permissions at all stats perfectly
well and refuses on the click that follows.

enqueueWait had no production caller, so it folds into enqueueWaitFor, which
keeps its rationale. The two tests that used it pass a nil done.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… prose

The control fixture pinned only kind "file" and kind "text", so the image arm
of FileMsg and the dir and other arms of PathEntry were declared in TypeScript
and proven by nothing. A fileImage case and two more stats entries close that
at both ends of the shared fixture.

protocol.md pointed at "the two paragraphs below" where three follow, and the
pair it meant was not the pair that reading finds. It now names the bolded
sentence. Its promise that an image is refused past 4 MiB was unqualified, but
the size it measures is a snapshot taken before the stream starts, so a file
that crosses the cap while it is being read is sent up to the cap and arrives
with truncated unset. That is the disclaimer eof already carries, applied
where it was missing.

The design doc's exchange diagram said lstat. Both verbs follow symlinks, and
the spec is the side that is right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ens it

The previous commit made the non-blocking open the only check, and that was
wrong twice.

A socket stats as kind "other", so a client may underline it, but open(2) on
one fails with EOPNOTSUPP on Darwin and ENXIO on Linux. Neither is a
permission error, so the read answered not_found: the same daemon that had
just said the path exists then said it was not there. Any device whose open
fails had the same problem. unsupported is what the protocol promises for
anything that is not a regular file.

Worse, refusing these required touching them, and an open is an act rather
than an observation. Opening a fifo releases whatever is parked in open() on
the other end, so a shell running `cmd > /tmp/p` gets a reader that vanishes
and dies of SIGPIPE. Clicking an underlined path would kill a user's process.
A serial device asserts DTR when opened, and a session leader with no
controlling terminal, which this daemon is, acquires one by opening a tty.

So the pre-open stat comes back as a filter and the open stays as a guard.
The filter decides from the path and keeps the daemon from touching anything
that is not a regular file, restoring every error code. The open cannot trust
it, because the name can be replaced underneath, so it keeps O_NONBLOCK and
takes its type checks and its size from the descriptor's own Stat. Neither
half is redundant and the comments say which is which. O_NOCTTY is added
while here: free, and the tty case is the one where the window costs the
whole daemon.

The fifo test now asserts that a writer parked on the other end is still
parked after the refusal, which pins that the fifo was never opened rather
than merely refused. A unix socket case asserts the stat and the read agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@karngyan
karngyan merged commit 8503660 into main Aug 11, 2026
1 check passed
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.

1 participant