From 630d5bcdabdfbef88898d5ab32384ca774c7feee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Jacques=20Martr=C3=A8s?= Date: Mon, 20 Apr 2026 10:18:30 +0200 Subject: [PATCH] feat: add pi-mono support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pi (https://github.com/badlogic/pi-mono) as a supported source. Pi is a terminal-based AI coding agent. Integration is via a TypeScript extension (codeisland-pi.ts) that forwards pi lifecycle events to the CodeIsland Unix socket, following the same hook-event protocol as other supported tools. Changes: - SessionSnapshot: add "pi" to supportedSources and displayName - AppState: add findPiPids() and route "pi" in findPids(forSource:) - Resources: add codeisland-pi.ts extension and pi.png icon placeholder The extension maps pi events to CodeIsland hook_event_names: session_start → SessionStart session_shutdown → SessionEnd before_agent_start → UserPromptSubmit tool_call (safe) → PreToolUse tool_call (dangerous) → PermissionRequest (blocking via bridge) tool_result → PostToolUse agent_end → Stop session_before_compact → PreCompact session_compact → PostCompact Installation: drop codeisland-pi.ts into ~/.pi/agent/extensions/ and reload pi with /reload. No settings.json changes required. Note: pi.png is a placeholder — a proper pixel mascot and icon can be contributed as a follow-up. --- Sources/CodeIsland/AppState.swift | 14 + Sources/CodeIsland/Resources/cli-icons/pi.png | Bin 0 -> 1541 bytes Sources/CodeIsland/Resources/codeisland-pi.ts | 400 ++++++++++++++++++ Sources/CodeIslandCore/SessionSnapshot.swift | 2 + 4 files changed, 416 insertions(+) create mode 100644 Sources/CodeIsland/Resources/cli-icons/pi.png create mode 100644 Sources/CodeIsland/Resources/codeisland-pi.ts diff --git a/Sources/CodeIsland/AppState.swift b/Sources/CodeIsland/AppState.swift index 0c5194c..0ca91dc 100644 --- a/Sources/CodeIsland/AppState.swift +++ b/Sources/CodeIsland/AppState.swift @@ -618,6 +618,7 @@ final class AppState { case "hermes": return findHermesPids(candidatePids: candidatePids) case "qwen": return findQwenPids(candidatePids: candidatePids) case "kimi": return findKimiPids(candidatePids: candidatePids) + case "pi": return findPiPids(candidatePids: candidatePids) default: return [] } } @@ -2351,6 +2352,19 @@ final class AppState { ) } + private nonisolated static func findPiPids(candidatePids: [pid_t]? = nil) -> [pid_t] { + findPids( + matchingPathSubstrings: [ + "/pi-coding-agent/", + "/bin/pi", + ], + argSubstrings: [ + "pi-coding-agent", + ], + candidatePids: candidatePids + ) + } + private nonisolated static func md5Hash(of string: String) -> String { let digest = Insecure.MD5.hash(data: Data(string.utf8)) return digest.map { String(format: "%02x", $0) }.joined() diff --git a/Sources/CodeIsland/Resources/cli-icons/pi.png b/Sources/CodeIsland/Resources/cli-icons/pi.png new file mode 100644 index 0000000000000000000000000000000000000000..70fd01b0ea38ea8b3d0429a6438f5b8e85aba94a GIT binary patch literal 1541 zcmaJ>eKb^Q7{4Ur0=8rcXMfNrMx!Z`2J5r}m{7q=oI5Z4;q`f(Bo2BGNsu z_o*M^xIMiCe~;%%@XdW@9C6U|=3EF(Xy3J&Mnv)+onDt<%fnSpp|orWz33X39|qvAImHaraYT6ua6s5eeo7(qmH2LMX#~Pu2;Tnp)=eVBAS5%uY|{(3wuB zAapme=5QR?*2MU+i^ao>T30eu5Zd{wQ@+ZGno1dH3BTocen+^M)MNL^v8uk#$Zq?I z%@%6)NV%y0;oD~kM$#S1?GRjgwP!pQ{x`l^OZ@Y-Sb<3otdEN&B0Gw!W}D2I@{l2u zuwn>3!;aHkC&9xO4Yv!~RpprP?wSlwBI39;_4!?-dnmpw?!Q=fcUHoZd-O?O@D$h* z=K60K1pZXi%Q6$dLa4UWf-c&ORa8`Hrl;5SPRgF&Q|Ccw(z0?*!*ML*@LQ+JW*hD? z?2vx^2D4PXwiOfte^e8Al1$(yFq5Bs%ER5I;%cyvNtNy-14|BNmk*%)-+J>w&c+K* zW&AZr?A1wdT(c&ASOJ96+iQQ)0*#~_viFD-7&-Fs-ZXFmEm^+iZ{yhneh(>TdI0q1 zphvmNM}e(UhHh{p@bicBH8DVEei4QF{>+VWB|@NSEyiUGGldQ^HRWMybq+#oPK!0uXpM50A2a#48$6R-P=pz-9TF_%;W)Wir+`pO z;&-{gD-Y?eIszbgl4xEEB)ld$b}mzG+8CiJvNPw%n)GWv#32?OS;Zv~kk3xHn{&b| zE}4Ibb)U&^ePw0=+`*3yIV#4D9X`1)`WssZ;sKSF^R_e95r8e<4G#X6TGF;U2gwt) zKDZ1cGYrlbdhM?EJiL7E{(8`j%3;MHDxlY(61Om5hyb@Ik-@-yk4tU@fFAj*wDAVH z-*y$fV-fi0zyIZ*;8}->ds25n1QnR7434S=nsQtG(gg;+2t=G8gLy*FJ7|SKsNwRR ztp>l{!s@~ZOy7fcw+>K3a) z2t9JGBVr0PnAp|meV%Gu3p!47j_uJa=@{{jVjoU;G` literal 0 HcmV?d00001 diff --git a/Sources/CodeIsland/Resources/codeisland-pi.ts b/Sources/CodeIsland/Resources/codeisland-pi.ts new file mode 100644 index 0000000..52383ad --- /dev/null +++ b/Sources/CodeIsland/Resources/codeisland-pi.ts @@ -0,0 +1,400 @@ +/** + * @fileoverview CodeIsland Integration Extension. + * + * Bridges the running pi session to the CodeIsland macOS floating-window app + * (https://github.com/wxtsky/CodeIsland) by forwarding lifecycle events over + * the Unix domain socket CodeIsland listens on. + * + * Architecture: + * pi (this extension) ──→ /tmp/codeisland-{uid}.sock ──→ CodeIsland.app + * + * The extension is a socket CLIENT — no server is started. If CodeIsland is not + * running the socket does not exist and all send calls fail silently. + * + * Event mapping: + * session_start → SessionStart + * session_shutdown → SessionEnd + * before_agent_start → UserPromptSubmit + * tool_call → PreToolUse (or PermissionRequest for dangerous bash) + * tool_result → PostToolUse + * agent_end → Stop + * session_before_compact → PreCompact + * session_compact → PostCompact + * + * Permission handling: + * Dangerous bash commands (`rm -rf`, `sudo`, `chmod 777`) are intercepted and + * sent as a blocking PermissionRequest via the codeisland-bridge binary. The + * extension waits for CodeIsland's decision and returns allow/block accordingly. + * This replaces the built-in permission-gate.ts when CodeIsland is active. + * + * Installation: + * Drop this file in ~/.pi/agent/extensions/ — it is auto-discovered. + * + * Requirements: + * - CodeIsland.app running on the same machine + * - "pi" added to CodeIsland's supported sources (see PR instructions) + */ + +import { connect } from "net"; +import { execFileSync, execFile } from "child_process"; +import { existsSync } from "fs"; +import { homedir } from "os"; +import { getuid } from "process"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import type { AssistantMessage } from "@mariozechner/pi-ai"; + +// ── Socket / bridge constants ───────────────────────────────────────────────── + +/** Unix socket path CodeIsland listens on (user-scoped). */ +const SOCKET_PATH = `/tmp/codeisland-${getuid()}.sock`; + +/** + * Bridge binary path. Used for blocking permission requests because Node's + * half-close (`sock.end()`) causes NWConnection to close before the response + * arrives on macOS; the bridge uses POSIX `shutdown(SHUT_WR)` which works. + */ +const BRIDGE_PATH = `${homedir()}/.codeisland/codeisland-bridge`; + +/** Environment variable keys forwarded to CodeIsland for terminal detection. */ +const ENV_KEYS = [ + "TERM_PROGRAM", + "ITERM_SESSION_ID", + "TERM_SESSION_ID", + "TMUX", + "TMUX_PANE", + "KITTY_WINDOW_ID", + "__CFBundleIdentifier", +] as const; + +// ── Dangerous bash patterns (mirrors permission-gate.ts) ────────────────────── + +const DANGEROUS_PATTERNS: RegExp[] = [ + /\brm\s+(-rf?|--recursive)/i, + /\bsudo\b/i, + /\b(chmod|chown)\b.*777/i, +]; + +function isDangerous(command: string): boolean { + return DANGEROUS_PATTERNS.some((p) => p.test(command)); +} + +// ── Environment / TTY helpers ───────────────────────────────────────────────── + +/** Collects relevant terminal environment variables. */ +function collectEnv(): Record { + const env: Record = {}; + for (const key of ENV_KEYS) { + if (process.env[key]) env[key] = process.env[key]!; + } + return env; +} + +/** + * Walks the process tree upward to find the controlling TTY. + * Cached at startup — pi's TTY does not change during a session. + */ +function detectTty(): string | null { + try { + let pid = process.pid; + for (let i = 0; i < 8; i++) { + const out = execFileSync("ps", ["-o", "tty=,ppid=", "-p", String(pid)], { + timeout: 1000, + }) + .toString() + .trim(); + const [tty, ppidStr] = out.split(/\s+/); + if (tty && tty !== "??" && tty !== "?") { + return tty.startsWith("/dev/") ? tty : `/dev/${tty}`; + } + const ppid = parseInt(ppidStr ?? "0", 10); + if (!ppid || ppid <= 1) break; + pid = ppid; + } + } catch {} + return null; +} + +// ── Socket communication ────────────────────────────────────────────────────── + +/** + * Sends a JSON payload to the CodeIsland socket (fire-and-forget). + * Returns `false` silently when CodeIsland is not running. + * + * @param payload - Event object to serialise and send. + * @returns `true` on successful delivery, `false` otherwise. + */ +function sendToSocket(payload: object): Promise { + return new Promise((resolve) => { + try { + const sock = connect({ path: SOCKET_PATH }, () => { + sock.write(JSON.stringify(payload)); + sock.end(); + resolve(true); + }); + sock.on("error", () => resolve(false)); + sock.setTimeout(3_000, () => { + sock.destroy(); + resolve(false); + }); + } catch { + resolve(false); + } + }); +} + +/** + * Sends a JSON payload via the bridge binary and waits for CodeIsland's response. + * Used exclusively for blocking permission/question requests. + * + * @param payload - Blocking request object. + * @param timeoutMs - Maximum wait time in milliseconds (default 30 s). + * @returns Parsed response JSON, or `null` on error / timeout. + */ +function sendAndWaitResponse( + payload: object, + timeoutMs = 30_000, +): Promise | null> { + return new Promise((resolve) => { + if (!existsSync(BRIDGE_PATH)) { + resolve(null); + return; + } + try { + const child = execFile( + BRIDGE_PATH, + [], + { timeout: timeoutMs, maxBuffer: 1_048_576 }, + (error, stdout) => { + if (error) { + resolve(null); + return; + } + try { + resolve(JSON.parse(stdout)); + } catch { + resolve(null); + } + }, + ); + child.stdin!.write(JSON.stringify(payload)); + child.stdin!.end(); + } catch { + resolve(null); + } + }); +} + +// ── Event builders ──────────────────────────────────────────────────────────── + +/** + * Builds the base fields required on every CodeIsland event payload. + * + * @param sessionId - Pi session UUID (prefixed with `"pi-"`). + * @param cwd - Current working directory. + * @param extra - Event-specific fields merged into the base. + * @returns Complete event payload ready for `sendToSocket`. + */ +function base( + sessionId: string, + cwd: string, + extra: Record, + tty: string | null, +): Record { + return { + session_id: `pi-${sessionId}`, + _source: "pi", + _ppid: process.pid, + _env: collectEnv(), + _tty: tty, + _server_port: 0, + cwd, + ...extra, + }; +} + +/** Capitalises the first character of a tool name for display. */ +function displayToolName(name: string): string { + return name.charAt(0).toUpperCase() + name.slice(1); +} + +/** Extracts plain text from the last assistant message in an event.messages array. */ +function extractLastAssistantText( + messages: readonly unknown[], +): string { + const assistants = messages.filter( + (m): m is AssistantMessage => + !!m && + typeof m === "object" && + (m as { role?: string }).role === "assistant", + ); + const last = assistants.at(-1); + if (!last) return ""; + const content = last.content; + if (!Array.isArray(content)) return ""; + return content + .filter((c): c is { type: "text"; text: string } => c?.type === "text") + .map((c) => c.text) + .join("") + .trim(); +} + +// ── Extension ───────────────────────────────────────────────────────────────── + +export default function codeislandExtension(pi: ExtensionAPI) { + /** TTY path detected once at startup. */ + const tty = detectTty(); + + /** + * Session IDs for which a blocking PermissionRequest is currently in flight. + * Non-lifecycle events for these sessions are suppressed to prevent CodeIsland's + * "answered externally" heuristic from auto-denying while the card is visible. + */ + const pendingPermissionSessions = new Set(); + + // ── Session lifecycle ────────────────────────────────────────────────────── + + pi.on("session_start", async (_event, ctx) => { + const sessionId = ctx.sessionManager.getSessionId(); + const sessionName = pi.getSessionName(); + await sendToSocket( + base(sessionId, ctx.cwd, { + hook_event_name: "SessionStart", + ...(sessionName ? { session_title: sessionName } : {}), + }, tty), + ); + }); + + pi.on("session_shutdown", async (_event, ctx) => { + const sessionId = ctx.sessionManager.getSessionId(); + await sendToSocket( + base(sessionId, ctx.cwd, { hook_event_name: "SessionEnd" }, tty), + ); + }); + + // ── Agent lifecycle ──────────────────────────────────────────────────────── + + pi.on("before_agent_start", async (event, ctx) => { + const sessionId = ctx.sessionManager.getSessionId(); + const sid = `pi-${sessionId}`; + + if (pendingPermissionSessions.has(sid)) return; + + const prompt = event.prompt ?? ""; + await sendToSocket( + base(sessionId, ctx.cwd, { + hook_event_name: "UserPromptSubmit", + prompt, + }, tty), + ); + }); + + pi.on("agent_end", async (event, ctx) => { + const sessionId = ctx.sessionManager.getSessionId(); + const sid = `pi-${sessionId}`; + + if (pendingPermissionSessions.has(sid)) return; + + const lastAssistantMessage = extractLastAssistantText(event.messages); + const sessionName = pi.getSessionName(); + + await sendToSocket( + base(sessionId, ctx.cwd, { + hook_event_name: "Stop", + last_assistant_message: lastAssistantMessage || undefined, + ...(sessionName ? { codex_title: sessionName } : {}), + }, tty), + ); + }); + + // ── Tool calls ───────────────────────────────────────────────────────────── + + pi.on("tool_call", async (event, ctx) => { + const sessionId = ctx.sessionManager.getSessionId(); + const sid = `pi-${sessionId}`; + const toolName = displayToolName(event.toolName); + + // Build a tool_input object appropriate for the tool type. + const toolInput: Record = { ...event.input }; + if (event.toolName === "bash") { + const command = event.input.command as string | undefined; + if (command) toolInput.patterns = [command]; + } + if (event.toolName === "edit" || event.toolName === "write") { + const path = event.input.path as string | undefined; + if (path) toolInput.file_path = path; + } + + // Dangerous bash → send blocking PermissionRequest via bridge. + if ( + event.toolName === "bash" && + typeof event.input.command === "string" && + isDangerous(event.input.command) + ) { + pendingPermissionSessions.add(sid); + + const payload = base(sessionId, ctx.cwd, { + hook_event_name: "PermissionRequest", + tool_name: toolName, + tool_input: toolInput, + _pi_tool_call_id: event.toolCallId, + }, tty); + + let response: Record | null = null; + try { + response = await sendAndWaitResponse(payload); + } finally { + pendingPermissionSessions.delete(sid); + } + + const behavior = ( + response?.hookSpecificOutput as Record | undefined + )?.decision as Record | undefined; + + if (behavior?.behavior === "deny") { + return { block: true, reason: "Blocked by CodeIsland" }; + } + + // Approved — fall through to normal PreToolUse event below. + } + + // Non-blocking PreToolUse for all other tool calls. + if (!pendingPermissionSessions.has(sid)) { + await sendToSocket( + base(sessionId, ctx.cwd, { + hook_event_name: "PreToolUse", + tool_name: toolName, + tool_input: toolInput, + }, tty), + ); + } + + return undefined; + }); + + pi.on("tool_result", async (_event, ctx) => { + const sessionId = ctx.sessionManager.getSessionId(); + const sid = `pi-${sessionId}`; + + if (pendingPermissionSessions.has(sid)) return; + + await sendToSocket( + base(sessionId, ctx.cwd, { hook_event_name: "PostToolUse" }, tty), + ); + }); + + // ── Compaction ───────────────────────────────────────────────────────────── + + pi.on("session_before_compact", async (_event, ctx) => { + const sessionId = ctx.sessionManager.getSessionId(); + await sendToSocket( + base(sessionId, ctx.cwd, { hook_event_name: "PreCompact" }, tty), + ); + }); + + pi.on("session_compact", async (_event, ctx) => { + const sessionId = ctx.sessionManager.getSessionId(); + await sendToSocket( + base(sessionId, ctx.cwd, { hook_event_name: "PostCompact" }, tty), + ); + }); +} diff --git a/Sources/CodeIslandCore/SessionSnapshot.swift b/Sources/CodeIslandCore/SessionSnapshot.swift index 04f86f3..d398f60 100644 --- a/Sources/CodeIslandCore/SessionSnapshot.swift +++ b/Sources/CodeIslandCore/SessionSnapshot.swift @@ -29,6 +29,7 @@ public struct SessionSnapshot { "hermes", "qwen", "kimi", + "pi", ] public var status: AgentStatus = .idle @@ -255,6 +256,7 @@ public struct SessionSnapshot { case "hermes": return "Hermes" case "qwen": return "Qwen Code" case "kimi": return "Kimi Code CLI" + case "pi": return "pi" default: if let customName = Self.loadCustomSourceNames()[source] { return customName