# Web App: file attachments in the composer, and `/agent` in place of the `@` mention

The composer gains a second upload entry and loses its `@` trigger. A message can now carry arbitrary files the way it already carried pasted images, and the two commands that move a conversation elsewhere — `/agent` and `/model` — stage their pick as a chip instead of acting on it, so the message that opens the new conversation is the one the user meant to write.

## File attachments

The "+" menu holds a paperclip entry directly below image upload: any file type, up to 20 files per message, 10MB each and 12MB decoded in total (an oversize pick is refused in the browser before it is read, so nothing is uploaded to earn the rejection; the server answers 413 `file_too_large` / `too_many_files` for anything that gets past that). Selected files show as removable chips beside the image thumbnails, in the order they were picked, and — like images — an attachments-only message with no text is sendable. Goal mode clears and blocks them, since the server takes text-only goal input.

The request body cap those limits sit under is now measured rather than read: it compared `content-length`, which a chunked request simply omits, so a body of any size passed. It counts bytes off the stream instead.

There is no upload endpoint: a chat that is still a draft has no Session to upload to, so the file rides the task request itself as a base64 `data:` URL, exactly like a pasted image. `TaskInputPart` gains a `{type: "file", fileName, dataUrl}` variant; the server validates it without touching the filesystem, then writes the bytes into the Session scratchpad — deleted with the Session, so cleanup stays free — and appends one `[attached file: <absolute path>]` line to the message text. The model opens the file with its ordinary file tools; the bytes never enter the conversation.

That line is appended **after** the user's text rather than prefixed as a `[tag]…[/tag]` block: `[use_skills]` and the origin blocks are parsed at the start of a message, and a second leading block would break that chain. Appending has its own rule for the same reason — `[handoff_from]` and `[model_switch_from]` are only recognized when they are the *whole* message, so a message that is nothing but one of those blocks is skipped and the lines become a message of their own. Without that, a handoff carrying attachments and no text turned its own origin block into unparseable text, and the new conversation opened with the raw marker in a user bubble instead of a banner.

The line's spelling lives in one core module shared with the existing `[attached image: …]` producer, so the two conventions cannot drift; the Web renderer pulls both back out of the body text, images becoming pictures and files an "Attached files" notice — and, like an image line, only when the path actually resolves into the Session scratchpad, so a line typed by hand stays ordinary text instead of rendering inside system chrome.

An attachment keeps the name it was given. Only ASCII outside `[A-Za-z0-9._-]` is rewritten — a space or shell metacharacter inside a path the model is about to paste into a command is a genuine footgun — while everything above ASCII survives, minus Unicode category C (invisible controls, and the bidi overrides behind file-name spoofing): `报告 2026.pdf` lands as `报告-2026.pdf` instead of an anonymous `file.pdf`. Windows hygiene comes with it — trailing dots and spaces dropped, device names prefixed (`con.txt` → `_con.txt`) — and the stem is capped by UTF-8 **bytes** on a character boundary, since a CJK character costs three of them and a character count would let a Chinese name blow the filesystem's real limit.

The scratchpad read endpoint changed to match: its traversal guard was a character whitelist, which a non-ASCII name could not pass. It now runs a structural check (no separators, no control characters, not a relative marker) and confirms it by resolving the path and requiring its parent to be exactly that Session's directory — strictly stronger than the charset was, since it also rejects the shapes a character class misses, such as a Windows drive-relative `C:evil.png`. Responses from that endpoint now carry `X-Content-Type-Options: nosniff`, because arbitrary user-uploaded bytes are served from the App's own origin.

## `/agent`, and both switches staged until send

Typing `@` no longer opens an agent picker; the leading-`@` send-time parsing is gone with it. The same handoff is now reached through `/agent`, which consumes its slash token and opens a picker built like the `/model` one — a search box matching agent id and display name, avatar rows, keyboard navigation, Escape or a click outside to dismiss. Both pickers share one panel component rather than a copy. Like `/model`, `/agent` is offered in an active session only: a draft has no conversation to hand over, and picks its Agent in the draft page's own selector.

Both switch commands stage rather than act. Picking an agent or a model sends nothing: it pins a chip above the text body, the user keeps typing, and Enter (or Send) performs the switch — the handoff into a new chat for that Agent, or the fork of this conversation onto the picked model. Only an empty composer falls back to the default auto-message; selected skills keep riding along in a `[use_skills]` block. The two chips are mutually exclusive with each other and with goal mode, either is dropped with its × or with Backspace at the start of the text, and both are cached with the draft text they belong to — so a session switch or a reload restores the intent whole instead of leaving the text without its chip.

A staged model fork additionally waits for the session to be idle, and says so above the composer: it branches a new session off a Trace that a run or a compaction is still appending to, so firing it mid-run would point the new model at a truncated conversation. Every send path treats a staged model switch the way it already treated the handoff target: it alone makes the composer sendable, it keeps the single action button on Send rather than Stop, and it blocks mid-run steering — a staged switch is not a message to the agent running here.

The `[handoff_from]` origin block told the receiving model that it had been "@-mentioned". The tag and its field lines are a persisted format that old Traces still parse through, so only the prose changed: it is now trigger-agnostic and survives the composer changing how a handoff is started.
