cmux notifications: resolve the setup and give TurnCompleted a consumer - #121
Merged
Conversation
…r one cmux exports `CMUX_SOCKET_PATH`, `CMUX_SURFACE_ID`, `CMUX_WORKSPACE_ID` and `CMUX` into every process it spawns, and its daemon answers JSON-RPC on the socket — so a run that finishes in a terminal nobody has looked at for ten minutes could say so. Nothing in smith knew any of it. This adds the deciding half and stops at the wire on purpose. `NotifyConfig` is the resolved answer: what is enabled, which socket, which surface, which workspace, how long to wait. `socket?` and `deliverable?` separate the two questions a caller is tempted to confuse — a socket was found, and everything needed to use one was. `CmuxClient` owns the two tiers and every rule about which one wins. The environment wins because it describes the terminal that is open now, where a config file describes one somebody had open when they wrote it. A falsey value is an absent value by one rule rather than by four: `""`, `0`, `false`, `no` and `off` each let the tier below speak, so `export CMUX_SOCKET=` and no export at all are the same statement. `CMUX` is a flag first and a path second — read as a location only when it holds a `/`, because treating `1` as a socket path would connect to a file called `1`. `Config#notify` reads the `[notify]` section and stops there. It does not reach for `ENV`, so which tier a value came from stays something you can ask; the merge is one call away. `Notify` decides that a notification should go out and what it says. It carries a `CmuxClientable` and never asks which one, so no caller branches on "am I inside cmux?" — the same line of code does nothing when there is nowhere to deliver to. A blank `body` is left out rather than sent, because an empty one renders as a gap. Extras win over the fields `notify` fills in, so a caller can send something other than a `notification` without rebuilding the payload. `deliver` swallows everything a client throws. A notification is the last thing a run does: a socket that vanished between the availability check and the write, or a cmux killed mid-run, must not become the reason a finished turn is reported as a failed one. `CmuxClient.client` returns the null client even for a complete config, and the spec that says so is written to fail loudly when a real one arrives. Until the protocol is settled — `notification.create_for_caller` against the older verbs, framing, deadline — nothing claims a notification was delivered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…never sets The resolution switched notifications on by reading `CMUX` truthy. cmux does not export `CMUX` — checked against its own environment and its own docs, which name `CMUX_SOCKET_PATH`, `CMUX_SURFACE_ID`, `CMUX_WORKSPACE_ID`, `CMUX_TAB_ID` and `CMUX_PANEL_ID`. So the branch that decided "you are inside cmux" never fired, and notifications were unreachable without a config file that asked for them by hand. What does say it is the socket path: cmux exports one into every process it spawns, so its presence is the announcement that this session is running inside a cmux terminal, which is the situation a completion notification exists for. The default is now on inside cmux and off everywhere else, so a plain terminal run is unchanged by this feature and a cmux one is not. Which makes `enabled` tri-state, and that is the part the shape had to change for. `false` is somebody turning notifications off and must win over the terminal; `nil` is nobody having said, which is the only answer the terminal may overrule. A config file that never mentions `[notify]` was being read as one that refused it. `||` was not enough to tell the two apart — it collapses `false` into `nil` and the specs caught an explicit `enabled = false` being switched back on by the socket beside it. Two further corrections, both from reading the environment rather than guessing at it: - `CMUX_SOCKET` is exported *empty* alongside a populated `CMUX_SOCKET_PATH`. A resolution reading the first variable that is set rather than the first value that says something finds no socket at all — and now concludes the session is not inside cmux, since the socket is what says so. - `CMUX_TAB_ID`/`CMUX_PANEL_ID` carry the same two ids as `CMUX_WORKSPACE_ID`/`CMUX_SURFACE_ID`, verified equal in the same shell. Both pairs are read, so neither spelling is assumed. `resolve` and `build` also default to a snapshot of the environment rather than to `ENV` itself, which is not a `Hash`. Nothing called them yet, so a default argument that could not typecheck was never compiled against one; the next commit does, and this makes it able to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TurnCompleted now has a consumer: a listener that collects what the run said and, when the turn is over, hands it to Smith::Notify as the three fields cmux documents — title, subtitle, body. Subtitle names the project the session belongs to and its name where it has one, which is what tells two smith runs apart from another tab. A listener of its own rather than a branch in the renderer: which renderer is showing the run is irrelevant to whether a notification should go out — a --json caller and a fullscreen one are equally served — and a renderer owns the run's exit code and failure state, which this has no business deciding. It is attached in build_agent, the one place all five routes to a main-thread agent pass through, so the plain loop, the fullscreen one, `run`, `resume` and a mid-session `/resume` all notify without any of them knowing. Subagents are left out, and not by a check for one: children are built by Subagents::Supervisor with their own Agent and their own listener, so they never meet this one. That is also the right answer — cmux agrees, to the extent that it suppresses subagent completions of its own agents — though its CMUX_SUPPRESS_SUBAGENT_NOTIFICATIONS governs events cmux derives from the wrappers it installs for Claude Code and Codex, not anything posted over the socket, so smith cannot lean on it here. Two things the specs found while writing them: - Text collected for a turn that ended in failure — TurnError, BudgetExceeded, ContextExhausted, none of which is followed by TurnCompleted — was left standing and prefixed to the next turn's answer. A session of two turns notified the second body with the first run's half-answer glued in front. Reproduced and asserted for all three. - The body was not cleared after sending, so a session of four turns sent the fourth one holding all four. The body is collapsed to one line and cut at a word boundary with an ellipsis, counting characters rather than bytes so German text cannot be split mid-character. MAX_BODY is smith's own choice: cmux documents no length on any of the three fields. It fires on every turn and does not guess whether anybody is watching, because cmux already withdraws the banner of a workspace that has become visible — the focus decision belongs to the terminal, which is the one thing this cannot see from inside. Still delivers nothing, which is the point of stopping here: CmuxClient.client returns NullCmuxClient until the socket is spoken to (#120). The wiring is complete and every notification is dropped at that seam, asserted rather than assumed, so build_agent can attach the listener unconditionally instead of branching on "am I inside cmux?". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CLI built its notifier by naming CmuxClient as well as Notify — calling the resolver, then wrapping the client it got back. That made two doors where the caller only needs one, and the second door is the one that knows about the environment, the socket and the wire. Notify.build takes the resolved config and returns a notifier, so a caller hands over what the config file said and gets back something that notifies or silently does not. Which client that deserves is not the caller's question. The resolution stays where it is — Notify still knows no environment, no socket and no protocol, and only names the module that does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he environment does #120 asks that the rest of smith name none of the client names but `Notify`, and `Config#notify` still named `CmuxClient` — the module that owns the environment, the socket and the wire — in order to turn a TOML table into a record. Reading a config file is none of those three things, and the argument that this was "about concepts rather than names" was a way of leaving a checklist item undone. `from_table` moves onto `NotifyConfig`, which is the record it builds. The shared blank-string rule moves with it and the environment side delegates to it, so "unset" and "explicitly empty" stay one rule across both tiers rather than becoming two that could drift. `CmuxClient` drops its `toml` require and keeps what it is for: the `CMUX_*` variables, which of the two wins, and the client a resolved config deserves. Now verified rather than intended: outside the subsystem's own files, smith names `CmuxClient`, `CmuxClientable` and `NullCmuxClient` nowhere at all, and `ENV` appears in exactly one file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8 tasks
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.
Part of #120
A long run finishes and nobody notices: smith writes its last line into a terminal that may not have been visible for ten minutes. cmux has a path for this — a Unix socket the terminal exports into every process it spawns. This builds that path up to the socket and stops there.
All eight criteria in the issue's Spezifikation are met. Delivery is not, and that is deliberate:
CmuxClient.clientreturnsNullCmuxClientin every case, so a complete config and a real cmux terminal still send nothing. The seam is where PR 2 begins. I have not marked thisCloses— closing would say a notification arrives, and none does.Being inside cmux is read off the socket, not off a flag
The resolution the issue described switched notifications on by reading
CMUXtruthy. cmux does not exportCMUX. Its own docs nameCMUX_SOCKET_PATH,CMUX_SURFACE_ID,CMUX_WORKSPACE_ID,CMUX_TAB_ID,CMUX_PANEL_ID, and a real cmux shell confirms it:So the branch that decided "you are inside cmux" never fired, and notifications were unreachable except by hand-writing a config file.
What does say it is the socket path: cmux exports one into every process it spawns, so its presence is the announcement that this session is running inside a cmux terminal — which is the situation a completion notification exists for. The default is now on inside cmux, off everywhere else.
CMUX_SOCKET_PATH/CMUX_SURFACE_ID/CMUX_WORKSPACE_IDwere the names the issue had; the tab and panel spellings are read too, because both pairs are present and equal and which one a build exports is not something smith can ask about.Resolved against the real environment of the terminal this was written in:
enabledhad to become tri-statefalseis somebody turning notifications off and must beat the terminal.nilis nobody having said, which is the only answer the terminal may overrule. Without the third state, a config file that never mentions[notify]reads as one that refused it — and no amount of environment can switch it back on.||does not tell the two apart: it collapsesfalseintonil. The specs caught it.Three further things the environment taught
CMUX_SOCKETis exported empty alongside a populatedCMUX_SOCKET_PATH. A resolution reading the first variable that is set rather than the first value that says something finds no socket at all — and now concludes the session is not inside cmux, since the socket is what says so.resolve/builddefaulted toENV, which is not aHash. A default argument is only typechecked where the method is called, and nothing called them — so it compiled untilbuild_agentdid, and then:Error: can't restrict ENV:Module to Hash(String, String | ::Nil). Both now default to a snapshot.[notify]table lived inCmuxClient, soConfig— which deliberately knows noENV— named the module that owns the environment, the socket and the wire.from_tableis pure TOML → record and moved ontoNotifyConfig. Verified afterwards, not intended: outside the subsystem's own files smith namesCmuxClient,CmuxClientableandNullCmuxClientnowhere, andENVappears in exactly one file.TurnCompletednow has a consumerTurnNotifiercollects what the run said and, when the turn ends, hands it toNotifyas the three fields cmux documents — title, subtitle, body. Subtitle names the project the session belongs to, plus its name where it has one, which is what tells two smith runs apart from another tab.A listener of its own rather than a branch in the renderer: which renderer is showing the run is irrelevant to whether a notification should go out — a
--jsoncaller and a fullscreen one are equally served — and a renderer owns the run's exit code and failure state, which this has no business deciding.It is attached in
build_agent, the one place all five routes to a main-thread agent pass through, so each notifies without knowing:run_headless,resume_headless,run_plain_loop,run_tui_loop, andactivate_session(the mid-loop/resume).Subagents are left out, and not by a check for one:
Subagents::Supervisorbuilds children withAgent.newof its own (line 177) and gives them their own listener (188), so they never meet this one. That is also the right answer — a delegate announcing each child would be noise where the parent's completion is the signal. Worth noting cmux'sCMUX_SUPPRESS_SUBAGENT_NOTIFICATIONScannot be leaned on here: it governs events cmux derives from the wrappers it installs for Claude Code and Codex, not anything posted over the socket.It fires on every turn and does not guess whether anybody is watching, because cmux already withdraws the banner of a workspace that has become visible ("post a system notification when cmux is in the background",
docs/notifications.md). The focus decision belongs to the terminal — the one thing this cannot see from inside.Two bugs the specs found while writing them
Neither was in the plan; both came out of asserting behaviour rather than assuming it.
TurnCompleted.TurnError,BudgetExceededandContextExhaustedeach end one, and none is followed by a completion — so whatever was collected survived into the next run's answer. Reproduced: a two-turn session notified"partial answersecond answer".The body is collapsed to one line and cut at a word boundary with an ellipsis, counting characters rather than bytes so German text cannot be split mid-character.
MAX_BODY = 200is smith's own choice — cmux documents no length on any of the three fields.Acceptance criteria
[notify]section:enabled,socket_path,surface_id,workspace_id,timeout.config_spec.cr:846reads each key from a realconfig.tomlon disk;cmux_notify_spec.cr:93reads them from a parsed table. An integertimeout = 2and a float0.5are the same statement (as_f?reads either);timeout = 0and a negative one fall back to the default.CMUX_*environment wins against the config.cmux_notify_spec.cr:185— config says/config/cmux.sock, environment says/env/cmux.sock, the environment wins, for the socket and both ids.enabled = truealone is not enough — no socket path, nothing to send.cmux_notify_spec.cr:79(deliverable?false) and:371(clientis null).CMUX_SOCKET_PATH→CMUX_SOCKET→CMUX;CMUXread as a path only when it looks like one.:235(0/false/no/off, either case, in either spelling),:267,:277,:282(CMUX=1resolves no socket and cannot switch notifications on),:294(skips a falsey variable and keeps looking),:304(falls through to the config when every variable is falsey).Smith::Notifyknows no environment, no socket and no protocol — and the rest of smith names none of the three client names butNotify. Verified, not intended:grepforCmuxClient|CmuxClientable|NullCmuxClientacrosssrc/, excluding the subsystem's own five files, returns nothing;\bENV\binnotify.cr,notify_config.cr,cmux_clientable.cr,null_cmux_client.cr,turn_notifier.crreturns nothing. Callers go throughNotify.build(config)— one door, not two.cmux_notify_spec.cr:761(resolves to nothing deliverable in a non-cmux environment) and:783(a turn ending still costs nothing and fails nothing). The listener is attached unconditionally inbuild_agent; the null client absorbs it.:494— a client that throws returnsfalse.notify.cr:95rescuesException.:703drives it through the notifier: a client that throws out ofhandledoes not propagate.:426—subtitle: " ", body: ""leaves both keys out of the payload entirely, so nothing renders as a gap.crystal specgreen,crystal tool format --checkclean, build clean.1418 examples, 0 failures, 0 errors, 0 pending.crystal tool format --checkexits 0 with no output.crystal build src/smith.crexits 0. All five commits individually build, format clean and pass their specs — bisectable.Proof: red without the fix
Each block is the verbatim run of
crystal spec spec/smith/cmux_notify_spec.crwith the one piece reverted, restored afterwards (git statusclean, suite green again).(a)
enabledback to||, collapsingfalseintonil:(b)
announceback to the form that never clears the collected text:(c) the failure endpoints no longer clearing (
TurnError/BudgetExceeded/ContextExhausted):Not in this PR
No README or CHANGELOG entry. Both describe what a release does for the user, and this changes nothing for one yet: every notification is dropped at the null client. Documenting a
[notify]section now would advertise a setting that does nothing. It belongs with the commit that delivers.Still open, per the issue: the wire itself (
notification.create_for_callerover v2 JSON-RPC against v1notify title|subtitle|body), framing, whether to read a response or fire and forget, whattimeoutdoes once a socket exists, whethersurface_id/workspace_idneed sending at all if the caller's context resolves server-side, and which other events are worth a notification.