From db484b16c297d0a3c56718c11f9fd6d3dbe1ffe2 Mon Sep 17 00:00:00 2001 From: Ben Tang Date: Wed, 6 May 2026 08:09:33 +0800 Subject: [PATCH 1/3] feat(mux): add Kaku terminal emulator backend Kaku is a WezTerm fork that keeps the identical CLI interface but uses the KAKU_UNIX_SOCKET env var and kaku binary instead of WEZTERM_UNIX_SOCKET and wezterm. Adds isKakuRuntimeAvailable() for detection via KAKU_UNIX_SOCKET and adds kaku variants for all mux operations: split-pane, send-text, kill-pane, get-text, set-tab-title, set-window-title. Kaku is detected before WezTerm since KAKU_UNIX_SOCKET is unique. --- pi-extension/subagents/cmux.ts | 106 ++++++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 3 deletions(-) diff --git a/pi-extension/subagents/cmux.ts b/pi-extension/subagents/cmux.ts index d0312fd..0f5d08b 100644 --- a/pi-extension/subagents/cmux.ts +++ b/pi-extension/subagents/cmux.ts @@ -6,7 +6,7 @@ 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" | "kaku"; const commandAvailability = new Map(); @@ -43,7 +43,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 (pref === "cmux" || pref === "tmux" || pref === "zellij" || pref === "wezterm" || pref === "kaku") return pref; return null; } @@ -63,6 +63,10 @@ function isWezTermRuntimeAvailable(): boolean { return !!process.env.WEZTERM_UNIX_SOCKET && hasCommand("wezterm"); } +function isKakuRuntimeAvailable(): boolean { + return !!process.env.KAKU_UNIX_SOCKET && hasCommand("kaku"); +} + export function isCmuxAvailable(): boolean { return isCmuxRuntimeAvailable(); } @@ -79,16 +83,22 @@ export function isWezTermAvailable(): boolean { return isWezTermRuntimeAvailable(); } +export function isKakuAvailable(): boolean { + return isKakuRuntimeAvailable(); +} + export function getMuxBackend(): MuxBackend | null { const pref = muxPreference(); if (pref === "cmux") return isCmuxRuntimeAvailable() ? "cmux" : null; if (pref === "tmux") return isTmuxRuntimeAvailable() ? "tmux" : null; if (pref === "zellij") return isZellijRuntimeAvailable() ? "zellij" : null; if (pref === "wezterm") return isWezTermRuntimeAvailable() ? "wezterm" : null; + if (pref === "kaku") return isKakuRuntimeAvailable() ? "kaku" : null; if (isCmuxRuntimeAvailable()) return "cmux"; if (isTmuxRuntimeAvailable()) return "tmux"; if (isZellijRuntimeAvailable()) return "zellij"; + if (isKakuRuntimeAvailable()) return "kaku"; if (isWezTermRuntimeAvailable()) return "wezterm"; return null; } @@ -111,7 +121,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 === "kaku") { + return "Start pi inside Kaku."; + } + return "Start pi inside cmux (`cmux pi`), tmux (`tmux new -A -s pi 'pi'`), zellij (`zellij --session pi`, then run `pi`), Kaku, or WezTerm."; } function requireMuxBackend(): MuxBackend { @@ -870,6 +883,30 @@ export function createSurfaceSplit( return paneId; } + if (backend === "kaku") { + const args = ["cli", "split-pane"]; + if (direction === "left") args.push("--left"); + else if (direction === "right") args.push("--right"); + else if (direction === "up") args.push("--top"); + else args.push("--bottom"); + args.push("--cwd", process.cwd()); + if (fromSurface) { + args.push("--pane-id", fromSurface); + } + const paneId = execFileSync("kaku", args, { encoding: "utf8" }).trim(); + if (!paneId || !/^\d+$/.test(paneId)) { + throw new Error(`Unexpected kaku split-pane output: ${paneId || "(empty)"}`); + } + try { + execFileSync("kaku", ["cli", "set-tab-title", "--pane-id", paneId, name], { + encoding: "utf8", + }); + } catch { + // Optional — tab title is cosmetic. + } + return paneId; + } + // zellij const directionArg = direction === "left" || direction === "right" ? "right" : "down"; const args = ["new-pane", "--direction", directionArg, "--name", name, "--cwd", process.cwd()]; @@ -941,6 +978,15 @@ export function renameCurrentTab(title: string): void { return; } + if (backend === "kaku") { + const paneId = process.env.WEZTERM_PANE; + const args = ["cli", "set-tab-title"]; + if (paneId) args.push("--pane-id", paneId); + args.push(title); + execFileSync("kaku", args, { encoding: "utf8" }); + 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. @@ -996,6 +1042,19 @@ export function renameWorkspace(title: string): void { return; } + if (backend === "kaku") { + const paneId = process.env.WEZTERM_PANE; + const args = ["cli", "set-window-title"]; + if (paneId) args.push("--pane-id", paneId); + args.push(title); + try { + execFileSync("kaku", args, { encoding: "utf8" }); + } catch { + // Optional — window title is cosmetic. + } + 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 @@ -1033,6 +1092,15 @@ export function sendCommand(surface: string, command: string): void { return; } + if (backend === "kaku") { + execFileSync( + "kaku", + ["cli", "send-text", "--pane-id", surface, "--no-paste", command + "\n"], + { encoding: "utf8" }, + ); + return; + } + zellijActionSync(["write-chars", command], surface); zellijActionSync(["write", "13"], surface); } @@ -1060,6 +1128,13 @@ export function sendEscape(surface: string): void { return; } + if (backend === "kaku") { + execFileSync("kaku", ["cli", "send-text", "--pane-id", surface, "--no-paste", "\u001b"], { + encoding: "utf8", + }); + return; + } + zellijActionSync(["write", "27"], surface); } @@ -1132,6 +1207,15 @@ export function readScreen(surface: string, lines = 50): string { return tailLines(raw, lines); } + if (backend === "kaku") { + const raw = execFileSync( + "kaku", + ["cli", "get-text", "--pane-id", surface], + { encoding: "utf8" }, + ); + return tailLines(raw, 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. @@ -1177,6 +1261,15 @@ export async function readScreenAsync(surface: string, lines = 50): Promise Date: Wed, 6 May 2026 08:24:07 +0800 Subject: [PATCH 2/3] refactor(mux): deduplicate wezterm/kaku CLI operations Extract isWezTermLikeBackend() and getWezTermLikeBinary() helpers so kaku reuses the same CLI operation blocks instead of duplicating them for every backend function. --- pi-extension/subagents/cmux.ts | 133 +++++++-------------------------- 1 file changed, 28 insertions(+), 105 deletions(-) diff --git a/pi-extension/subagents/cmux.ts b/pi-extension/subagents/cmux.ts index 0f5d08b..5f35fbb 100644 --- a/pi-extension/subagents/cmux.ts +++ b/pi-extension/subagents/cmux.ts @@ -67,6 +67,15 @@ function isKakuRuntimeAvailable(): boolean { return !!process.env.KAKU_UNIX_SOCKET && hasCommand("kaku"); } +/** True for backends that share WezTerm's CLI interface (wezterm, kaku). */ +function isWezTermLikeBackend(backend: MuxBackend): backend is "wezterm" | "kaku" { + return backend === "wezterm" || backend === "kaku"; +} + +function getWezTermLikeBinary(backend: MuxBackend): string { + return backend === "kaku" ? "kaku" : "wezterm"; +} + export function isCmuxAvailable(): boolean { return isCmuxRuntimeAvailable(); } @@ -859,7 +868,8 @@ export function createSurfaceSplit( return pane; } - if (backend === "wezterm") { + if (isWezTermLikeBackend(backend)) { + const binary = getWezTermLikeBinary(backend); const args = ["cli", "split-pane"]; if (direction === "left") args.push("--left"); else if (direction === "right") args.push("--right"); @@ -869,36 +879,12 @@ export function createSurfaceSplit( if (fromSurface) { args.push("--pane-id", fromSurface); } - const paneId = execFileSync("wezterm", args, { encoding: "utf8" }).trim(); + const paneId = execFileSync(binary, args, { encoding: "utf8" }).trim(); if (!paneId || !/^\d+$/.test(paneId)) { - throw new Error(`Unexpected wezterm split-pane output: ${paneId || "(empty)"}`); + throw new Error(`Unexpected ${binary} split-pane output: ${paneId || "(empty)"}`); } try { - execFileSync("wezterm", ["cli", "set-tab-title", "--pane-id", paneId, name], { - encoding: "utf8", - }); - } catch { - // Optional — tab title is cosmetic. - } - return paneId; - } - - if (backend === "kaku") { - const args = ["cli", "split-pane"]; - if (direction === "left") args.push("--left"); - else if (direction === "right") args.push("--right"); - else if (direction === "up") args.push("--top"); - else args.push("--bottom"); - args.push("--cwd", process.cwd()); - if (fromSurface) { - args.push("--pane-id", fromSurface); - } - const paneId = execFileSync("kaku", args, { encoding: "utf8" }).trim(); - if (!paneId || !/^\d+$/.test(paneId)) { - throw new Error(`Unexpected kaku split-pane output: ${paneId || "(empty)"}`); - } - try { - execFileSync("kaku", ["cli", "set-tab-title", "--pane-id", paneId, name], { + execFileSync(binary, ["cli", "set-tab-title", "--pane-id", paneId, name], { encoding: "utf8", }); } catch { @@ -969,21 +955,12 @@ export function renameCurrentTab(title: string): void { return; } - if (backend === "wezterm") { - const paneId = process.env.WEZTERM_PANE; - const args = ["cli", "set-tab-title"]; - if (paneId) args.push("--pane-id", paneId); - args.push(title); - execFileSync("wezterm", args, { encoding: "utf8" }); - return; - } - - if (backend === "kaku") { + if (isWezTermLikeBackend(backend)) { const paneId = process.env.WEZTERM_PANE; const args = ["cli", "set-tab-title"]; if (paneId) args.push("--pane-id", paneId); args.push(title); - execFileSync("kaku", args, { encoding: "utf8" }); + execFileSync(getWezTermLikeBinary(backend), args, { encoding: "utf8" }); return; } @@ -1029,26 +1006,13 @@ export function renameWorkspace(title: string): void { return; } - if (backend === "wezterm") { + if (isWezTermLikeBackend(backend)) { const paneId = process.env.WEZTERM_PANE; const args = ["cli", "set-window-title"]; if (paneId) args.push("--pane-id", paneId); args.push(title); try { - execFileSync("wezterm", args, { encoding: "utf8" }); - } catch { - // Optional — window title is cosmetic. - } - return; - } - - if (backend === "kaku") { - const paneId = process.env.WEZTERM_PANE; - const args = ["cli", "set-window-title"]; - if (paneId) args.push("--pane-id", paneId); - args.push(title); - try { - execFileSync("kaku", args, { encoding: "utf8" }); + execFileSync(getWezTermLikeBinary(backend), args, { encoding: "utf8" }); } catch { // Optional — window title is cosmetic. } @@ -1083,18 +1047,9 @@ export function sendCommand(surface: string, command: string): void { return; } - if (backend === "wezterm") { - execFileSync( - "wezterm", - ["cli", "send-text", "--pane-id", surface, "--no-paste", command + "\n"], - { encoding: "utf8" }, - ); - return; - } - - if (backend === "kaku") { + if (isWezTermLikeBackend(backend)) { execFileSync( - "kaku", + getWezTermLikeBinary(backend), ["cli", "send-text", "--pane-id", surface, "--no-paste", command + "\n"], { encoding: "utf8" }, ); @@ -1121,15 +1076,8 @@ export function sendEscape(surface: string): void { return; } - if (backend === "wezterm") { - execFileSync("wezterm", ["cli", "send-text", "--pane-id", surface, "--no-paste", "\u001b"], { - encoding: "utf8", - }); - return; - } - - if (backend === "kaku") { - execFileSync("kaku", ["cli", "send-text", "--pane-id", surface, "--no-paste", "\u001b"], { + if (isWezTermLikeBackend(backend)) { + execFileSync(getWezTermLikeBinary(backend), ["cli", "send-text", "--pane-id", surface, "--no-paste", "\u001b"], { encoding: "utf8", }); return; @@ -1198,18 +1146,9 @@ export function readScreen(surface: string, lines = 50): string { ); } - if (backend === "wezterm") { - const raw = execFileSync( - "wezterm", - ["cli", "get-text", "--pane-id", surface], - { encoding: "utf8" }, - ); - return tailLines(raw, lines); - } - - if (backend === "kaku") { + if (isWezTermLikeBackend(backend)) { const raw = execFileSync( - "kaku", + getWezTermLikeBinary(backend), ["cli", "get-text", "--pane-id", surface], { encoding: "utf8" }, ); @@ -1252,18 +1191,9 @@ export async function readScreenAsync(surface: string, lines = 50): Promise Date: Wed, 6 May 2026 08:29:34 +0800 Subject: [PATCH 3/3] refactor(mux): infer binary from KAKU_UNIX_SOCKET instead of backend param getWezTermLikeBinary() now reads KAKU_UNIX_SOCKET directly from the environment rather than branching on the backend parameter. No callers need to pass an argument. --- pi-extension/subagents/cmux.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pi-extension/subagents/cmux.ts b/pi-extension/subagents/cmux.ts index 5f35fbb..c789901 100644 --- a/pi-extension/subagents/cmux.ts +++ b/pi-extension/subagents/cmux.ts @@ -72,8 +72,8 @@ function isWezTermLikeBackend(backend: MuxBackend): backend is "wezterm" | "kaku return backend === "wezterm" || backend === "kaku"; } -function getWezTermLikeBinary(backend: MuxBackend): string { - return backend === "kaku" ? "kaku" : "wezterm"; +function getWezTermLikeBinary(): string { + return process.env.KAKU_UNIX_SOCKET ? "kaku" : "wezterm"; } export function isCmuxAvailable(): boolean { @@ -869,7 +869,7 @@ export function createSurfaceSplit( } if (isWezTermLikeBackend(backend)) { - const binary = getWezTermLikeBinary(backend); + const binary = getWezTermLikeBinary(); const args = ["cli", "split-pane"]; if (direction === "left") args.push("--left"); else if (direction === "right") args.push("--right"); @@ -960,7 +960,7 @@ export function renameCurrentTab(title: string): void { const args = ["cli", "set-tab-title"]; if (paneId) args.push("--pane-id", paneId); args.push(title); - execFileSync(getWezTermLikeBinary(backend), args, { encoding: "utf8" }); + execFileSync(getWezTermLikeBinary(), args, { encoding: "utf8" }); return; } @@ -1012,7 +1012,7 @@ export function renameWorkspace(title: string): void { if (paneId) args.push("--pane-id", paneId); args.push(title); try { - execFileSync(getWezTermLikeBinary(backend), args, { encoding: "utf8" }); + execFileSync(getWezTermLikeBinary(), args, { encoding: "utf8" }); } catch { // Optional — window title is cosmetic. } @@ -1049,7 +1049,7 @@ export function sendCommand(surface: string, command: string): void { if (isWezTermLikeBackend(backend)) { execFileSync( - getWezTermLikeBinary(backend), + getWezTermLikeBinary(), ["cli", "send-text", "--pane-id", surface, "--no-paste", command + "\n"], { encoding: "utf8" }, ); @@ -1077,7 +1077,7 @@ export function sendEscape(surface: string): void { } if (isWezTermLikeBackend(backend)) { - execFileSync(getWezTermLikeBinary(backend), ["cli", "send-text", "--pane-id", surface, "--no-paste", "\u001b"], { + execFileSync(getWezTermLikeBinary(), ["cli", "send-text", "--pane-id", surface, "--no-paste", "\u001b"], { encoding: "utf8", }); return; @@ -1148,7 +1148,7 @@ export function readScreen(surface: string, lines = 50): string { if (isWezTermLikeBackend(backend)) { const raw = execFileSync( - getWezTermLikeBinary(backend), + getWezTermLikeBinary(), ["cli", "get-text", "--pane-id", surface], { encoding: "utf8" }, ); @@ -1193,7 +1193,7 @@ export async function readScreenAsync(surface: string, lines = 50): Promise