feat: Pi adapter + cmux sink; fix: paste in attached sessions#58
Draft
dev-jelly wants to merge 5 commits into
Draft
feat: Pi adapter + cmux sink; fix: paste in attached sessions#58dev-jelly wants to merge 5 commits into
dev-jelly wants to merge 5 commits into
Conversation
Add support for the Pi coding agent (@earendil-works/pi-coding-agent) as a tracked muxa agent, mirroring the opencode plugin-first model. Core adapter: - New AgentKind::Pi variant in event schema - crates/muxa/src/adapters/pi.rs: PiAdapter normalizes stdin JSON (forwarded by the extension) into AgentEvent. Discriminates on Pi's native event names (session_start, before_agent_start, tool_execution_start/end, turn_end, agent_end, session_shutdown) rather than inventing private terms. - Wire Pi into the four exhaustive AgentKind match sites (process_tree, ohmyprompt x2, discovery). CLI + init wizard: - muxa hook pi --event event handler - PiHooks component (standard + full presets), auto-detected when ~/.pi/agent exists - crates/muxa-cli/src/init/files/pi.rs: extension body installed at ~/.pi/agent/extensions/muxa/index.ts. Uses getSessionId() (UUID) for stable identity, forwards native events, spawns detached + unref so a down muxad never blocks the agent loop. - plan_pi follows the marker upsert/remove pattern (not DeleteFile), keeping install/uninstall symmetric with opencode and preserving any user-added content in the extension file. Heartbeat cadence is turn-bounded (turn_end) rather than per streamed message, to keep daemon churn low. Tests: 7 adapter unit tests covering the new mappings. Workspace tests (753) and rustfmt clean. Docs (README + INSTALL.md) updated.
Two fixes uncovered by live end-to-end testing against a real Pi
session (events were silently dropped).
1. marker fence: generalize CommentStyle (Hash | Slash)
The pi extension installs into a .ts file, but the marker module
hard-coded the shell-style '#' fence. A '# >>> muxa managed' line
is a syntax error in TypeScript, so Pi's jiti loader rejected the
whole extension at 1:0 with ParseError.
- Add CommentStyle enum + upsert_with / remove_with variants.
- Keep upsert / remove (Hash) as byte-identical wrappers so
tmux/opencode callers are unaffected.
- pi uses CommentStyle::Slash.
2. extension: bake absolute muxa binary path at install time
The extension spawned a bare 'muxa', relying on Pi's process PATH.
But ~/.cargo/bin is typically absent from agent process PATH, so
every spawn hit ENOENT and the error was swallowed by the
fire-and-forget handler — zero events reached muxad, zero logs.
- Add util::locate_muxa() (mirrors launchd::locate_muxad):
PATH -> ~/.cargo/bin -> homebrew -> fallback.
- Substitute __MUXA_BIN__ placeholder in the extension body at
upsert time so the installed file carries an absolute path.
Also: merge the turn_end Heartbeat arm into the catch-all fallback
(clippy: identical-body match arms); document the cadence choice.
Verified live: muxa status shows kind=pi, session_id=<real UUID>,
model captured, last_prompt captured, state=working.
Surface agent state transitions to the cmux native macOS multiplexer's
sidebar via `cmux notify`. When an agent transitions into an
attention-needing state (WaitingInput / WaitingChoice / Error by
default), muxa runs:
cmux notify --title <kind> --subtitle <state> --body <prompt> --surface <id>
targeted at the exact cmux surface the agent runs in.
Targeting: the hook adapter now detects $CMUX_SURFACE_ID (set by cmux
in every terminal it spawns) and attaches it as a SurfaceRef{Cmux} on
the event. This is additive identity metadata and does NOT suppress the
host tmux pane — the two are independent (an agent can run in tmux
nested inside cmux; standalone cmux terminals report no tmux pane
anyway). Decoupling avoids a footgun where leaking CMUX_SURFACE_ID into
subprocesses would blank out the pane used for recap/status-line.
New surface kind: SurfaceKind::Cmux (additive; all existing SurfaceKind
match sites are == Pty equality checks, no exhaustive matches, so the
variant is safe to add).
Sink shape mirrors the webhook sink: best-effort, in-task per-agent
rate limit, no queue, bounded mpsc decouples broadcast drain from spawn
latency. Binary resolved via PATH (or [sinks.cmux].binary override),
validated at startup so a missing cmux surfaces immediately.
Config:
[sinks.cmux]
enabled = true
# binary = "/path/to/cmux" # override PATH lookup
# on_states = ["WaitingInput", "Error"]
# rate_limit_secs = 60
Docs: docs/SINKS.md cmux section added. Workspace tests (760) and
rustfmt clean. pi/claude/codex/gemini all inherit cmux surface detection
for free via the shared run_hook path.
`muxa run <cmd>` attaches by relaying crossterm key events from stdin to the muxa-owned PTY. Paste was broken three ways: 1. No bracketed-paste negotiation with the parent terminal, so clipboard pastes never arrived as a discrete event. 2. `Event::Paste` was dropped by the catch-all arm in attach_session_loop. 3. `key_to_pty_input` ignored the SUPER (Cmd) modifier, so when the terminal did surface Cmd+V it emitted a literal "v" into the PTY. Enable bracketed paste on attach (and restore on exit), relay Event::Paste payloads to the PTY with LF->CR conversion, and drop SUPER combos from key_to_pty_input.
Apply the findings from docs/CODE_REVIEW.md. P1 — protocol version + downgrade (event.rs, ipc.rs) Adding `pi` (AgentKind) and `cmux` (SurfaceKind) without bumping PROTOCOL_VERSION left v3-negotiated clients silently seeing an empty agent list: a snapshot carrying the new variants fails to deserialize and `Client::snapshot` falls back to `Vec::new()`. Bump PROTOCOL_VERSION 3 → 4 and extend `downgrade_wire` for protocol < 4: `pi` → `unknown`, `cmux` → `pty` (closest non-host surface; keeps the host-pane reconciler from reaping it). New test `v3_hello_downgrades_pi_and_cmux_in_snapshot`. P1 — preserve bracketed-paste framing on relay (main.rs) crossterm strips the `CSI 200~`/`CSI 201~` delimiters before surfacing `Event::Paste`, so relaying the bare text with LF→CR made multi-line pastes execute each line immediately (a shell paste `ls\nrm -rf x` would run both). Re-wrap the payload in the delimiters before writing to the child PTY so a paste-aware child buffers it as one input. P1 — serialize Pi event delivery (init/files/pi.rs) Each Pi callback spawned an independent detached `muxa` process, so rapid lifecycle events raced to the socket and could arrive out of order (late `agent_end` resurrecting a stopped row, late `tool_execution_start` marking a finished turn Working). Route every delivery through a single module-level promise chain so events reach muxad in emission order. Callbacks only *append* to the chain (never await it), so Pi's agent loop stays non-blocking. Each step resolves on child exit/close/error or a 5s unref'd safety timeout. P2 — extract text from Pi assistant content blocks (init/files/pi.rs) `AssistantMessage.content` is an array of typed blocks, not a string, so the old `typeof content === "string"` check always failed and `last_response` was never populated — a successful turn couldn't clear a prior Error state. Added `extractText` that concatenates the `text` blocks of the last message. P2 — report cumulative session cost (init/files/pi.rs) `turn_end.message.usage.cost.total` is per-message, but the daemon's `apply_heartbeat` overwrites `Agent.cost_usd` and the UI renders it as the running session cost, so a cheaper later turn made the number drop. Accumulate across turns in a module-level counter reset on `session_start`, and forward the cumulative total. P2 — add `pi` to timeline filters (timeline.rs, dashboard/server.rs) `AgentKindArg` and `parse_agent_kind` both lacked Pi, so `muxa timeline --agent pi` and `/api/timeline?agent=pi` were rejected. Added the variant to both parsers. cargo fmt clean; workspace tests green (incl. new downgrade test).
Author
|
Pushed review fixes in
Review doc kept at |
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.
Summary
Draft. Bundles four related pieces of work that expand muxa's agent + surface coverage. Opening as a single draft for early review; happy to split into focused PRs (Pi adapter / cmux sink / paste fix) before marking ready if reviewers prefer — the four commits are independent and cherry-pickable.
5ccd506AgentKind::Pi, init wizard component, TS extension installer)0c6d940//marker fence + bake absolutemuxapath into the extensiondbbcb1aSurfaceKind::Cmux,$CMUX_SURFACE_IDdetection,cmux notify)5ad01291. Pi coding agent adapter
Pi (
@earendil-works/pi-coding-agent) has no shell-hook system; it exposes an in-process TypeScript extension API. This mirrors the opencode plugin-first model: a small extension at~/.pi/agent/extensions/muxa/index.tsforwards one JSON blob per lifecycle signal tomuxa hook pi --event event, andPiAdapterdiscriminates on Pi's native event names (session_start,before_agent_start,tool_execution_start/end,turn_end,agent_end, …) rather than inventing private terms.AgentKind::Piwired into all exhaustive match sites (process_tree,ohmypromptsource/cli mapping,discovery).~/.pi/agentand installs the extension via the marker-block upsert pattern (symmetric install/uninstall, preserves user-appended content).turn_end) to keep daemon churn low; model + cost ride on the same heartbeat.muxadetached +unref()so a downmuxadnever blocks the agent loop; all errors swallowed by design (observability must never break the agent).Verified live against a real Pi session:
muxa statusshowskind=pi, real session UUID, model + last_prompt captured, state transitions correct.2. Pi wiring fixes (uncovered in live testing)
Two bugs that silently dropped every Pi event before these fixes:
.tsfile butmarker.rshard-coded the shell-style#fence, which is a syntax error in TypeScript. Pi's jiti loader rejected the whole extension at1:0withParseError. Generalized toCommentStyle::{Hash, Slash}; keptupsert/removeas byte-identicalHashwrappers so tmux/opencode callers are unaffected.muxaspawn — the extension spawned a baremuxa, relying on Pi's process PATH. But~/.cargo/binis typically absent there, so every spawn hit ENOENT and the fire-and-forget handler swallowed it — zero events, zero logs. Addedutil::locate_muxa()(mirrorslaunchd::locate_muxad: PATH →~/.cargo/bin→ homebrew → fallback) and substitute__MUXA_BIN__at install time so the installed file carries an absolute path.3. cmux notification sink
Surfaces agent state transitions to the cmux native macOS multiplexer's sidebar via
cmux notify. When an agent transitions into an attention-needing state (WaitingInput/WaitingChoice/Errorby default), muxa runs:$CMUX_SURFACE_IDand attachesSurfaceRef{Cmux}. Notifications fire only when a concrete cmux surface is aimable; agents under tmux/zellij are skipped.CMUX_SURFACE_IDdoes not suppress host pane lookup (onlyMUXA_SESSION_IDdoes). An agent can run in tmux nested inside cmux; the two identities are independent. Decoupling avoids a footgun where leakingCMUX_SURFACE_IDinto subprocesses blanks the pane used for recap/status-line.SurfaceKind::Cmuxis safe to add: all existingSurfaceKindmatch sites are== Ptyequality checks, no exhaustive matches.(kind, session, state)rate limit, bounded mpsc decouples broadcast drain from spawn latency, 10s spawn timeout so a wedged cmux app can't freeze the sink loop.[sinks.cmux].binaryoverride), validated at startup so a missing cmux surfaces immediately instead of silently no-op'ing.Config:
All four shell-hook agents (pi/claude/codex/gemini) inherit cmux surface detection for free via the shared
run_hookpath.4. Paste (Cmd+V) in attached sessions
muxa run <cmd>attaches by relaying crossterm key events from stdin to the muxa-owned PTY. Paste was broken three ways:Event::Pastewas dropped by the catch-all arm inattach_session_loop.key_to_pty_inputignored the SUPER (Cmd) modifier, so when the terminal did surface Cmd+V it emitted a literalvinto the PTY.Fixes: enable bracketed paste on attach (
\x1b[?2004h) and restore on exit, relayEvent::Pastepayloads to the PTY withLF -> CRconversion, drop SUPER combos fromkey_to_pty_input.Test plan
cargo fmt --checkcleanmuxa statusshows correct kind/session/model/prompt/stateWaitingInput; tmux agents skippedKnown follow-ups (not blocking draft)
permission_asked/waiting_inputthat the shipped extension does not yet emit — kept for forward-compat, could use a// forward-compatcomment.attach_session_looprather than insideRawModeGuard'sDrop; fine for the normal path, but folding it into the guard would make panic/early-return recovery automatic.