cmux NEXT: cmux://agent links carry a message, and a stray parameter never costs the session
Summary
Leo opened https://cmux.com/deeplink/agent?harness=claude&session=e8f63fd9-…&host=cosmic-gray&message=5cf944c9-… and the page threw the whole link away over &message=. #22940 (into main) fixes the page; this is the app side of the same link.
?message=<harness message id>is a parameter of an agent link. The chat tab the link opens scrolls that message's row into view and marks it, the way a#turn-<id>link does and through the same reveal channel (message:<id>, the page'sMESSAGE_REVEAL). The transcript replays when the tab opens, so the page waits for the row and then gives up quietly: a message this machine's transcript does not have, or a terminal tab, leaves the view exactly where it is. The id is the one acpmux reports on a message's chunks (agent_message_chunk.messageId), which is what the page already keys its rows by.- A parameter cmux cannot use never costs the link its target, and is no longer silent.
DeepLink.readreturns the names it ignored — a name no link of that kind reads, a repeated one (no single value, so neither side guesses), or a hint outside its charset — andlink.openshows them in a toast. A malformedmachine=on an agent link is one of those hints now instead of a refusal. The other kinds parse exactly as before, includingmachine=still refusing a session link. - One schema for the emitter and the parser.
schemas/deeplinks/agent-link.jsonstates the parameters, their charsets, what happens to an unknown one, and the vectors.AgentLinkSchemaTestsruns them here;web/tests/deeplink-agent.test.tsruns the same file in #22940, which adds it byte-identically, so the base merge takes it cleanly. The schema's harness charset is this app's (1–64 lowercase), and #22940 widens the page to match.
An independent review of 107839f9 found two real defects and four smaller ones; 7960c943 fixes them:
- It did not compile. The reveal called
AgentTabs.messageReveal, but the type inAgentTabs.swiftisAgentTabStore. - The reveal only worked near the tail. The transcript virtualizes its rows, so a message more than a viewport or two above the view had no element to scroll to, and the reveal gave up exactly as if the chat lacked the message — the opposite of why a message link is sent. It now asks the transcript for the row by its place in the chat, the way a running line's count already does (
showMessage→revealRow), and marks the row once it is drawn. - Folding older history kept the pre-fold
messageRows, so a link to a message from an older page found nothing; the fold's entries are kept now, under the live turn's. ?machinewith no value refused every other kind of link, which it never did before; and twomachine=on an agent link are dropped by name like any other repeated parameter.- The ignored-parameter toast is for agent links only, and only after the link opened: nightly's
stable_*_idpane links and a shared?utm_source=were reporting parameters their kinds have always ignored, and a refusal replaced the notice a frame later. - The schema now says where a message id comes from and what a link naming anything else does (an assistant message's ACP
agent_message_chunk.messageId; a prompt, a tool call, a Cloud row id or an id this transcript never saw opens the chat and leaves the view alone), carriesmachine, and states the order both sides write a query in and name ignored parameters in.
A second independent review, of 95d5c861, found one more real defect and two smaller ones; 484f7854, a6a868f8 and 6a89a84c answer them:
- A message that worked was unreachable. A tool call ends the reply it interrupts, so one harness message is several rows and only a turn's last row stays outside its "Worked for" fold. The reveal asked for the message's first row, the fold hid it, the transcript drew no such row, and the link did nothing - the view stayed at the bottom, exactly as if this session had never seen the message. That is the ordinary shape of a reply that worked ("Let me check the tests.", Bash, "All green."). The row choice moved out of
App.tsx, where nothing could test it, intomessageRevealTarget: it takes a row the transcript already draws, and when the fold hides every one of them it names that fold so the pane opens it first - the four lines a running line's count already has for a shell's row.scrollToRowmarks the copy an open fold draws of the row (<id>:fold), which is the same row to a reader. - The toast echoed the link's own text. The ignored names came straight from the URL, capped only at 200 characters in all, so
cmux://agent/claude/<a real session>?<190 characters of sentence text>=1opened the chat and then rendered attacker-chosen prose - newlines and writing-direction marks included - inside cmux's own toast chrome. It now shows them the way the page does, from the same schema: each name inside the charset a parameter name may have, 32 characters each, eight at most, and?for a name with nothing printable left. - Known, not fixed here, and in
Observed / Not runbelow: a message above the loaded history window still opens the chat and leaves the view alone, and a?message=that resolves to nothing stays silent - which is what the schema says a link naming an id this transcript never saw does.
A third independent review, of 50c29b47, found two defects worth fixing and three smaller ones; 584d39ca answers them:
- The toast could deny a parameter the link had used. The app took the page's display transform for an ignored name (cut to the charset a name may have) but not the page's collision guard, so
cmux://agent/claude/<session>?host%20=x&host=big-redopened the chathost=big-rednamed and then said cmux "ignored parameters it doesn't use: host" — contradicting the link it had just followed.DeepLink.usedParametersnames what the link carried, and the notice drops a name shown as one of those, which is whatignoredOrdersays to do; with nothing left to name there is no toast at all. - The notice ignored the rule every refusal follows. It was shown unconditionally, so a script, an agent or
cmux link openpopped a HUD in whatever window the reader was in, where a refusal from the same run shows nothing (AppActionContext.showsNotice). It is now gated on the run being this reader's and on the caller not collecting what the run said. - A pending message reveal belongs to the view, not only to the chat: a reader who moves into a subagent within the five-second wait drops the row rather than scrolling to whatever carries its id there (a subagent's rows carry ids of the same shape).
AgentLinkSchemaTestsgains two cases that pinmachineahead of the hints (?cwd=x&host=a&host=b&machine=nope→machine, host, cwd). Every Swift case before this had one ignored name, so the order the schema makes normative — and the web pins — could have moved on this side alone. They are Swift cases, not newvectorsin the schema, soweb/tests/deeplink-agent.test.tsdoes not run them; the shared vectors are unchanged.parameters(of:)is read once per link instead of once per query item.
A fourth independent review of 584d39ca passed it, with four low findings; a236dda0 fixes the three that are defects:
- The collision guard was half the page's. It covered the query parameters but not the path:
cmux://agent/claude/s1?session=x(likewise?session%20=x,?harness=y) opened the chats1and then said cmux ignoredsession— the same contradiction the previous commit removed forhost. The page never says it, becauseharnessandsessionare parameters it carries values for.usedParametersnow covers an agent link'spathParams. - A repeated stray name was said twice. The dedupe ran on the names the link wrote, where the page dedupes what it shows, so
?cwd%20=x&cwd%09=yprinted "cwd, cwd" and two unprintable names printed "?, ?". It dedupes after the display transform now, and the eight-name cap still runs last. - The palette would have shown nothing.
refusalHasCalleralso covers a reporting run, and a reporting caller can only display a reasonrefuse()reported — this notice is not one. So gating on it left Open Link… from the palette silent about a stray parameter. It gates onisCapturingRefusal, the socket and CLI runs that collect what a run said;!backgroundalready covers scripts and agents.
Known and not fixed, both deliberate:
- A pending reveal is dropped when the reader moves out of a subagent too, not only into one: the recorded view key is the subagent's. Keeping the row would mean scrolling to whatever carries its id after the view changed, and a subagent's rows carry ids of the chat's own shape, so the drop is the safe side of a five-second race. The reveal is a scroll, not a navigation; losing it costs the reader nothing they can't redo by clicking the link again.
DeepLinkNavigator.openAgenthas no app-level test, so the reveal channel (AgentTabStore.messageReveal→links.tsMESSAGE_REVEAL) is pinned on the webviews side only. The pre-existingsubagent:channel has the same shape, and a string-pin test would be the kind of unit test cmux-next asks lanes not to add; the honest fix is a boundary test of a real link in a built app, which needs a Mac.
Everything #22096 decided stays: a link opens only a tab this app already shows the session in, never a new tab, a resume or a connection, and host/name remain display hints. plans/cmux-next/links.json gains the message rows when the PR autofix regenerates it on a Mac (its inputs changed; no one hand-edits it).
Not in scope: cmux://agent links written inside Markdown or a chat reply still drop their query string (webviews/src/pages/markdown/links.ts, replyHref.ts allow no query), so a pasted message link opens the chat but not the message. Worth a follow-up.
Testing
On big-red (Linux): no Swift build here, so the Swift compile and tests are CI's.
-
Red, then green (the fold defect):
484f78542cfb8commits the three new cases with the row choice extracted but still choosing as before -bun test test/agent-pane-reveal-message.test.tsinwebviews/is 9 pass, 3 fail (the fold is not named where it must open, and the folded row is taken over the drawn one).a6a868f8d68a3is 12 pass, 0 fail, 36 expects; the cases shape their rows with the realturnView, so the fold is the one the transcript actually draws. -
Red, then green:
0f60c962commits the schema and both test files with no implementation —bun test test/agent-pane-reveal-message.test.tsinwebviews/fails withSyntaxError: Export named 'MESSAGE_REVEAL' not found, andAgentLinkSchemaTestsdoes not compile (message,DeepLink.readdo not exist).107839f9passes: 6 tests, 17 expects. -
webviews/:bun run checkon the head - clean, exit 0 ("All 2082 files are correctly formatted", "no warnings, lint errors, or type errors in 2015 files", layers, type scale, dialog keys, acpmux protocol); the three base warnings it used to carry are fixed on base.bun test test/agent-pane-reveal-message.test.ts test/transcript-long-chat-updates.test.tsx- 15 pass, 0 fail, 68 expects. -
Two more base merges,
93fb7c08and558c5e95:feat-cmux-nextreworked the chat'scountedref (it gained the Inspector, then was reformatted) right where the?message=reveal reads it, so both resolutions keep base's ref and this branch's reveal beside it.bun run checkis clean on each. Nothing else conflicted;handlers.refusal.linkParametersIgnoredis still in all 21 locales after both. -
cmux-ci compile-check --app --ref a236dda086caf0709f320368212e5da7c0afb2a4: passed (28 s) on the fleet, with the three review fixes in. -
The base merge
50c29b47is clean (no conflicts). It also cleared a base red my earlier head ran into:CmuxNextAppTestsdid not compile on base until0f6199ce(TerminalFirstNewTabTests.swift:49passed aStringwhere#expecttakes aComment?), which is what failed the fleet Swift run ofc3189dfb. -
cmux-ci compile-check --app --ref 9f4b0bb0d7bb36c547b96e7eadfb4ddcf6d125f2: passed (28 s) on the fleet (the head after it,6a89a84c, changes only#expectcomment arguments inToastCopyTests.swift). The earlier7960c943check: passed (45 s) on the fleet — the compile error the review found is gone. It compiles no tests, so the new test file's compile is CI'scmux-next swift test. -
python3 scripts/cmux-next/prepush-gate.py: no new violations../scripts/lint-ios-package-conventions.sh: no unjustified violations. -
A new test,
theParsersCharsetsAreTheSchemas, runs 25 probes (65-character harness, 201-character message, empty,..,machine_with uppercase hex, …) through both the schema's own expression and this app's validator and requires the same answer, so a bound cannot move on one side alone. -
python3 scripts/localization_catalog.py check: the newhandlers.refusal.linkParametersIgnoredis in all 21 locales ofRefusals.xcstringsand is not among the reported rows (the 41k parity errors are the base'sneeds_reviewdebt, untouched here). -
cmux-ci compile-check --app --ref 558c5e95fb280ffc5bf9bef7391d63172984560d: passed (33 s) on the fleet (jobbd9625ed6659567c8dc8aa5b), on the head with both base merges in. -
Review of the delta since the passing review (the three fixes plus both merge resolutions): PASS, safe to merge, no blockers. It hand-traced
DeepLink.readfor all three new assertions, decoded all 21 localizations ofhandlers.refusal.linkParametersIgnoredto show neither negative assertion intwoStrayNamesShownTheSameAreSaidOncecan be produced in any locale, enumerated every setter ofisCapturingRefusalandisReportingRefusalto confirm the HUD gate adds no HUD to a non-interactive path, checked the dedupe-then-cap order againstorigin/main'sdeeplink-agent.ts(the page does the same), and confirmedcounted.currentmatches base byte for byte in both the ref and its assignment. Its two residual notes are informational:truncatedLinkText's 200-character cap can cut the worst-case 8x32-character toast (pre-existing, and the schema delegates the cap), and theagentPathKeysunion is unconditional where the page suppresses on a usable value (equivalent for every reachable input, since.agentrequires both path segments to validate). -
A stale local
src/agent-session/acpmux/generated/strings.jsonmadebun run checkreport 66 bogusStringKeytype errors after the first base merge;bun run stringsregenerates it and the check is clean. No source defect, and nothing committed.
Observed / Not run
- No capture: the native half adds no surface and changes no layout — a URL reader, one hop in
DeepLinkNavigator, and one more sentence in the existing refusal HUD (its text is pinned inToastCopyTests, its 21 locales inRefusals.xcstrings). What a reader sees is the agent pane scrolling to a message and marking it, which is webview, andpr_capture_check.pyleaves webview changes to the gallery advisory run. Driving the real toast or scroll needs a Mac, and this lane runs on Linux. - Observed: the webviews reveal tests red then green, the full
webviewscheck, the prepush gate, and the localization check. - Observed: the fold cases red (9/3) then green (12/0) on the same command, and
ToastCopyTests.anIgnoredParameterNameCannotCarryTextIntoTheToastadded for the sanitized names (CI's lane runs it; nothing Swift runs on this Linux host). - Not run here, CI's: the three review fixes each come with a Swift case —
theNamesOfAnAgentLinksPathAreNeverSaidToBeIgnored(an agent link's?session=x&harness=y&cwd=zis ignored assession, harness, cwdyetusedParameterscovers the first two, and another kind's?session=xis still a stray name) andtwoStrayNamesShownTheSameAreSaidOnce(cwdandcwd\tsaycwdonce; two unprintable names say?once). They compile on the fleet check above but run incmux-next swift test, since no Swift runs on this host. The palette gate change has no test: what it turns on is a HUD in a built app. - Not covered:
DeepLinkNavigator.openAgentitself has no test (an app-level test needs the App target's fakes, and nothing can be built here); the reveal it drives is covered inwebviews, and the parser inAgentLinkSchemaTests. - Scope: the reveal looks in the transcript the pane has loaded. Attach pages in the chat's tail (
direct.tsasks for 400 rows) and older history pages in on demand, so a link naming a message above the loaded window opens the chat and leaves the view where it is, like an id this machine never saw. The obvious follow-up is to page older history in while a message reveal is pending (loadOlder, which thefoldOldermerge fix here makes safe). - Scope: nothing in the app writes a message link yet; the only producer is cmux.com (#22940), and the two agree on the id space through this schema rather than through an end-to-end test.
- Not run:
swift build/swift testand any Mac work (agents never build the app on Leo's Macs; this host is Linux) — soAgentLinkSchemaTests,DeepLinkAgentTestsandLinkExportTestsare CI's compile and test lanes. No dogfood run of the real toast or scroll in a built app.
Changelog
Added: cmux://agent links can name one message, and the chat scrolls to it
Fixed: a link with a parameter cmux doesn't use opens anyway and says what it ignored
Agent session: big-red
🤖 Generated with Claude Code
Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
cmux://agent links can now carry ?message=<harness message id>, and the chat tab they open scrolls to and highlights that message. A parameter cmux can't use no longer drops the link: the link opens, and a toast names what was ignored.
- The
messageparameter rides the same reveal channel as#turn-<id>links: the reveal finds its row by its place in the chat, waits for the transcript to draw it, and lets go after a moment, so it works however far up the chat the message sits and never fights a Find reveal or a later link. A row still waiting to be drawn is dropped if the pane switches chats, since row ids restart per session. schemas/deeplinks/agent-link.jsonis the single schema for these links, shared with cmux.com's deeplink page so the emitter and the parser can't drift; tests hold every charset probe for probe and run the schema's vectors, including the order both sides write queries and name ignored parameters in.- On an agent link a malformed, repeated, or valueless
machine=is ignored and named rather than refused; other link kinds keep their old reading, a valueless one refusing nothing. The ignored-parameter toast is agent links only, shown only after the link opens.
Testing updates
AgentLinkSchemaTestsdid not build: a nestedtry #require(URL(...))(recursive macro expansion) and aStringwhere#expecttakes aComment?. URLs are their ownletnow and the subject is interpolated, socmux-next fast tierandcmux-next generated filesare green again.plans/cmux-next/links.jsonis regenerated (autofix script, not hand-edited) with themessageparameter's example and schema pointer; the same run's other regenerated files carry only other lanes' drift and stay out of this branch.- The new webviews test file shifted CI's deterministic shards, moving
composer-row-layout.test.tsonto one it timed out on; it now names its own 300 s time limit like its siblings.