Read a file the session named: the wire and the daemon - #55
Merged
Conversation
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>
This was referenced Aug 17, 2026
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.
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/filewould work perfectly on loopback and be invisible from thedevice this feature exists for.
peekis the precedent and the same reasoningproduced 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 contentstat/stats— does this batch of paths exist, one message per hovered lineread/file/eof— a ref is minted, content streams under it,eofends itcancel— abandon one~expansion and symlinkresolution, all daemon-side because only the daemon knows where a session is
spec/protocol.md, which is the contract phase 2 is written fromChunks 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.
enqueuedrops a connection that fills the outbox, which is right for producerswith 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/ploses its pipe and dies of SIGPIPE — a click on a link killing auser'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_NONBLOCKandO_NOCTTYand re-checks on the descriptor, because the pre-open stat is afilter and the descriptor is the authority.
Authority
This grants none that does not already exist. A client that can send
readcansend
spawnand runcat, and the daemon runs as the user either way, so readsare 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 ontomain.testdata/wire/control.jsongrew nine cases and is decoded by both suites, whichis 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.mdPlan:
docs/superpowers/plans/2026-08-11-file-peek-phase-1-wire-and-daemon.mdNext
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