Skip to content

feat: Pi adapter + cmux sink; fix: paste in attached sessions#58

Draft
dev-jelly wants to merge 5 commits into
Open330:mainfrom
dev-jelly:fix/run-cmd-v-paste
Draft

feat: Pi adapter + cmux sink; fix: paste in attached sessions#58
dev-jelly wants to merge 5 commits into
Open330:mainfrom
dev-jelly:fix/run-cmd-v-paste

Conversation

@dev-jelly

Copy link
Copy Markdown

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.

Commit Type What
5ccd506 feat Pi coding agent adapter (AgentKind::Pi, init wizard component, TS extension installer)
0c6d940 fix Pi wiring: // marker fence + bake absolute muxa path into the extension
dbbcb1a feat cmux notification sink (SurfaceKind::Cmux, $CMUX_SURFACE_ID detection, cmux notify)
5ad0129 fix Support paste (Cmd+V) in attached muxa sessions

1. 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.ts forwards one JSON blob per lifecycle signal to muxa hook pi --event event, and PiAdapter discriminates 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::Pi wired into all exhaustive match sites (process_tree, ohmyprompt source/cli mapping, discovery).
  • Init wizard auto-detects ~/.pi/agent and installs the extension via the marker-block upsert pattern (symmetric install/uninstall, preserves user-appended content).
  • Heartbeat cadence is turn-bounded (turn_end) to keep daemon churn low; model + cost ride on the same heartbeat.
  • Extension spawns muxa detached + unref() so a down muxad never blocks the agent loop; all errors swallowed by design (observability must never break the agent).

Verified live against a real Pi session: muxa status shows kind=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:

  1. Marker fence — the extension installs into a .ts file but marker.rs hard-coded the shell-style # fence, which is a syntax error in TypeScript. Pi's jiti loader rejected the whole extension at 1:0 with ParseError. Generalized to CommentStyle::{Hash, Slash}; kept upsert/remove as byte-identical Hash wrappers so tmux/opencode callers are unaffected.
  2. Bare muxa spawn — the extension spawned a bare muxa, relying on Pi's process PATH. But ~/.cargo/bin is typically absent there, so every spawn hit ENOENT and the fire-and-forget handler swallowed it — zero events, zero logs. Added util::locate_muxa() (mirrors launchd::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 / Error by default), muxa runs:

cmux notify --title <kind> --subtitle <state> --body <prompt> --surface <id>
  • Targeting: hook adapter detects $CMUX_SURFACE_ID and attaches SurfaceRef{Cmux}. Notifications fire only when a concrete cmux surface is aimable; agents under tmux/zellij are skipped.
  • Surface identity is additiveCMUX_SURFACE_ID does not suppress host pane lookup (only MUXA_SESSION_ID does). An agent can run in tmux nested inside cmux; the two identities are independent. Decoupling avoids a footgun where leaking CMUX_SURFACE_ID into subprocesses blanks the pane used for recap/status-line.
  • New SurfaceKind::Cmux is safe to add: all existing SurfaceKind match sites are == Pty equality checks, no exhaustive matches.
  • Shape mirrors the webhook sink: best-effort, in-task per-(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.
  • Binary resolved via PATH (or [sinks.cmux].binary override), validated at startup so a missing cmux surfaces immediately instead of silently no-op'ing.

Config:

[sinks.cmux]
enabled = true
# binary = "/path/to/cmux"
# on_states = ["WaitingInput", "Error"]
# rate_limit_secs = 60

All four shell-hook agents (pi/claude/codex/gemini) inherit cmux surface detection for free via the shared run_hook path.

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:

  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.

Fixes: enable bracketed paste on attach (\x1b[?2004h) and restore on exit, relay Event::Paste payloads to the PTY with LF -> CR conversion, drop SUPER combos from key_to_pty_input.

Test plan

  • cargo fmt --check clean
  • Workspace tests green (760)
  • Live Pi session: muxa status shows correct kind/session/model/prompt/state
  • cmux sink: notification lands on the originating surface for WaitingInput; tmux agents skipped
  • Reviewer: confirm whether to keep as one PR or split (Pi / cmux / paste)

Known follow-ups (not blocking draft)

  • Adapter has defensive arms for permission_asked / waiting_input that the shipped extension does not yet emit — kept for forward-compat, could use a // forward-compat comment.
  • Bracketed-paste disable lives in a separate block next to attach_session_loop rather than inside RawModeGuard's Drop; fine for the normal path, but folding it into the guard would make panic/early-return recovery automatic.

dev-jelly added 5 commits July 3, 2026 00:44
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).
@dev-jelly

Copy link
Copy Markdown
Author

Pushed review fixes in 4029296. All six findings addressed:

# Finding Fix
P1 New wire variants break v3 clients (empty snapshot) Bump PROTOCOL_VERSION 3 → 4; extend downgrade_wire: piunknown, cmuxpty for protocol < 4. New test v3_hello_downgrades_pi_and_cmux_in_snapshot.
P1 Multi-line paste executes each line Re-wrap Event::Paste payload in CSI 200~ / CSI 201~ framing before writing to the child PTY.
P1 Pi lifecycle events arrive out of order Route every muxa hook pi spawn through a single promise chain (deliveryChain); callbacks only append (non-blocking), each step resolves on child exit/close/error or a 5s unref'd timeout.
P2 last_response never populated extractText() walks AssistantMessage.content blocks and concatenates text blocks.
P2 Cost regresses on cheaper turns Accumulate per-turn cost in a module counter, reset on session_start, forward the cumulative total.
P2 muxa timeline --agent pi / /api/timeline?agent=pi rejected Added Pi to AgentKindArg and parse_agent_kind.

cargo fmt clean, workspace tests green (incl. new downgrade test), clippy clean on touched files.

Review doc kept at docs/CODE_REVIEW.md for traceability.

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