Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ Patterns are case-sensitive and support `*` and `?`, e.g.
| Claude Code | Supported | `~/.claude/settings.json` |
| OpenAI Codex | Supported | `~/.codex/config.toml` |
| Google Gemini CLI | Supported | `~/.gemini/settings.json` |
| Pi | Supported | `~/.pi/agent/extensions/muxa/index.ts` |
| opencode | Planned | [tracking issue](https://github.com/Open330/muxa/issues/14) |

## More Docs
Expand Down
7 changes: 7 additions & 0 deletions crates/muxa-cli/src/init/components.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ pub enum Component {
GeminiHooks,
/// opencode plugin event bridge
OpencodeHooks,
/// Pi coding agent extension bridge
PiHooks,
/// `muxad` user-level systemd service (Linux only)
MuxadSystemd,
/// `muxad` `launchd` `LaunchAgent` (macOS only)
Expand All @@ -41,6 +43,7 @@ impl Component {
Component::CodexHooks,
Component::GeminiHooks,
Component::OpencodeHooks,
Component::PiHooks,
Component::MuxadSystemd,
Component::MuxadLaunchd,
Component::MuxadShellrc,
Expand All @@ -56,6 +59,7 @@ impl Component {
Component::CodexHooks => "codex-hooks",
Component::GeminiHooks => "gemini-hooks",
Component::OpencodeHooks => "opencode-hooks",
Component::PiHooks => "pi-hooks",
Component::MuxadSystemd => "muxad-systemd",
Component::MuxadLaunchd => "muxad-launchd",
Component::MuxadShellrc => "muxad-shellrc",
Expand All @@ -76,6 +80,7 @@ impl Component {
Component::CodexHooks => "OpenAI Codex: shell hooks",
Component::GeminiHooks => "Gemini CLI: shell hooks",
Component::OpencodeHooks => "opencode: plugin event bridge",
Component::PiHooks => "Pi coding agent: extension event bridge",
Component::MuxadSystemd => "muxad: systemd user service (auto-start on login)",
Component::MuxadLaunchd => "muxad: launchd LaunchAgent (auto-start on login)",
Component::MuxadShellrc => "muxad: shellrc autostart hook (no service manager)",
Expand All @@ -92,6 +97,7 @@ impl Component {
Component::CodexHooks => "auto-detect when ~/.codex/config.toml exists",
Component::GeminiHooks => "auto-detect when ~/.gemini/settings.json exists",
Component::OpencodeHooks => "installs ~/.config/opencode/plugins/muxa.ts",
Component::PiHooks => "installs ~/.pi/agent/extensions/muxa/index.ts",
Component::MuxadSystemd => "Linux only; skipped on macOS / launchd hosts",
Component::MuxadLaunchd => "macOS only; skipped on Linux / systemd hosts",
Component::MuxadShellrc => "appends to ~/.zshrc or ~/.bashrc; cross-platform",
Expand Down Expand Up @@ -136,6 +142,7 @@ impl Component {
Component::CodexHooks,
Component::GeminiHooks,
Component::OpencodeHooks,
Component::PiHooks,
dm,
],
Preset::Full => {
Expand Down
13 changes: 13 additions & 0 deletions crates/muxa-cli/src/init/detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub struct Detection {
pub claude_settings: Option<PathBuf>,
pub codex_config: Option<PathBuf>,
pub gemini_settings: Option<PathBuf>,
pub pi_agent_dir: Option<PathBuf>,
pub muxad_running: bool,
pub systemd_user_available: bool,
pub launchctl_available: bool,
Expand All @@ -36,6 +37,7 @@ impl Detection {
claude_settings: existing_file(home_join(".claude/settings.json")),
codex_config: existing_file(home_join(".codex/config.toml")),
gemini_settings: existing_file(home_join(".gemini/settings.json")),
pi_agent_dir: existing_dir(home_join(".pi/agent")),
muxad_running: muxad_is_running(),
systemd_user_available: super::files::systemd::systemd_available(),
launchctl_available: super::files::launchd::launchctl_available(),
Expand All @@ -59,6 +61,9 @@ impl Detection {
if self.gemini_settings.is_some() {
out.push(Component::GeminiHooks);
}
if self.pi_agent_dir.is_some() {
out.push(Component::PiHooks);
}
// Pre-check the daemon-manager that fits this host so the
// wizard's default produces a working install. The picker
// hides the others (filtered via `Component::applicable_here`).
Expand Down Expand Up @@ -132,6 +137,14 @@ fn existing_file(p: PathBuf) -> Option<PathBuf> {
}
}

fn existing_dir(p: PathBuf) -> Option<PathBuf> {
if p.is_dir() {
Some(p)
} else {
None
}
}

fn home_join(rel: &str) -> PathBuf {
dirs::home_dir().unwrap_or_default().join(rel)
}
Expand Down
1 change: 1 addition & 0 deletions crates/muxa-cli/src/init/files/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub mod dashboard;
pub mod gemini;
pub mod launchd;
pub mod opencode;
pub mod pi;
pub mod shellrc;
pub mod systemd;
pub mod tmux;
235 changes: 235 additions & 0 deletions crates/muxa-cli/src/init/files/pi.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
//! `~/.pi/agent/extensions/muxa/index.ts` content layer.
//!
//! Unlike the shell-hook agents (Claude/Codex/Gemini), Pi exposes an
//! in-process TypeScript extension API. This layer writes a small
//! extension that subscribes to Pi's lifecycle events and forwards a
//! normalized JSON payload to `muxa hook pi --event event` via the
//! muxa-managed `MUXA_SOCKET`.
//!
//! The extension is best-effort: a down/unreachable `muxad` must never
//! block Pi's critical path, so every forwarding call is fire-and-forget
//! with swallowed errors.

use crate::init::marker::{self, Outcome};

const ID: &str = "pi-extension";

const BODY: &str = r#"// Forward Pi lifecycle events to muxa without blocking Pi's agent loop.
//
// muxa is an observability layer; if its daemon is down every call here
// must fail silently so Pi keeps running normally. Native Pi event
// names are forwarded verbatim (the Rust `PiAdapter` discriminates on
// `type`), mirroring how the opencode plugin forwards opencode events.
import { spawn } from "node:child_process";
import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";

const SOCKET = process.env.MUXA_SOCKET ?? "";

// Resolve a stable session id. `getSessionId()` returns the canonical
// UUID for persisted and in-memory sessions; falling back to the pid
// keeps a usable identity even on hosts where the API isn't available.
function sessionId(ctx: { sessionManager?: { getSessionId?: () => string } }): string {
try {
return ctx.sessionManager?.getSessionId?.() ?? `pi-${process.pid}`;
} catch {
return `pi-${process.pid}`;
}
}

// `__MUXA_BIN__` is substituted at install time by `muxa init` with the
// absolute path to the `muxa` binary. This matters because the extension
// runs inside Pi's process, whose PATH may not include ~/.cargo/bin; a
// bare `spawn("muxa")` would then ENOENT and we'd silently lose every
// event (the error is swallowed below by design).
const MUXA_BIN = "__MUXA_BIN__";

// Cumulative cost for the current session branch, in USD. Reset on
// `session_start` and summed across `turn_end` events. See the turn_end
// handler for why this must be cumulative rather than per-message.
let sessionCostUsd = 0;

// Pull the readable text out of a Pi message. `content` is either a
// plain string (older path) or — the case that matters here — an array
// of typed blocks. We concatenate every `text` block; non-text blocks
// (thinking, tool calls, images) carry no user-facing reply text.
function extractText(msg: { content?: unknown } | undefined): string | undefined {
const content = msg?.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
const texts: string[] = [];
for (const block of content) {
if (
block !== null &&
typeof block === "object" &&
(block as { type?: string }).type === "text" &&
typeof (block as { text?: unknown }).text === "string"
) {
texts.push((block as { text: string }).text);
}
}
return texts.length > 0 ? texts.join("\n") : undefined;
}
return undefined;
}

// Forward one event object to `muxa hook pi`, serialized through a
// single in-process delivery chain so events reach muxad in the order
// Pi emitted them. Each callback returns immediately (it only *appends*
// to the chain, never awaits it), so Pi's agent loop is never blocked;
// the actual spawn + wait happens asynchronously, one delivery at a
// time. Without this, rapidly successive lifecycle events (e.g.
// `agent_end` then `session_shutdown`) would each spawn an independent
// `muxa` process racing to the same socket, and a late `agent_end`
// could resurrect an already-stopped row or a late `tool_execution_start`
// could mark a finished turn as Working again.
let deliveryChain: Promise<void> = Promise.resolve();

// Bounded safety timeout for a single delivery. cmux/muxad spawns
// return near-instantly; this only guards against a wedged spawn
// freezing the whole chain. `unref()`d so the timer can never keep
// Pi's process alive on its own.
const DELIVERY_TIMEOUT_MS = 5_000;

function forward(type: string, payload: Record<string, unknown>, ctx: ExtensionContextLike) {
deliveryChain = deliveryChain
.then(() => deliverOne(type, payload, ctx))
// A rejected step must not break subsequent deliveries — an error
// in one event should never swallow the next.
.catch(() => {});
}

function deliverOne(
type: string,
payload: Record<string, unknown>,
ctx: ExtensionContextLike,
): Promise<void> {
return new Promise((resolve) => {
let settled = false;
const done = () => {
if (!settled) {
settled = true;
resolve();
}
};
try {
const body = JSON.stringify({
type,
session_id: sessionId(ctx),
cwd: ctx.cwd,
pane: process.env.TMUX_PANE ?? process.env.ZELLIJ_PANE_ID,
pid: process.pid,
...payload,
});
const child = spawn(MUXA_BIN, ["hook", "pi", "--event", "event"], {
stdio: ["pipe", "ignore", "ignore"],
detached: true,
env: SOCKET ? { ...process.env, MUXA_SOCKET: SOCKET } : process.env,
});
// Resolve on any terminal event so the next delivery can start.
// Multiple events firing is fine — `settled` makes them idempotent.
child.on("error", done);
child.on("exit", done);
child.on("close", done);
const timer = setTimeout(done, DELIVERY_TIMEOUT_MS);
timer.unref?.();
child.stdin?.end(body);
child.unref();
} catch {
// swallow — observability must never break the agent
done();
}
});
}

type ExtensionContextLike = {
cwd?: string;
model?: { id?: string };
sessionManager?: { getSessionId?: () => string };
};

export default function (pi: ExtensionAPI) {
// session lifecycle
pi.on("session_start", async (_event, ctx) => {
// Reset the per-session cost accumulator: a session_start marks a
// fresh session branch, so costs from a previous turn sequence
// must not carry over.
sessionCostUsd = 0;
forward("session_start", {}, ctx);
});

pi.on("session_shutdown", async (_event, ctx) => {
forward("session_shutdown", {}, ctx);
});

// prompt submission — carries the user's prompt text
pi.on("before_agent_start", async (event, ctx) => {
forward("before_agent_start", { prompt: event.prompt }, ctx);
});

// tool execution lifecycle — the *observation* layer, not the
// preflight `tool_call` layer. `tool_execution_end` carries `isError`
// and the final `result`, which is what we want for success/fail.
pi.on("tool_execution_start", async (event, ctx) => {
forward("tool_execution_start", { tool: event.toolName }, ctx);
});

pi.on("tool_execution_end", async (event, ctx) => {
forward("tool_execution_end", {
tool: event.toolName,
success: !event.isError,
}, ctx);
});

// turn boundary — refresh model + cost info once per turn rather than
// per streamed message, keeping daemon churn low.
// Cost is reported as a session-branch cumulative total, not the
// per-message `usage.cost.total`: the daemon's `apply_heartbeat`
// overwrites `Agent.cost_usd` and the UI renders it as the running
// session cost, so sending a per-message value would make the number
// jump down on a cheaper turn. We accumulate across turns and reset
// on `session_start` (handled below).
pi.on("turn_end", async (event, ctx) => {
const usage = (event.message?.usage as { cost?: { total?: number } } | undefined);
const turnCost = usage?.cost?.total;
if (typeof turnCost === "number") sessionCostUsd += turnCost;
forward("turn_end", {
model: ctx.model?.id,
cost_usd: sessionCostUsd,
}, ctx);
});

// agent loop end — final assistant message text, when extractable.
// Pi's `AssistantMessage.content` is an array of typed blocks
// (text / thinking / tool-call …), not a bare string, so we walk the
// last message and concatenate its text blocks. Without this the
// adapter never sees a `response`, `last_response` stays empty, and a
// successful `TurnStopped` can't clear a prior Error state.
pi.on("agent_end", async (event, ctx) => {
const last = event.messages?.at(-1);
forward("agent_end", { response: extractText(last) }, ctx);
});
}
"#;

/// Default install path: `~/.pi/agent/extensions/muxa/index.ts`.
///
/// Pi auto-discovers extensions from subdirectories of
/// `~/.pi/agent/extensions/`, loading `index.ts` as the entry point.
pub fn default_path() -> Option<std::path::PathBuf> {
dirs::home_dir().map(|h| {
h.join(".pi")
.join("agent")
.join("extensions")
.join("muxa")
.join("index.ts")
})
}

pub fn upsert(original: &str) -> (String, Outcome) {
let body = BODY.replace("__MUXA_BIN__", &super::super::util::locate_muxa());
marker::upsert_with(original, ID, &body, marker::CommentStyle::Slash)
}

pub fn remove(original: &str) -> (String, Outcome) {
marker::remove_with(original, ID, marker::CommentStyle::Slash)
}
Loading