You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Since wmux is already super close to herdr (https://www.youtube.com/watch?v=qnIu-Xu64H0), it is maybe a good idea to get some more ideas and inspirations from there (we did the autobackup recently). The code is Apache 2.0 and i guess worth looking at. What do you guys think would be awesome features wmux should grow on?
Concrete inspiration from herdr's my coding tool integration — I went through the plugin source, and the wire protocol is small enough to copy in an afternoon.
The one idea worth stealing: the entire value sits in a single signal — "this agent is parked on a human." for example my coding tool emits it from one process-wide choke-point that every interactive wait already flows through: shell-command approval, file-permission approval, questions, every menu. No per-prompt special-casing, nothing for the multiplexer to guess from screen content.
State model is two facts, three states:
ifawaiting_human: return"blocked"ifrun_depth>0: return"working"# refcount, not bool — sub-agentsreturn"idle"
Four endpoints, newline-delimited JSON over a socket, one connection per message:
┌───────────────────────────┬────────────────────────────────────────────────────────────────────────────────┐
│ method │ why it matters │
├───────────────────────────┼────────────────────────────────────────────────────────────────────────────────┤
│ pane.report_agent │ the whole point — see at a glance which of 10 sessions needs you │
│ pane.report_agent_session │ ties a pane to a resumable session file, not a dead PID │
│ pane.report_metadata │ model / context% / tokens, with a TTL so a crashed process doesn't lie forever │
│ pane.release_agent │ agent actively let go → fall back to unknown instead of a ghost working │
└───────────────────────────┴────────────────────────────────────────────────────────────────────────────────┘
Activation is three env vars the multiplexer injects into the pane (*_ENV=1, *_SOCKET_PATH, *_PANE_ID). Not set → the plugin doesn't even start a thread. Zero cost outside a pane, no install step.
Two things not to skip: a monotonic seq the receiver dedupes on (seq <= last_seq → drop), and an ack requirement. Without them a retry replays a stale state, or a lost idle strands the pane on working forever.
Minimum viable: report_agent + release_agent. Everything else is upside.
Where wmux could go further: herdr's protocol is strictly one-way — every method is report_*. The multiplexer observes, it can't talk back. A back-channel (send input to a running agent, answer an approval from outside the pane) is the obvious next step, and my coding tool already has the plumbing for it — its pause controller accepts steering from a foreign thread mid-run.
Built it. Shipping in v0.39.0 — thank you for the write-up, the "single signal, one choke-point" framing is what made it obviously worth doing.
Why this was the right thing to steal
wmux already inferred agent state two ways, and both were guesses:
claude-observer.ts scrapes the Claude Code TUI with regexes
claude-session-view.ts calls a surface working while a hook or observer signal is younger than 5s
That inference structurally cannot represent "parked on a human." An agent waiting on a permission prompt goes silent — so after the 5s window it decayed to idle, indistinguishable from a finished turn. With ten panes open, the pane that needed you looked exactly like the nine that didn't. Your diagnosis was precise.
What shipped
Your state model, verbatim:
awaitingHuman -> blocked
runDepth > 0 -> working # refcount, not bool — subagents nest
otherwise -> idle
never reported / released -> unknown
All four methods, over the existing V2 pipe:
method
notes
pane.report_agent
the whole point
pane.report_agent_session
resumable handle, not a PID
pane.report_metadata
model / context% / tokens, with a TTL
pane.release_agent
drops the record → unknown, not a ghost working
Plus pane.agent_state — with no target it returns every pane and a blocked list, which is "which of 10 sessions needs you" answered in one call.
You said not to skip the seq dedupe and the ack. Both are in: reports carry a monotonic seq, anything <= last_seq is dropped, and a deduped report answers {accepted: false} rather than erroring — a retry has to be a harmless no-op, not a failure that invites another retry. There are tests for the exact ghost you described.
Two places I went further than herdr, both for the reason you'd expect — a crashed process must not keep lying:
blockednever expires. A waiting agent emits nothing while it waits, so a decaying blocked would silently become idle — reintroducing the exact bug this removes.
a working claim is a promise to report again; past a trust window it decays to unknown rather than asserting a run that may be long dead. Metadata gets a TTL for the same reason.
Activation
wmux already had your three env vars — WMUX=1, WMUX_SURFACE_ID, WMUX_PIPE — plus a per-instance auth token, since anything that can write to the pipe can spoof agent status (#72). So there was no activation work: an agent inside a pane already knows which pane it's in.
wmux report-agent --blocked "permission: Bash"
wmux report-agent --unblocked
wmux report-agent --run-start | --run-end | --run-depth N [--seq N]
wmux report-metadata --model opus --tokens 82.9k --context-pct 41
wmux report-session <id>| release-agent
wmux agent-state # every pane + the blocked list
And it works with nothing installed. wmux already registers four Claude Code hooks, so they're translated into reports directly: Notification → blocked (including the 60s idle nudge — sniffing the message text to exclude it would break on any rewording, and would fail in the dangerous direction), PostToolUse → working via an absolute depth so a per-tool-call signal can't inflate the count, SubagentStop → decrement, Stop → decisive idle as the backstop. Other agents call the pipe directly.
In the sidebar a parked pane now reads "Needs you" in violet — deliberately not amber, since amber already means "working" and the entire value here is reading differently from the panes getting on with it — with its own dot rhythm, and it surfaces for a single blocked pane instead of only at 2+ sessions.
On the back-channel
herdr's protocol is strictly one-way… A back-channel is the obvious next step.
Agreed, and wmux is already most of the way there — surface.send_text / surface.send_key can drive a pane from outside today, so answering an approval without touching the pane is mostly a matter of knowing what is being asked. blockedReason now carries that. What's missing is a structured "here are the choices" payload rather than free text; if you want to open a separate issue for that shape, I'll build it.
Follow-up: shipping a 0.39.1 patch on top of this.
Reviewing the module after tagging, I found a ghost reachable through its own cleanup path. clearAgentState (called on PTY exit) deleted the record but broadcast nothing — and the renderer holds its own copy of the last state it was sent. So a pane whose shell had exited kept rendering as working, or worse as "Needs you", on behalf of a process that no longer existed. A shell can exit while its tab stays open, so the pane is still there to render.
Exactly the failure this module exists to remove, which is a fitting way to be reminded that "the process died without telling us" is the hard case, not the easy one. Your point about release_agent mattering — "agent actively let go → fall back to unknown instead of a ghost working" — turns out to apply just as much to the involuntary exit as the voluntary one.
Fixed: PTY exit now announces unknown the same way release_agent does, and both share one forget helper so a third caller can't reintroduce the silent path.
Glad it landed well — and thanks for testing it the interesting way. Spinning up a TUI dialog and then switching away is precisely the case the old inference could not represent, so that screenshot is the feature working at the exact point the previous design broke.
Since 0.39.1 there is one more follow-up worth recording here, because it is a nice illustration of your own release_agent argument generalising further than either of us framed it.
You wrote:
agent actively let go → fall back to unknown instead of a ghost working
That is the voluntary exit. Reviewing the module after tagging, I found the involuntary one had the same hole: clearAgentState, called on PTY exit, deleted the record but broadcast nothing — and the renderer holds its own copy of the last state it was sent. A pane whose shell had died kept rendering as working, or worse as "Needs you", on behalf of a process that no longer existed. A shell can exit while its tab stays open, so the pane is still there to render it. Both paths now go through one forget helper, so a third caller cannot reintroduce the silent version. "The process died without telling us" is the hard case, not the easy one.
Keeping this open as the idea thread it started as. The concrete item got built; the question in the title did not. On that, the back-channel is still where I think the most value is:
herdr's protocol is strictly one-way… A back-channel is the obvious next step.
wmux is most of the way there — surface.send_text / surface.send_key already drive a pane from outside, and blockedReason now carries what is being asked. What is missing is a structured "here are the choices" payload instead of free text, so the sidebar could offer the buttons directly rather than making you switch to the pane to answer. If you want to sketch the shape that would need — the way you sketched the four methods — I will build it.
For v0.40.0, unrelated to this thread but shipping alongside: agent config writes are now consent-gated (#132), a diff pane on a non-git directory no longer freezes the app (#133), saved sessions keep each terminal's own directory (#134), and the stale icon is fixed (#137). Keep them coming. 🙏
Built the back-channel. Shipping in v0.41.0 — this closes the part of your write-up that 0.39.0 left open.
herdr's protocol is strictly one-way — every method is report_*. The multiplexer observes, it can't talk back. A back-channel (send input to a running agent, answer an approval from outside the pane) is the obvious next step.
0.39.0 gave you the signal: with ten panes open, you can see which one is parked on you. But that was the whole of it — you could see which pane needed you, then you still had to go to it. The observation was actionable only by hand.
The shape
Two additions, and the design turns on who owns the keystroke.
pane.report_agent takes an optional choices array. Each entry has an id, a human-readable label, and exactly what to send — key (the surface.send_key vocabulary) or literal text:
wmux report-agent --blocked "Run the migration against prod?" --choices '[ {"id":"yes","label":"Yes, run it","key":"1"}, {"id":"no","label":"No, stop","key":"2","isDefault":true}]'
The agent declares both halves. wmux does not know how to answer a Claude Code permission prompt, an OpenCode menu, or your custom TUI, and it deliberately never guesses. The moment it interprets someone else's interface it takes on a dependency on that interface not changing — and the failure would land silently on the user, months later, as a button that quietly does the wrong thing. That is the same reasoning that made blocked a declared fact rather than a scraped one; it applies at least as strongly when the direction reverses and wmux is writing.
A choice carrying neither key nor text is dropped, and the reply echoes how many were kept, so a misbehaving reporter finds out from the RPC rather than from a confused human clicking a dead button.
pane.answer_agent — the first non-report_* method, and the only one that writes. The sidebar renders the declared answers as buttons under the "Needs you" line; clicking one relays the agent's own payload into that pane while you stay in yours. Or from anywhere:
wmux agent-state # which panes are blocked, and their choices
wmux answer-agent --surface surf-… --choice yes # answer without going there
Three rules, because this one writes
Everything else in the module records. This pushes bytes into a live terminal, so it is guarded differently:
1. Only a pane that is currently blocked can be answered. A pane the agent has moved on from may well have you typing in it, and a stale click on a button the sidebar has not repainted yet must not push characters into your shell. That is a refusal with a message, not a silent no-op.
2. Only the agent's own declared payload is sent for a choice. Free-form text into a pane is still surface.send_text — it has always existed, and routing it through here would grant nothing while making this method look more dangerous than it is.
3. Answering does NOT clear blocked. This is the one I want to flag, because it looks wrong until you work through the failure. You clicked "Allow" — surely the pane should stop asking?
But suppose the agent declared key: "y" for a prompt that actually wants enter. If answering cleared the state optimistically, the pane goes quiet while the agent is still stuck, and you find out much later. So the agent has to confirm by reporting, exactly as it would if you had typed the answer yourself. The sidebar shows "Sent — waiting…" in the meantime.
A pane that keeps saying "needs you" after a failed answer is annoying. One that goes quiet is the ghost this whole module exists to remove — and it is the same asymmetry that made blocked never expire in the first place. The two error directions have very different costs, so the design leans into the cheap one deliberately.
Related: the choices are cleared on any transition of awaitingHuman. Unblocking ends the prompt they belong to; a fresh block starts a different one. Carrying them across either edge would arm the previous question's Allow/Deny against the current one — a click sending a keystroke the agent never declared for the prompt it is actually showing.
Two things worth reporting honestly
A test caught a real bug in my own error handling. I wrote a case substituting a writer that throws synchronously, expecting a clean RPC error. It didn't get one: Promise.resolve(fn()) evaluates fn()before it can wrap it, so a sync throw sails past the .catch() and out of the dispatcher — it would have taken down the pipe request handler instead of answering. The real writer is async and would have rejected properly, so this would have stayed invisible until someone wrote a non-async one. Fixed, and the test stays.
Claude Code cannot declare choices yet, and I am not going to fake it. Its Notification hook tells wmux the agent wants you, but the payload carries no structured prompt data — no options, no keys. So out of the box you still get exactly what 0.39.0 gave you: the "Needs you" signal, with no buttons. The buttons appear for any agent that adopts the protocol, and the auto-injected instructions now teach agents to use it, so an agent that stops to ask a question is told how to say so and how to offer its answers.
I could have hardcoded Claude Code's permission-prompt keys to make the demo prettier. That is exactly the guess rule 2 exists to prevent, and it would break the first time the prompt is reworded — the same brittleness that made the TUI scraper worth replacing.
Landed in 168f6a8, 38 new tests. Thanks for pushing on this one; the "single signal, one choke-point" framing held up all the way through the reverse direction.
Closing this — everything concrete in the thread is now shipped:
v0.39.0 — the declared state protocol: blocked / working / idle, run refcount, seq dedupe, metadata TTL, and all four report_* methods
v0.39.1 — a dead PTY no longer leaves a ghost working / "Needs you" pane
v0.41.0 — the back-channel: structured choices, pane.answer_agent, and sidebar buttons that answer a blocked pane without switching to it
@arnonuem — thank you. This thread produced the single best feature request wmux has had. The "one signal, one choke-point" framing was what made it obviously worth building, and the two details you said not to skip — the monotonic seq and the ack — are both in, with tests for the exact ghost you described.
The title question stays open in spirit, just not as a ticket: a general "what should wmux grow?" issue is hard to close and hard to act on, and specific ones like this get built. Please keep opening them. 🙏
Thank you — and the second half of that is the part I want to answer properly, b
AFB5
ecause "i am not using claude code" is the reason this ended up designed the way it is rather than a caveat to it.
Everything in the declared protocol is plain CLI over the named pipe:
No plugin, no SDK, no Claude Code. Anything that can shell out can report, and WMUX_SURFACE_ID is already in the pane's environment so it doesn't even need to know which pane it's in. That was your own argument in the write-up — one signal, one choke point — and it's what makes the protocol survive the thing that killed the old inference: it never had to guess what was on screen, so it never had to be taught a new agent's output format.
The Claude Code hook bridge is an extra path on top, not the road in. It exists so wmux works out of the box for people who install nothing, and it feeds the same state the CLI does. Same for the OpenCode side. If your tool can print, it can report — and since 0.41.0, it can be answered back.
Which is also why the back-channel refuses to invent a keystroke. answer_agent only ever sends a payload the agent itself declared in --choices, and answering never clears blocked — the agent has to confirm that. If wmux guessed a key on your behalf and got it wrong, it would be answering a permission prompt with the wrong answer, silently, in a pane you weren't looking at. Making that structurally impossible mattered more than making it convenient.
Glad it's earning its keep. Keep the reports coming — #128 is the clearest thing anyone has written about this project.
Activity
arnonuem commentedon Jul 29, 2026
Concrete inspiration from herdr's my coding tool integration — I went through the plugin source, and the wire protocol is small enough to copy in an afternoon.
The one idea worth stealing: the entire value sits in a single signal — "this agent is parked on a human." for example my coding tool emits it from one process-wide choke-point that every interactive wait already flows through: shell-command approval, file-permission approval, questions, every menu. No per-prompt special-casing, nothing for the multiplexer to guess from screen content.
State model is two facts, three states:
Four endpoints, newline-delimited JSON over a socket, one connection per message:
Activation is three env vars the multiplexer injects into the pane (*_ENV=1, *_SOCKET_PATH, *_PANE_ID). Not set → the plugin doesn't even start a thread. Zero cost outside a pane, no install step.
Two things not to skip: a monotonic seq the receiver dedupes on (seq <= last_seq → drop), and an ack requirement. Without them a retry replays a stale state, or a lost idle strands the pane on working forever.
Minimum viable: report_agent + release_agent. Everything else is upside.
Where wmux could go further: herdr's protocol is strictly one-way — every method is report_*. The multiplexer observes, it can't talk back. A back-channel (send input to a running agent, answer an approval from outside the pane) is the obvious next step, and my coding tool already has the plumbing for it — its pause controller accepts steering from a foreign thread mid-run.
feat(agent-state): panes declare blocked/working/idle instead of bein…
amirlehmam commentedon Jul 29, 2026
Built it. Shipping in v0.39.0 — thank you for the write-up, the "single signal, one choke-point" framing is what made it obviously worth doing.
Why this was the right thing to steal
wmux already inferred agent state two ways, and both were guesses:
claude-observer.tsscrapes the Claude Code TUI with regexesclaude-session-view.tscalls a surfaceworkingwhile a hook or observer signal is younger than 5sThat inference structurally cannot represent "parked on a human." An agent waiting on a permission prompt goes silent — so after the 5s window it decayed to
idle, indistinguishable from a finished turn. With ten panes open, the pane that needed you looked exactly like the nine that didn't. Your diagnosis was precise.What shipped
Your state model, verbatim:
All four methods, over the existing V2 pipe:
pane.report_agentpane.report_agent_sessionpane.report_metadatapane.release_agentunknown, not a ghostworkingPlus
pane.agent_state— with no target it returns every pane and ablockedlist, which is "which of 10 sessions needs you" answered in one call.You said not to skip the seq dedupe and the ack. Both are in: reports carry a monotonic
seq, anything<= last_seqis dropped, and a deduped report answers{accepted: false}rather than erroring — a retry has to be a harmless no-op, not a failure that invites another retry. There are tests for the exact ghost you described.Two places I went further than herdr, both for the reason you'd expect — a crashed process must not keep lying:
blockednever expires. A waiting agent emits nothing while it waits, so a decayingblockedwould silently becomeidle— reintroducing the exact bug this removes.workingclaim is a promise to report again; past a trust window it decays tounknownrather than asserting a run that may be long dead. Metadata gets a TTL for the same reason.Activation
wmux already had your three env vars —
WMUX=1,WMUX_SURFACE_ID,WMUX_PIPE— plus a per-instance auth token, since anything that can write to the pipe can spoof agent status (#72). So there was no activation work: an agent inside a pane already knows which pane it's in.And it works with nothing installed. wmux already registers four Claude Code hooks, so they're translated into reports directly:
Notification→ blocked (including the 60s idle nudge — sniffing the message text to exclude it would break on any rewording, and would fail in the dangerous direction),PostToolUse→ working via an absolute depth so a per-tool-call signal can't inflate the count,SubagentStop→ decrement,Stop→ decisive idle as the backstop. Other agents call the pipe directly.In the sidebar a parked pane now reads "Needs you" in violet — deliberately not amber, since amber already means "working" and the entire value here is reading differently from the panes getting on with it — with its own dot rhythm, and it surfaces for a single blocked pane instead of only at 2+ sessions.
On the back-channel
Agreed, and wmux is already most of the way there —
surface.send_text/surface.send_keycan drive a pane from outside today, so answering an approval without touching the pane is mostly a matter of knowing what is being asked.blockedReasonnow carries that. What's missing is a structured "here are the choices" payload rather than free text; if you want to open a separate issue for that shape, I'll build it.Landed in b97ff0d. Keep them coming. 🙏
fix(agent-state): a dead PTY left a ghost blocked/working pane (#128)
amirlehmam commentedon Jul 29, 2026
Follow-up: shipping a 0.39.1 patch on top of this.
Reviewing the module after tagging, I found a ghost reachable through its own cleanup path.
clearAgentState(called on PTY exit) deleted the record but broadcast nothing — and the renderer holds its own copy of the last state it was sent. So a pane whose shell had exited kept rendering asworking, or worse as "Needs you", on behalf of a process that no longer existed. A shell can exit while its tab stays open, so the pane is still there to render.Exactly the failure this module exists to remove, which is a fitting way to be reminded that "the process died without telling us" is the hard case, not the easy one. Your point about
release_agentmattering — "agent actively let go → fall back to unknown instead of a ghost working" — turns out to apply just as much to the involuntary exit as the voluntary one.Fixed: PTY exit now announces
unknownthe same wayrelease_agentdoes, and both share oneforgethelper so a third caller can't reintroduce the silent path.merge: 合入 master agent-state (amirlehmam#128)
arnonuem commentedon Jul 31, 2026
@amirlehmam wow nice it is working - i asked it to spin up a tui dialog in that session and then i switched session:
it tells me it needs my input :D
It showed me this dialog
thanks a lot - love it.
amirlehmam commentedon Jul 31, 2026
Glad it landed well — and thanks for testing it the interesting way. Spinning up a TUI dialog and then switching away is precisely the case the old inference could not represent, so that screenshot is the feature working at the exact point the previous design broke.
Since 0.39.1 there is one more follow-up worth recording here, because it is a nice illustration of your own
release_agentargument generalising further than either of us framed it.You wrote:
That is the voluntary exit. Reviewing the module after tagging, I found the involuntary one had the same hole:
clearAgentState, called on PTY exit, deleted the record but broadcast nothing — and the renderer holds its own copy of the last state it was sent. A pane whose shell had died kept rendering asworking, or worse as "Needs you", on behalf of a process that no longer existed. A shell can exit while its tab stays open, so the pane is still there to render it. Both paths now go through oneforgethelper, so a third caller cannot reintroduce the silent version. "The process died without telling us" is the hard case, not the easy one.Keeping this open as the idea thread it started as. The concrete item got built; the question in the title did not. On that, the back-channel is still where I think the most value is:
wmux is most of the way there —
surface.send_text/surface.send_keyalready drive a pane from outside, andblockedReasonnow carries what is being asked. What is missing is a structured "here are the choices" payload instead of free text, so the sidebar could offer the buttons directly rather than making you switch to the pane to answer. If you want to sketch the shape that would need — the way you sketched the four methods — I will build it.For v0.40.0, unrelated to this thread but shipping alongside: agent config writes are now consent-gated (#132), a diff pane on a non-git directory no longer freezes the app (#133), saved sessions keep each terminal's own directory (#134), and the stale icon is fixed (#137). Keep them coming. 🙏
feat(agent-state): answer a blocked pane without switching to it (#128)
amirlehmam commentedon Jul 31, 2026
Built the back-channel. Shipping in v0.41.0 — this closes the part of your write-up that 0.39.0 left open.
0.39.0 gave you the signal: with ten panes open, you can see which one is parked on you. But that was the whole of it — you could see which pane needed you, then you still had to go to it. The observation was actionable only by hand.
The shape
Two additions, and the design turns on who owns the keystroke.
pane.report_agenttakes an optionalchoicesarray. Each entry has anid, a human-readablelabel, and exactly what to send —key(thesurface.send_keyvocabulary) or literaltext:The agent declares both halves. wmux does not know how to answer a Claude Code permission prompt, an OpenCode menu, or your custom TUI, and it deliberately never guesses. The moment it interprets someone else's interface it takes on a dependency on that interface not changing — and the failure would land silently on the user, months later, as a button that quietly does the wrong thing. That is the same reasoning that made
blockeda declared fact rather than a scraped one; it applies at least as strongly when the direction reverses and wmux is writing.A choice carrying neither
keynortextis dropped, and the reply echoes how many were kept, so a misbehaving reporter finds out from the RPC rather than from a confused human clicking a dead button.pane.answer_agent— the first non-report_*method, and the only one that writes. The sidebar renders the declared answers as buttons under the "Needs you" line; clicking one relays the agent's own payload into that pane while you stay in yours. Or from anywhere:Three rules, because this one writes
Everything else in the module records. This pushes bytes into a live terminal, so it is guarded differently:
1. Only a pane that is currently blocked can be answered. A pane the agent has moved on from may well have you typing in it, and a stale click on a button the sidebar has not repainted yet must not push characters into your shell. That is a refusal with a message, not a silent no-op.
2. Only the agent's own declared payload is sent for a choice. Free-form text into a pane is still
surface.send_text— it has always existed, and routing it through here would grant nothing while making this method look more dangerous than it is.3. Answering does NOT clear
blocked. This is the one I want to flag, because it looks wrong until you work through the failure. You clicked "Allow" — surely the pane should stop asking?But suppose the agent declared
key: "y"for a prompt that actually wantsenter. If answering cleared the state optimistically, the pane goes quiet while the agent is still stuck, and you find out much later. So the agent has to confirm by reporting, exactly as it would if you had typed the answer yourself. The sidebar shows "Sent — waiting…" in the meantime.A pane that keeps saying "needs you" after a failed answer is annoying. One that goes quiet is the ghost this whole module exists to remove — and it is the same asymmetry that made
blockednever expire in the first place. The two error directions have very different costs, so the design leans into the cheap one deliberately.Related: the choices are cleared on any transition of
awaitingHuman. Unblocking ends the prompt they belong to; a fresh block starts a different one. Carrying them across either edge would arm the previous question's Allow/Deny against the current one — a click sending a keystroke the agent never declared for the prompt it is actually showing.Two things worth reporting honestly
A test caught a real bug in my own error handling. I wrote a case substituting a writer that throws synchronously, expecting a clean RPC error. It didn't get one:
Promise.resolve(fn())evaluatesfn()before it can wrap it, so a sync throw sails past the.catch()and out of the dispatcher — it would have taken down the pipe request handler instead of answering. The real writer isasyncand would have rejected properly, so this would have stayed invisible until someone wrote a non-async one. Fixed, and the test stays.Claude Code cannot declare choices yet, and I am not going to fake it. Its
Notificationhook tells wmux the agent wants you, but the payload carries no structured prompt data — no options, no keys. So out of the box you still get exactly what 0.39.0 gave you: the "Needs you" signal, with no buttons. The buttons appear for any agent that adopts the protocol, and the auto-injected instructions now teach agents to use it, so an agent that stops to ask a question is told how to say so and how to offer its answers.I could have hardcoded Claude Code's permission-prompt keys to make the demo prettier. That is exactly the guess rule 2 exists to prevent, and it would break the first time the prompt is reworded — the same brittleness that made the TUI scraper worth replacing.
Landed in 168f6a8, 38 new tests. Thanks for pushing on this one; the "single signal, one choke-point" framing held up all the way through the reverse direction.
test(pipe): pin that pane.answer_agent requires the auth token
amirlehmam commentedon Jul 31, 2026
Closing this — everything concrete in the thread is now shipped:
blocked/working/idle, run refcount,seqdedupe, metadata TTL, and all fourreport_*methodsworking/ "Needs you" panechoices,pane.answer_agent, and sidebar buttons that answer a blocked pane without switching to it@arnonuem — thank you. This thread produced the single best feature request wmux has had. The "one signal, one choke-point" framing was what made it obviously worth building, and the two details you said not to skip — the monotonic
seqand the ack — are both in, with tests for the exact ghost you described.The title question stays open in spirit, just not as a ticket: a general "what should wmux grow?" issue is hard to close and hard to act on, and specific ones like this get built. Please keep opening them. 🙏
arnonuem commentedon Jul 31, 2026
glad you took it serious - for me this is now real gold value :)
cuz i am not using claude code ;)
arnonuem commentedon Aug 1, 2026
i love it:
amirlehmam commentedon Aug 2, 2026
Thank you — and the second half of that is the part I want to answer properly, b AFB5 ecause "i am not using claude code" is the reason this ended up designed the way it is rather than a caveat to it.
Everything in the declared protocol is plain CLI over the named pipe:
No plugin, no SDK, no Claude Code. Anything that can shell out can report, and
WMUX_SURFACE_IDis already in the pane's environment so it doesn't even need to know which pane it's in. That was your own argument in the write-up — one signal, one choke point — and it's what makes the protocol survive the thing that killed the old inference: it never had to guess what was on screen, so it never had to be taught a new agent's output format.The Claude Code hook bridge is an extra path on top, not the road in. It exists so wmux works out of the box for people who install nothing, and it feeds the same state the CLI does. Same for the OpenCode side. If your tool can print, it can report — and since 0.41.0, it can be answered back.
Which is also why the back-channel refuses to invent a keystroke.
answer_agentonly ever sends a payload the agent itself declared in--choices, and answering never clearsblocked— the agent has to confirm that. If wmux guessed a key on your behalf and got it wrong, it would be answering a permission prompt with the wrong answer, silently, in a pane you weren't looking at. Making that structurally impossible mattered more than making it convenient.Glad it's earning its keep. Keep the reports coming — #128 is the clearest thing anyone has written about this project.
merge(upstream): bring amirlehmam/wmux master (0.46.0) into oh-my-pi
feat(agents): cross-workspace agent roster, navigator and jump-to-blo…
fix(sidebar): a silent agent must fall through, not override the row (#…