Skip to content
Open
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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Supported multiplexers:
- [tmux](https://github.com/tmux/tmux)
- [zellij](https://zellij.dev)
- [WezTerm](https://wezfurlong.org/wezterm/) (terminal emulator with built-in multiplexing)
- [herdr](https://herdr.dev)

Start pi inside one of them:

Expand All @@ -46,17 +47,19 @@ tmux new -A -s pi 'pi'
zellij --session pi # then run: pi
# or
# just run pi inside WezTerm — no wrapper needed
# or
herdr # then run: pi
```

Optional: set `PI_SUBAGENT_MUX=cmux|tmux|zellij|wezterm` to force a specific backend.
Optional: set `PI_SUBAGENT_MUX=cmux|tmux|zellij|wezterm|herdr` to force a specific backend.

If your shell startup is slow and subagent commands sometimes get dropped before the prompt is ready, set `PI_SUBAGENT_SHELL_READY_DELAY_MS` to a higher value (defaults to `500`):

```bash
export PI_SUBAGENT_SHELL_READY_DELAY_MS=2500
```

Subagent panes are created without stealing keyboard focus (cmux, tmux). Launch commands target child surfaces by explicit ID, so focus and command delivery are independent. Note: the `interactive` option controls parent status notifications, not terminal focus.
Subagent panes are created without stealing keyboard focus (cmux, tmux, herdr). Launch commands target child surfaces by explicit ID, so focus and command delivery are independent. Note: the `interactive` option controls parent status notifications, not terminal focus.

## What's Included

Expand Down
68 changes: 63 additions & 5 deletions pi-extension/subagents/cmux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,23 @@ import { execSync, execFile, execFileSync, spawnSync } from "node:child_process"
import { promisify } from "node:util";
import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import {
createHerdrSurface,
createHerdrSurfaceSplit,
readHerdrScreen,
readHerdrScreenAsync,
sendHerdrCommand,
sendHerdrEscape,
closeHerdrSurface,
renameHerdrTab,
renameHerdrWorkspace,
isHerdrAvailable,
} from "./herdr.ts";
import { basename, dirname, join } from "node:path";

const execFileAsync = promisify(execFile);

export type MuxBackend = "cmux" | "tmux" | "zellij" | "wezterm";
export type MuxBackend = "cmux" | "tmux" | "zellij" | "wezterm" | "herdr";

const commandAvailability = new Map<string, boolean>();

Expand Down Expand Up @@ -43,7 +55,7 @@ function hasCommand(command: string): boolean {

function muxPreference(): MuxBackend | null {
const pref = (process.env.PI_SUBAGENT_MUX ?? "").trim().toLowerCase();
if (pref === "cmux" || pref === "tmux" || pref === "zellij" || pref === "wezterm") return pref;
if (["cmux", "tmux", "zellij", "wezterm", "herdr"].includes(pref)) return pref as MuxBackend;
return null;
}

Expand Down Expand Up @@ -85,11 +97,13 @@ export function getMuxBackend(): MuxBackend | null {
if (pref === "tmux") return isTmuxRuntimeAvailable() ? "tmux" : null;
if (pref === "zellij") return isZellijRuntimeAvailable() ? "zellij" : null;
if (pref === "wezterm") return isWezTermRuntimeAvailable() ? "wezterm" : null;
if (pref === "herdr") return isHerdrAvailable() ? "herdr" : null;

if (isCmuxRuntimeAvailable()) return "cmux";
if (isTmuxRuntimeAvailable()) return "tmux";
if (isZellijRuntimeAvailable()) return "zellij";
if (isWezTermRuntimeAvailable()) return "wezterm";
if (isHerdrAvailable()) return "herdr";
return null;
}

Expand All @@ -111,7 +125,10 @@ export function muxSetupHint(): string {
if (pref === "wezterm") {
return "Start pi inside WezTerm.";
}
return "Start pi inside cmux (`cmux pi`), tmux (`tmux new -A -s pi 'pi'`), zellij (`zellij --session pi`, then run `pi`), or WezTerm.";
if (pref === "herdr") {
return "Start pi inside herdr (`herdr`, then run `pi`).";
}
return "Start pi inside cmux (`cmux pi`), tmux (`tmux new -A -s pi 'pi'`), zellij (`zellij --session pi`, then run `pi`), WezTerm, or herdr (`herdr`, then run `pi`).";
}

function requireMuxBackend(): MuxBackend {
Expand Down Expand Up @@ -749,11 +766,15 @@ function createCmuxSplitSurface(
* For zellij: chooses a tab-aware tiled or stacked placement.
* For tmux/wezterm: falls back to split behavior.
*
* Returns an identifier (`surface:42` in cmux, `%12` in tmux, `pane:7` in zellij, `42` in wezterm).
* Returns an identifier (`surface:42` in cmux, `%12` in tmux, `pane:7` in zellij, `42` in wezterm, `1-2` in herdr).
*/
export function createSurface(name: string): string {
const backend = getMuxBackend();

if (backend === "herdr") {
return createHerdrSurface(name);
}

if (backend === "cmux" && cmuxSubagentPane) {
// Verify the pane still exists before adding a tab to it
try {
Expand Down Expand Up @@ -810,7 +831,7 @@ function createSurfaceInPane(name: string, pane: string): string {

/**
* Create a new split in the given direction from an optional source pane.
* Returns an identifier (`surface:42` in cmux, `%12` in tmux, `pane:7` in zellij, `42` in wezterm).
* Returns an identifier (`surface:42` in cmux, `%12` in tmux, `pane:7` in zellij, `42` in wezterm, `1-2` in herdr).
*/
export function createSurfaceSplit(
name: string,
Expand All @@ -819,6 +840,10 @@ export function createSurfaceSplit(
): string {
const backend = requireMuxBackend();

if (backend === "herdr") {
return createHerdrSurfaceSplit(name, direction);
}

if (backend === "cmux") {
return createCmuxSplitSurface(name, direction, fromSurface).surface;
}
Expand Down Expand Up @@ -941,6 +966,11 @@ export function renameCurrentTab(title: string): void {
return;
}

if (backend === "herdr") {
renameHerdrTab(title);
return;
}

// zellij: rename the agent's own pane, not the whole tab. In multi-pane layouts,
// rename-tab clobbers the user's tab title whenever a subagent starts or /plan runs.
// Closes #21.
Expand Down Expand Up @@ -996,6 +1026,11 @@ export function renameWorkspace(title: string): void {
return;
}

if (backend === "herdr") {
renameHerdrWorkspace(title);
return;
}

// Skip session rename for zellij. rename-session renames the socket file
// but the ZELLIJ_SESSION_NAME env var in the parent process keeps the old
// name, so all subsequent `zellij action ...` CLI calls fail with
Expand Down Expand Up @@ -1033,6 +1068,11 @@ export function sendCommand(surface: string, command: string): void {
return;
}

if (backend === "herdr") {
sendHerdrCommand(surface, command);
return;
}

zellijActionSync(["write-chars", command], surface);
zellijActionSync(["write", "13"], surface);
}
Expand Down Expand Up @@ -1060,6 +1100,11 @@ export function sendEscape(surface: string): void {
return;
}

if (backend === "herdr") {
sendHerdrEscape(surface);
return;
}

zellijActionSync(["write", "27"], surface);
}

Expand Down Expand Up @@ -1132,6 +1177,10 @@ export function readScreen(surface: string, lines = 50): string {
return tailLines(raw, lines);
}

if (backend === "herdr") {
return readHerdrScreen(surface, lines);
}

// Zellij 0.44+: use --pane-id flag + stdout instead of env var + temp file.
// The ZELLIJ_PANE_ID env var doesn't reliably target other panes for dump-screen,
// and --path may silently fail to create the file. Stdout capture is robust.
Expand Down Expand Up @@ -1177,6 +1226,10 @@ export async function readScreenAsync(surface: string, lines = 50): Promise<stri
return tailLines(stdout, lines);
}

if (backend === "herdr") {
return readHerdrScreenAsync(surface, lines);
}

// Zellij 0.44+: use --pane-id flag + stdout instead of env var + temp file.
const paneId = zellijPaneId(surface);
const { stdout } = await execFileAsync(
Expand Down Expand Up @@ -1212,6 +1265,11 @@ export function closeSurface(surface: string): void {
return;
}

if (backend === "herdr") {
closeHerdrSurface(surface);
return;
}

zellijActionSync(["close-pane"], surface);
}

Expand Down
Loading