8000 grow wmux grow ;) · Issue #128 · amirlehmam/wmux · GitHub
[go: up one dir, main page]

Skip to content

grow wmux grow ;) #128

Closed
Closed

Description

@arnonuem

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?

Activity

  1. arnonuem commented on Jul 29, 2026

    @arnonuem
    Author

    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:

    if awaiting_human:   return "blocked"
    if run_depth > 0:    return "working"   # refcount, not bool — sub-agents
    return "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.

  2. amirlehmam commented on Jul 29, 2026

    @amirlehmam
    Owner

    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:

    • blocked never 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.

    Landed in b97ff0d. Keep them coming. 🙏

  3. amirlehmam commented on Jul 29, 2026

    @amirlehmam
    Owner

    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.

  4. arnonuem commented on Jul 31, 2026

    @arnonuem
    Author

    @amirlehmam wow nice it is working - i asked it to spin up a tui dialog in that session and then i switched session:

    Image

    it tells me it needs my input :D

    It showed me this dialog

    Image

    thanks a lot - love it.

  5. amirlehmam commented on Jul 31, 2026

    @amirlehmam
    Owner

    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. 🙏

  6. amirlehmam commented on Jul 31, 2026

    @amirlehmam
    Owner

    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.

  7. amirlehmam commented on Jul 31, 2026

    @amirlehmam
    Owner

    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. 🙏

  8. arnonuem commented on Jul 31, 2026

    @arnonuem
    Author

    glad you took it serious - for me this is now real gold value :)
    cuz i am not using claude code ;)

  9. arnonuem commented on Aug 1, 2026

    @arnonuem
    Author

    i love it:

    Image
  10. amirlehmam commented on Aug 2, 2026

    @amirlehmam
    Owner

    Thank you — and the second half of that is the part I want to answer properly, because "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:

    wmux report-agent --blocked "Run it?" --choices '[{"id":"y","label":"Yes","key":"1"}]'
    wmux report-agent --unblocked
    wmux answer-agent --surface <id> --choice y

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      0