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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

/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

opened this

/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 new conversation_file table (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.md and the new classes in docs/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_plan because 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

Closes #8820

commented

/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 new conversation_file table (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.md and the new classes in docs/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_plan because 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

commented

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's POST /api/v1/conversation/stop (via stopTalk) sits beside my file upload, download and delete routes. The talk import 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 from main and my chip and attach elements. The fetch stub records URLs for main's tests and bodies for mine, and returns both.

I didn't change any tests, only combined them.

commented

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, GET and DELETE /api/v1/conversation/files/{id}) and regenerated the route index.
  • Security-floor seed: /api/v1/conversation/files/:id can't be filled by the test seed, because an attachment only exists after its owner uploads one. I added it to UNSEEDED with that reason.
  • Repo-head test: test/repo-head-quiet.test.ts passes 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.

commented

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: turnHtml now takes (turn, waiting, files, cost), so a sent message shows both its attachments and main's per-turn cost line.
  • docs/design/areas/talk.md: my attachment classes and main's .talk-cost and .talk-context note 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).

Changes requested — before the latest push

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:

  1. 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.
  2. 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.
  3. 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.
  4. No rate limit on POST /conversation/files: each 415/422 still reads up to 5 MB. Limit it per identity like talk() does.
  5. 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.
  6. 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.
  7. 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.

commented

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 with storeFile and sent with the words via pendingAmong. 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 in docs/features/talk-attachments.md.
  • Storage cap: past 50 MB, storeFile now 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), and storage_full is only for a file that cannot fit even after eviction. The comment and the OpenAPI refusals match.
  • Upload rate limit: POST /conversation/files is limited to 20 a minute per person, and the limit is checked before the body is read. Over it answers 429 with retry-after.
  • Tests: the new test/conversation-files.test.ts covers:

- 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 .badge and .btn. .talk-chips, .talk-files and .talk-attach are listed in docs/design/areas/talk.md. .talk-chip is gone.
  • Clean-ups: images in src/crew/claude.ts no longer splits onSteer from its doc comment, and the duplicate node:fs import 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.

commented

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: main split out a shared take() limiter, watch() and talkAgain. I kept those and made talk() accept the files argument on top of them.
  • src/crew/conversation.ts, src/tui/ssh-talk.ts: the imports and the doc comment now carry both sides. That covers talk-again and --attach.
  • src/web/pages/talk.ts: turnHtml is now (turn, waiting, cost?, act?, files?), so main'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.
Approved — before the latest push

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.

commented

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): main added a work argument to turnHtml, and my branch added files. The signature is now turnHtml(turn, waiting, cost?, act?, work?, files?). The page render and the live stream both pass works and sent.
  • 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 in test/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.
Approved

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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs