Skip to content

cmux notifications: resolve the setup and give TurnCompleted a consumer - #121

Merged
webmatze merged 5 commits into
mainfrom
issue-120-cmux-notify
Sep 9, 2026
Merged

cmux notifications: resolve the setup and give TurnCompleted a consumer#121
webmatze merged 5 commits into
mainfrom
issue-120-cmux-notify

Conversation

@webmatze

@webmatze webmatze commented Sep 9, 2026

Copy link
Copy Markdown
Owner

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.client returns NullCmuxClient in 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 this Closes — 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 CMUX truthy. cmux does not export CMUX. Its own docs name CMUX_SOCKET_PATH, CMUX_SURFACE_ID, CMUX_WORKSPACE_ID, CMUX_TAB_ID, CMUX_PANEL_ID, and a real cmux shell confirms it:

CMUX_SOCKET_PATH=/Users/…/.local/state/cmux/cmux.sock
CMUX_SOCKET=                      ← exported empty
CMUX_WORKSPACE_ID=D9C269B8-…      ← equals CMUX_TAB_ID
CMUX_SURFACE_ID=B9DD280A-…        ← equals CMUX_PANEL_ID
(CMUX: not set)

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_ID were 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:

enabled=true  socket=/Users/…/.local/state/cmux/cmux.sock
surface=B9DD280A-…  workspace=D9C269B8-…  deliverable?=true
client=Smith::NullCmuxClient

enabled had to become tri-state

false is somebody turning notifications off and must beat the terminal. nil is 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 collapses false into nil. The specs caught it.

Three further things the environment taught

  • 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.
  • resolve/build defaulted to ENV, which is not a Hash. A default argument is only typechecked where the method is called, and nothing called them — so it compiled until build_agent did, and then: Error: can't restrict ENV:Module to Hash(String, String | ::Nil). Both now default to a snapshot.
  • Reading the [notify] table lived in CmuxClient, so Config — which deliberately knows no ENV — named the module that owns the environment, the socket and the wire. from_table is pure TOML → record and moved onto NotifyConfig. Verified afterwards, not intended: outside the subsystem's own files smith names CmuxClient, CmuxClientable and NullCmuxClient nowhere, and ENV appears in exactly one file.

TurnCompleted now has a consumer

TurnNotifier collects what the run said and, when the turn ends, hands it to Notify as 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 --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 each notifies without knowing: run_headless, resume_headless, run_plain_loop, run_tui_loop, and activate_session (the mid-loop /resume).

Subagents are left out, and not by a check for one: Subagents::Supervisor builds children with Agent.new of 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's CMUX_SUPPRESS_SUBAGENT_NOTIFICATIONS cannot 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.

  1. The body was never cleared after sending. A session of four turns notified four times, the fourth body holding all four.
  2. A run can end without TurnCompleted. TurnError, BudgetExceeded and ContextExhausted each 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 = 200 is smith's own choice — cmux documents no length on any of the three fields.

Acceptance criteria

  • A [notify] section: enabled, socket_path, surface_id, workspace_id, timeout. config_spec.cr:846 reads each key from a real config.toml on disk; cmux_notify_spec.cr:93 reads them from a parsed table. An integer timeout = 2 and a float 0.5 are the same statement (as_f? reads either); timeout = 0 and a negative one fall back to the default.
  • The 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 = true alone is not enough — no socket path, nothing to send. cmux_notify_spec.cr:79 (deliverable? false) and :371 (client is null).
  • A falsey value counts as absent; CMUX_SOCKET_PATHCMUX_SOCKETCMUX; CMUX read as a path only when it looks like one. :235 (0/false/no/off, either case, in either spelling), :267, :277, :282 (CMUX=1 resolves 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::Notify knows no environment, no socket and no protocol — and the rest of smith names none of the three client names but Notify. Verified, not intended: grep for CmuxClient|CmuxClientable|NullCmuxClient across src/, excluding the subsystem's own five files, returns nothing; \bENV\b in notify.cr, notify_config.cr, cmux_clientable.cr, null_cmux_client.cr, turn_notifier.cr returns nothing. Callers go through Notify.build(config) — one door, not two.
  • A run not inside cmux notices no difference: same line of code, no effect. 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 in build_agent; the null client absorbs it.
  • A failed notification does not end a run. :494 — a client that throws returns false. notify.cr:95 rescues Exception. :703 drives it through the notifier: a client that throws out of handle does not propagate.
  • An empty or whitespace-only body is not sent. :426subtitle: " ", body: "" leaves both keys out of the payload entirely, so nothing renders as a gap.
  • crystal spec green, crystal tool format --check clean, build clean. 1418 examples, 0 failures, 0 errors, 0 pending. crystal tool format --check exits 0 with no output. crystal build src/smith.cr exits 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.cr with the one piece reverted, restored afterwards (git status clean, suite green again).

(a) enabled back to ||, collapsing false into nil:

Failures:

  1) Smith::CmuxClient .resolve honours an explicit enabled = false over the terminal
     Failure/Error: resolved.enabled?.should be_false

       Expected: false
            got: true

     # spec/smith/cmux_notify_spec.cr:222

Finished in 12.86 milliseconds
65 examples, 1 failures, 0 errors, 0 pending

(b) announce back to the form that never clears the collected text:

Failures:

  1) Smith::TurnNotifier notifies once per turn, and again for the next one
     Failure/Error: client.payloads[1]["body"].should eq("turn 1")

       Expected: "turn 1"
            got: "turn 0turn 1"

     # spec/smith/cmux_notify_spec.cr:662

Finished in 11.85 milliseconds
65 examples, 1 failures, 0 errors, 0 pending

(c) the failure endpoints no longer clearing (TurnError / BudgetExceeded / ContextExhausted):

Failures:

  1) Smith::TurnNotifier drops what a run said before it failed, so the next run starts clean
     Failure/Error: client.last["body"].should eq("the answer after it"), "#{ending.class} leaked"

       Smith::Events::TurnError leaked

     # spec/smith/cmux_notify_spec.cr:687

Finished in 13.84 milliseconds
65 examples, 1 failures, 0 errors, 0 pending

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_caller over v2 JSON-RPC against v1 notify title|subtitle|body), framing, whether to read a response or fire and forget, what timeout does once a socket exists, whether surface_id/workspace_id need sending at all if the caller's context resolves server-side, and which other events are worth a notification.

webmatze and others added 5 commits September 8, 2026 23:36
…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>
@webmatze
webmatze merged commit 7ac67e8 into main Sep 9, 2026
2 checks passed
@webmatze
webmatze deleted the issue-120-cmux-notify branch September 9, 2026 08:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant