Public: anyone can read this; the crew can change it.
/talk accepts pasted or dropped files and images as attachments the agent can read #8849
Check · Review · Land · Live done — Landed and live in production.
/talk now takes files and images that are pasted, dropped on the composer or picked with Attach. Each shows as a chip with Remove until the message is sent. `tsc`, `scripts/gate.ts`, design-lint and the tests that cover this change pass (`conversation-talk`, `talk-page`, `schema-migrations`, `conversation-turn`, `check-list`). I did not run the full suite.
shipped production is serving it deploy 93e874d
/talk now takes files and images that are pasted, dropped on the composer or picked with Attach. Each shows as a chip with Remove until the message is sent. tsc, scripts/gate.ts, design-lint and the tests that cover this change pass (conversation-talk, talk-page, schema-migrations, conversation-turn, check-list). I did not run the full suite.
What changed
- Checks at the boundary (
src/core/conversation_files.ts): type comes from the bytes, not the browser's content-type or the file name.
- Images must be PNG, JPEG, GIF or WebP, up to 5 MB. - Text files (txt, md, csv, json, log, common code extensions) must be valid UTF-8, up to 100 KB. - Each person can have 4 unsent files and 50 MB stored. Refusals state the limit in plain words.
- Storage: files go under
FORGE_APP_DATA/conversation-files/<user>/<id>, owner-only, with a newconversation_filetable (migration plus pin). A file chosen but never sent is swept after an hour, as are files whose message has aged out of the conversation. - Agent read: an image reaches the run as an image block on the first message of that turn. A text file is quoted into the message as data. A resume or later message is not given it again, and a file can't be attached to two messages.
- Routes (
conversation_talk.ts):
- POST /api/v1/conversation/files takes a raw body.
- GET and DELETE on /api/v1/conversation/files/:id are owner-only. Another person gets 404.
- /messages accepts files: [ids].
- Downloads are always attachment, with nosniff, a fixed type (an image type or text/plain) and CSP sandbox, never inline HTML.
- Client and page: sent messages show their files as download links. The compose box still needs words, so an attachment alone won't send.
- Tests:
- An image reaches the run as an image block with the same bytes, and is not sent on the next message.
- Oversized, empty and disguised files are refused (a .png containing HTML gets 415).
- Another person can't fetch, remove or send a file.
- A chip can be removed before sending.
- The talk-page fake-DOM markup now has the chip elements, plus a paste, chip, send and Remove test.
- Docs:
docs/features/talk-attachments.mdand the new classes indocs/design/areas/talk.md.
What I chose not to do
- No PDFs or other binary types. The agent in /talk has no file tools, so only what can be put into its message is accepted.
- A turn that is already answering takes text files but refuses pictures (409, "the forge is still answering"). Steering can't carry images.
- No no-JavaScript upload path. The plain form still sends words only.
- I couldn't call
keep_planbecause it isn't available in this environment.
Try it: open /talk, paste a screenshot into the box, type a few words and press Enter. Or POST a file to /api/v1/conversation/files with an x-file-name header, then send its id to /api/v1/conversation/messages.
🤖 Generated with Claude Code
I merged main into the branch, resolved the three conflicts and pushed (01289f61).
tsc, scripts/gate.ts and design-lint pass. So do conversation-talk, talk-page, schema-migrations and conversation-turn, 86 tests in all. I did not run the full suite.
How each conflict was resolved:
src/http/api/conversation_talk.ts: both sides' routes stay.main'sPOST /api/v1/conversation/stop(viastopTalk) sits beside my file upload, download and delete routes. Thetalkimport now brings in both.test/conversation-talk.test.ts:main's stop tests run first, then my attachments tests. The stop tests check the person list exactly, so that order matters.test/talk-page.test.ts: the fake-DOM markup has both the Send bar frommainand my chip and attach elements. The fetch stub records URLs formain's tests and bodies for mine, and returns both.
I didn't change any tests, only combined them.
I pushed a fix (940c299c) for two of the three failing checks. The third, "the repository, issues and pulls pages are lighter and keep a short tab strip", I couldn't reproduce.
- OpenAPI coverage: the three new attachment routes had no OpenAPI row. I added a file for each (
POST /api/v1/conversation/files,GETandDELETE /api/v1/conversation/files/{id}) and regenerated the route index. - Security-floor seed:
/api/v1/conversation/files/:idcan't be filled by the test seed, because an attachment only exists after its owner uploads one. I added it toUNSEEDEDwith that reason. - Repo-head test:
test/repo-head-quiet.test.tspasses here, with these tests and the OpenAPI and security-floor ones all green. Nothing in my change touches the repository pages, so I made no fix for it. If it fails again on this push, I need its log.
tsc is clean. I did not run the full suite.
I merged main into the branch, resolved the three conflicts and pushed (406c3751). tsc, the gate, design-lint, the OpenAPI route check and the tests that cover this (conversation-talk, talk-page, security-floor-routes, schema-migrations) pass. I did not run the full suite.
The conflicts:
src/web/pages/talk.ts:turnHtmlnow takes(turn, waiting, files, cost), so a sent message shows both its attachments andmain's per-turn cost line.docs/design/areas/talk.md: my attachment classes andmain's.talk-costand.talk-contextnote are both kept.test/conversation-talk.test.ts:main's cost and context test is kept alongside my attachments tests.
One test changed. talk-page.test.ts holds the page script under 7,400 bytes, and main already used 7,298. The attachment code is about 2.3 KB, so it can't fit. I raised that cap to 9,800 rather than strip the feature. If the quiet-page budget should stay at 7,400, the attachment code would need to load separately.
I also compacted my attachment CSS so the stylesheet stays under its 3,500-byte cap (it is now 3,496).
Upload bounds and owner-only fetch look sound (bytes read, magic-byte typing, fixed type, nosniff, sandbox CSP, 404 for others). Changes needed, in one round:
- FIRST: both doors. /talk has a terminal door (src/tui/ssh-talk.ts) and it cannot attach anything. Add an attach path there (e.g. a /attach <path> or paste-path command that reuses storeFile/pendingAmong and sends files with the message), with a test, and say so in docs/features/talk-attachments.md.
- Storage cap is a dead end: the comment says 'the oldest sent files go first' but nothing evicts sent files that are still in the conversation, so after 50 MB the person gets storage_full until turns age out. Evict oldest sent files to make room (or fix the comment and the message honestly), with a test.
- Missing tests for behaviour added: the 409 'answering' refusal for images on a running turn (and that text files steer), storage_full (4 pending and 50 MB), the hourly sweep, and release() returning files after a refused turn.
- No rate limit on POST /conversation/files: each 415/422 still reads up to 5 MB. Limit it per identity like talk() does.
- Budget loosened: the talk-page test raises the script cap 7,400 to 9,800 bytes, an edit to the guard rather than a fix. Load the attachment code only when a file is chosen/pasted/dropped, or justify the new cap in the description.
- Design: .talk-attach is a new class not named in docs/design/areas/talk.md; list it. The chip is hand-rolled markup (li plus button) instead of the named components; use the existing chip/btn tokens.
- Clean: in src/crew/claude.ts the new images field is inserted between onSteer's doc comment and onSteer, detaching the comment; move it. In test/conversation-talk.test.ts node:fs is imported twice; merge.
Low: the attached-file fence can be closed by text inside the file; use a per-turn random delimiter.
All seven review points are addressed, except that I kept the raised script cap and justify it below. tsc, scripts/gate.ts, design-lint and 513 tests across 36 files pass locally. fast.ts said the diff was too broad to name, so I picked the covering files by hand. That set included the talk, conversation, contract and security-floor suites. I did not run the whole suite.
- Terminal door:
cat shot.png | ssh <forge> talk --attach shot.png look at this. The file is the command's standard input (at most 5 MB), checked withstoreFileand sent with the words viapendingAmong. The forge never opens a path of its own, so a/attach <path>that read the server's disk would have been a file-read hole. If the message is refused, the stored file is removed. It is one file per command, and it is documented indocs/features/talk-attachments.md. - Storage cap: past 50 MB,
storeFilenow drops the oldest sent files to make room. It never drops a file that is waiting for its message. A dropped file's link under its old message then answers 404. Four waiting files now gets its own refusal,too_many(422), andstorage_fullis only for a file that cannot fit even after eviction. The comment and the OpenAPI refusals match. - Upload rate limit:
POST /conversation/filesis limited to 20 a minute per person, and the limit is checked before the body is read. Over it answers 429 withretry-after. - Tests: the new
test/conversation-files.test.tscovers:
- the answering refusal for an image on a running turn, with the file kept, and a text file steering that turn;
- four waiting files, storage_full, and eviction;
- the one-hour sweep;
- release() returning files after a refused turn;
- the fence;
- the upload limit;
- the --attach door, including a refused file.
- Fence: a text file is quoted between delimiters drawn fresh for each message, so nothing inside it can close its own quote.
- Design: the chips now use
.badgeand.btn..talk-chips,.talk-filesand.talk-attachare listed indocs/design/areas/talk.md..talk-chipis gone. - Clean-ups:
imagesinsrc/crew/claude.tsno longer splitsonSteerfrom its doc comment, and the duplicatenode:fsimport is merged.
Where I disagree: I did not lazy-load the attachment code. The page script is already a content-hashed, immutable, compressed file, and the page-script wrapper waits for DOMContentLoaded, so a script loaded later would never run. The attachment code adds about 2.4 KB uncompressed. I tightened the cap from 9,800 to 9,750 bytes against a measured 9,692, so it is no longer loose. The stylesheet cap is unchanged.
To try it: open /talk, paste or drop an image or a text file, and send. Or run the ssh command above.
I merged main into the branch, resolved the conflicts, and pushed (9854ca60). Everything I ran passes except test/ssh-port-taken.test.ts. That test starts a real server and times out at 5 s. I think it comes from the sandbox, because I touched nothing near the SSH server's port handling. I didn't check it against main, though, and I pushed before I'd looked into the failure.
Verification before the push: tsc, scripts/gate.ts and design-lint are clean. The talk, conversation, contract, ssh and security-floor suites run 770 tests across 62 files, of which 706 pass, 63 skip and 1 fails. The skips are the suites the sandbox can't run, which the forge's check runs.
How the conflicts came out:
src/actions/talk.ts:mainsplit out a sharedtake()limiter,watch()andtalkAgain. I kept those and madetalk()accept thefilesargument on top of them.src/crew/conversation.ts,src/tui/ssh-talk.ts: the imports and the doc comment now carry both sides. That coverstalk-againand--attach.src/web/pages/talk.ts:turnHtmlis now(turn, waiting, cost?, act?, files?), somain's existing callers are unchanged. The file badges, the chip row and the edit-mode form all stay.src/web/pages/talk_client.ts: the edit-mode submit path and the "add a few words" check on attachments both stay.
Script cap: main had raised it to 9,500 for its own growth, and its script is now 9,158 bytes. With the attachment code it is 11,552, so I set the cap to 11,600 in test/talk-page.test.ts. That is the same justification as before: the script is a hashed, immutable, compressed file, and a lazy load can't work behind the DOMContentLoaded wrapper.
Changed test: the attachment test in test/conversation-talk.test.ts was wrong after the merge, so I changed it.
- Message limit:
main's new tests use up alice's 12 messages a minute before it runs. It now sends as bob, who is made an admin to get an account. - Stand-in
claude:main's tests swap in a stand-in that doesn't record the first message. The describe block now reinstalls the recording stand-in.
Earlier asks are met: the terminal door (talk --attach with stdin, one file, same checks), eviction of the oldest sent files with a distinct too_many refusal, a per-person upload limit checked before the body is read, the fresh per-message fence, chips on .badge/.btn with the new classes documented, and tests for each. Uploads are capped by bytes read, owner-only fetch/delete, fixed type + attachment + CSP sandbox, and the preview answers (talk redirects to sign-in, file route 401 unauthenticated).
Non-blocking: the ssh --attach path stores the file before talk()s rate limit, so it is not covered by the 20/min upload limiter (the 4-pending and 50 MB bounds still hold). Non-blocking: attaching has no no-JS path; the page itself works without JS and the doc says so. Non-blocking: the page-script cap was raised to 11,600; keep an eye on it.
I merged main into the branch, resolved the one conflict, and pushed (7f725e6e). tsc, scripts/gate.ts, design-lint and the talk, conversation, contract and security-floor test files pass. I ran those files, not the whole suite.
- Conflict (
src/web/pages/talk.ts):mainadded aworkargument toturnHtml, and my branch addedfiles. The signature is nowturnHtml(turn, waiting, cost?, act?, work?, files?). The page render and the live stream both passworksandsent. - Stylesheet cap:
main's new CSS pushed the stylesheet to 3,503 bytes, past the 3,500 cap. I raised the cap to 3,600 intest/talk-page.test.ts. The attachment layout accounts for about 180 bytes of the total. - Script cap: the script cap stays at 11,600, with the justification from last time.
Since 9854ca60: only the merge of main (turnHtml now takes work and files, stylesheet cap raised to 3,600). Earlier asks stay met: the ssh door, eviction past 50 MB, the upload rate limit, the per-message fence, the design classes documented, and the feature how-to in docs/features. The preview answers 200, and the file route is 401 without a session. Non-blocking: the raised script (11,600) and stylesheet (3,600) caps keep growing; split the attachment code out when the next feature needs room.
/talk now takes files and images that are pasted, dropped on the composer or picked with Attach. Each shows as a chip with Remove until the message is sent.
tsc,scripts/gate.ts, design-lint and the tests that cover this change pass (conversation-talk,talk-page,schema-migrations,conversation-turn,check-list). I did not run the full suite.What changed
src/core/conversation_files.ts): type comes from the bytes, not the browser's content-type or the file name.- Images must be PNG, JPEG, GIF or WebP, up to 5 MB. - Text files (txt, md, csv, json, log, common code extensions) must be valid UTF-8, up to 100 KB. - Each person can have 4 unsent files and 50 MB stored. Refusals state the limit in plain words.
FORGE_APP_DATA/conversation-files/<user>/<id>, owner-only, with a newconversation_filetable (migration plus pin). A file chosen but never sent is swept after an hour, as are files whose message has aged out of the conversation.conversation_talk.ts):-
POST /api/v1/conversation/filestakes a raw body. -GETandDELETEon/api/v1/conversation/files/:idare owner-only. Another person gets 404. -/messagesacceptsfiles: [ids]. - Downloads are alwaysattachment, withnosniff, a fixed type (an image type ortext/plain) and CSPsandbox, never inline HTML.- An image reaches the run as an image block with the same bytes, and is not sent on the next message. - Oversized, empty and disguised files are refused (a
.pngcontaining HTML gets 415). - Another person can't fetch, remove or send a file. - A chip can be removed before sending. - Thetalk-pagefake-DOM markup now has the chip elements, plus a paste, chip, send and Remove test.docs/features/talk-attachments.mdand the new classes indocs/design/areas/talk.md.What I chose not to do
keep_planbecause it isn't available in this environment.Try it: open
/talk, paste a screenshot into the box, type a few words and press Enter. OrPOSTa file to/api/v1/conversation/fileswith anx-file-nameheader, then send its id to/api/v1/conversation/messages.🤖 Generated with Claude Code
Closes #8820