move pi-mono into companion-cloud as apps/companion-os

- Copy all pi-mono source into apps/companion-os/
- Update Dockerfile to COPY pre-built binary instead of downloading from GitHub Releases
- Update deploy-staging.yml to build pi from source (bun compile) before Docker build
- Add apps/companion-os/** to path triggers
- No more cross-repo dispatch needed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Harivansh Rathi 2026-03-07 09:22:50 -08:00
commit 0250f72976
579 changed files with 206942 additions and 0 deletions

View file

@ -0,0 +1,222 @@
/**
* CMUX Terminal Adapter
*
* Implements the TerminalAdapter interface for CMUX (cmux.dev).
*/
import {
execCommand,
type SpawnOptions,
type TerminalAdapter,
} from "../utils/terminal-adapter";
export class CmuxAdapter implements TerminalAdapter {
readonly name = "cmux";
detect(): boolean {
// Check for CMUX specific environment variables
return !!process.env.CMUX_SOCKET_PATH || !!process.env.CMUX_WORKSPACE_ID;
}
spawn(options: SpawnOptions): string {
// We use new-split to create a new pane in CMUX.
// CMUX doesn't have a direct 'spawn' that returns a pane ID and runs a command
// in one go while also returning the ID in a way we can easily capture for 'isAlive'.
// However, 'new-split' returns the new surface ID.
// Construct the command with environment variables
const envPrefix = Object.entries(options.env)
.filter(([k]) => k.startsWith("PI_"))
.map(([k, v]) => `${k}=${v}`)
.join(" ");
const fullCommand = envPrefix
? `env ${envPrefix} ${options.command}`
: options.command;
// CMUX new-split returns "OK <UUID>"
const splitResult = execCommand("cmux", [
"new-split",
"right",
"--command",
fullCommand,
]);
if (splitResult.status !== 0) {
throw new Error(
`cmux new-split failed with status ${splitResult.status}: ${splitResult.stderr}`,
);
}
const output = splitResult.stdout.trim();
if (output.startsWith("OK ")) {
const surfaceId = output.substring(3).trim();
return surfaceId;
}
throw new Error(`cmux new-split returned unexpected output: ${output}`);
}
kill(paneId: string): void {
if (!paneId) return;
try {
// CMUX calls them surfaces
execCommand("cmux", ["close-surface", "--surface", paneId]);
} catch {
// Ignore errors during kill
}
}
isAlive(paneId: string): boolean {
if (!paneId) return false;
try {
// We can use list-pane-surfaces and grep for the ID
// Or just 'identify' if we want to be precise, but list-pane-surfaces is safer
const result = execCommand("cmux", ["list-pane-surfaces"]);
return result.stdout.includes(paneId);
} catch {
return false;
}
}
setTitle(title: string): void {
try {
// rename-tab or rename-workspace?
// Usually agents want to rename their current "tab" or "surface"
execCommand("cmux", ["rename-tab", title]);
} catch {
// Ignore errors
}
}
/**
* CMUX supports spawning separate OS windows
*/
supportsWindows(): boolean {
return true;
}
/**
* Spawn a new separate OS window.
*/
spawnWindow(options: SpawnOptions): string {
// CMUX new-window returns "OK <UUID>"
const result = execCommand("cmux", ["new-window"]);
if (result.status !== 0) {
throw new Error(
`cmux new-window failed with status ${result.status}: ${result.stderr}`,
);
}
const output = result.stdout.trim();
if (output.startsWith("OK ")) {
const windowId = output.substring(3).trim();
// Now we need to run the command in this window.
// Usually new-window creates a default workspace/surface.
// We might need to find the workspace in that window.
// For now, let's just use 'new-workspace' in that window if possible,
// but CMUX commands usually target the current window unless specified.
// Wait a bit for the window to be ready?
const envPrefix = Object.entries(options.env)
.filter(([k]) => k.startsWith("PI_"))
.map(([k, v]) => `${k}=${v}`)
.join(" ");
const fullCommand = envPrefix
? `env ${envPrefix} ${options.command}`
: options.command;
// Target the new window
execCommand("cmux", [
"new-workspace",
"--window",
windowId,
"--command",
fullCommand,
]);
if (options.teamName) {
this.setWindowTitle(windowId, options.teamName);
}
return windowId;
}
throw new Error(`cmux new-window returned unexpected output: ${output}`);
}
/**
* Set the title of a specific window.
*/
setWindowTitle(windowId: string, title: string): void {
try {
execCommand("cmux", ["rename-window", "--window", windowId, title]);
} catch {
// Ignore
}
}
/**
* Kill/terminate a window.
*/
killWindow(windowId: string): void {
if (!windowId) return;
try {
execCommand("cmux", ["close-window", "--window", windowId]);
} catch {
// Ignore
}
}
/**
* Check if a window is still alive.
*/
isWindowAlive(windowId: string): boolean {
if (!windowId) return false;
try {
const result = execCommand("cmux", ["list-windows"]);
return result.stdout.includes(windowId);
} catch {
return false;
}
}
/**
* Custom CMUX capability: create a workspace for a problem.
* This isn't part of the TerminalAdapter interface but can be used via the adapter.
*/
createProblemWorkspace(title: string, command?: string): string {
const args = ["new-workspace"];
if (command) {
args.push("--command", command);
}
const result = execCommand("cmux", args);
if (result.status !== 0) {
throw new Error(`cmux new-workspace failed: ${result.stderr}`);
}
const output = result.stdout.trim();
if (output.startsWith("OK ")) {
const workspaceId = output.substring(3).trim();
execCommand("cmux", [
"workspace-action",
"--action",
"rename",
"--title",
title,
"--workspace",
workspaceId,
]);
return workspaceId;
}
throw new Error(`cmux new-workspace returned unexpected output: ${output}`);
}
}

View file

@ -0,0 +1,320 @@
/**
* iTerm2 Terminal Adapter
*
* Implements the TerminalAdapter interface for iTerm2 terminal emulator.
* Uses AppleScript for all operations.
*/
import { spawnSync } from "node:child_process";
import type { SpawnOptions, TerminalAdapter } from "../utils/terminal-adapter";
/**
* Context needed for iTerm2 spawning (tracks last pane for layout)
*/
export interface Iterm2SpawnContext {
/** ID of the last spawned session, used for layout decisions */
lastSessionId?: string;
}
export class Iterm2Adapter implements TerminalAdapter {
readonly name = "iTerm2";
private spawnContext: Iterm2SpawnContext = {};
detect(): boolean {
return (
process.env.TERM_PROGRAM === "iTerm.app" &&
!process.env.TMUX &&
!process.env.ZELLIJ
);
}
/**
* Helper to execute AppleScript via stdin to avoid escaping issues with -e
*/
private runAppleScript(script: string): {
stdout: string;
stderr: string;
status: number | null;
} {
const result = spawnSync("osascript", ["-"], {
input: script,
encoding: "utf-8",
});
return {
stdout: result.stdout?.toString() ?? "",
stderr: result.stderr?.toString() ?? "",
status: result.status,
};
}
spawn(options: SpawnOptions): string {
const envStr = Object.entries(options.env)
.filter(([k]) => k.startsWith("PI_"))
.map(([k, v]) => `${k}=${v}`)
.join(" ");
const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`;
const escapedCmd = itermCmd.replace(/"/g, '\\"');
let script: string;
if (!this.spawnContext.lastSessionId) {
script = `tell application "iTerm2"
tell current session of current window
set newSession to split vertically with default profile
tell newSession
write text "${escapedCmd}"
return id
end tell
end tell
end tell`;
} else {
script = `tell application "iTerm2"
repeat with aWindow in windows
repeat with aTab in tabs of aWindow
repeat with aSession in sessions of aTab
if id of aSession is "${this.spawnContext.lastSessionId}" then
tell aSession
set newSession to split horizontally with default profile
tell newSession
write text "${escapedCmd}"
return id
end tell
end tell
end if
end repeat
end repeat
end repeat
end tell`;
}
const result = this.runAppleScript(script);
if (result.status !== 0) {
throw new Error(
`osascript failed with status ${result.status}: ${result.stderr}`,
);
}
const sessionId = result.stdout.toString().trim();
this.spawnContext.lastSessionId = sessionId;
return `iterm_${sessionId}`;
}
kill(paneId: string): void {
if (
!paneId ||
!paneId.startsWith("iterm_") ||
paneId.startsWith("iterm_win_")
) {
return;
}
const itermId = paneId.replace("iterm_", "");
const script = `tell application "iTerm2"
repeat with aWindow in windows
repeat with aTab in tabs of aWindow
repeat with aSession in sessions of aTab
if id of aSession is "${itermId}" then
close aSession
return "Closed"
end if
end repeat
end repeat
end repeat
end tell`;
try {
this.runAppleScript(script);
} catch {
// Ignore errors
}
}
isAlive(paneId: string): boolean {
if (
!paneId ||
!paneId.startsWith("iterm_") ||
paneId.startsWith("iterm_win_")
) {
return false;
}
const itermId = paneId.replace("iterm_", "");
const script = `tell application "iTerm2"
repeat with aWindow in windows
repeat with aTab in tabs of aWindow
repeat with aSession in sessions of aTab
if id of aSession is "${itermId}" then
return "Alive"
end if
end repeat
end repeat
end repeat
end tell`;
try {
const result = this.runAppleScript(script);
return result.stdout.includes("Alive");
} catch {
return false;
}
}
setTitle(title: string): void {
const escapedTitle = title.replace(/"/g, '\\"');
const script = `tell application "iTerm2" to tell current session of current window
set name to "${escapedTitle}"
end tell`;
try {
this.runAppleScript(script);
} catch {
// Ignore errors
}
}
/**
* iTerm2 supports spawning separate OS windows via AppleScript
*/
supportsWindows(): boolean {
return true;
}
/**
* Spawn a new separate OS window with the given options.
*/
spawnWindow(options: SpawnOptions): string {
const envStr = Object.entries(options.env)
.filter(([k]) => k.startsWith("PI_"))
.map(([k, v]) => `${k}=${v}`)
.join(" ");
const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`;
const escapedCmd = itermCmd.replace(/"/g, '\\"');
const windowTitle = options.teamName
? `${options.teamName}: ${options.name}`
: options.name;
const escapedTitle = windowTitle.replace(/"/g, '\\"');
const script = `tell application "iTerm2"
set newWindow to (create window with default profile)
tell current session of newWindow
-- Set the session name (tab title)
set name to "${escapedTitle}"
-- Set window title via escape sequence (OSC 2)
-- We use double backslashes for AppleScript to emit a single backslash to the shell
write text "printf '\\\\033]2;${escapedTitle}\\\\007'"
-- Execute the command
write text "cd '${options.cwd}' && ${escapedCmd}"
return id of newWindow
end tell
end tell`;
const result = this.runAppleScript(script);
if (result.status !== 0) {
throw new Error(
`osascript failed with status ${result.status}: ${result.stderr}`,
);
}
const windowId = result.stdout.toString().trim();
return `iterm_win_${windowId}`;
}
/**
* Set the title of a specific window.
*/
setWindowTitle(windowId: string, title: string): void {
if (!windowId || !windowId.startsWith("iterm_win_")) {
return;
}
const itermId = windowId.replace("iterm_win_", "");
const escapedTitle = title.replace(/"/g, '\\"');
const script = `tell application "iTerm2"
repeat with aWindow in windows
if id of aWindow is "${itermId}" then
tell current session of aWindow
write text "printf '\\\\033]2;${escapedTitle}\\\\007'"
end tell
exit repeat
end if
end repeat
end tell`;
try {
this.runAppleScript(script);
} catch {
// Silently fail
}
}
/**
* Kill/terminate a window.
*/
killWindow(windowId: string): void {
if (!windowId || !windowId.startsWith("iterm_win_")) {
return;
}
const itermId = windowId.replace("iterm_win_", "");
const script = `tell application "iTerm2"
repeat with aWindow in windows
if id of aWindow is "${itermId}" then
close aWindow
return "Closed"
end if
end repeat
end tell`;
try {
this.runAppleScript(script);
} catch {
// Silently fail
}
}
/**
* Check if a window is still alive/active.
*/
isWindowAlive(windowId: string): boolean {
if (!windowId || !windowId.startsWith("iterm_win_")) {
return false;
}
const itermId = windowId.replace("iterm_win_", "");
const script = `tell application "iTerm2"
repeat with aWindow in windows
if id of aWindow is "${itermId}" then
return "Alive"
end if
end repeat
end tell`;
try {
const result = this.runAppleScript(script);
return result.stdout.includes("Alive");
} catch {
return false;
}
}
/**
* Set the spawn context (used to restore state when needed)
*/
setSpawnContext(context: Iterm2SpawnContext): void {
this.spawnContext = context;
}
/**
* Get current spawn context (useful for persisting state)
*/
getSpawnContext(): Iterm2SpawnContext {
return { ...this.spawnContext };
}
}

View file

@ -0,0 +1,123 @@
/**
* Terminal Registry
*
* Manages terminal adapters and provides automatic selection based on
* the current environment.
*/
import type { TerminalAdapter } from "../utils/terminal-adapter";
import { CmuxAdapter } from "./cmux-adapter";
import { Iterm2Adapter } from "./iterm2-adapter";
import { TmuxAdapter } from "./tmux-adapter";
import { WezTermAdapter } from "./wezterm-adapter";
import { ZellijAdapter } from "./zellij-adapter";
/**
* Available terminal adapters, ordered by priority
*
* Detection order (first match wins):
* 0. CMUX - if CMUX_SOCKET_PATH is set
* 1. tmux - if TMUX env is set
* 2. Zellij - if ZELLIJ env is set and not in tmux
* 3. iTerm2 - if TERM_PROGRAM=iTerm.app and not in tmux/zellij
* 4. WezTerm - if WEZTERM_PANE env is set and not in tmux/zellij
*/
const adapters: TerminalAdapter[] = [
new CmuxAdapter(),
new TmuxAdapter(),
new ZellijAdapter(),
new Iterm2Adapter(),
new WezTermAdapter(),
];
/**
* Cached detected adapter
*/
let cachedAdapter: TerminalAdapter | null = null;
/**
* Detect and return the appropriate terminal adapter for the current environment.
*
* Detection order (first match wins):
* 1. tmux - if TMUX env is set
* 2. Zellij - if ZELLIJ env is set and not in tmux
* 3. iTerm2 - if TERM_PROGRAM=iTerm.app and not in tmux/zellij
* 4. WezTerm - if WEZTERM_PANE env is set and not in tmux/zellij
*
* @returns The detected terminal adapter, or null if none detected
*/
export function getTerminalAdapter(): TerminalAdapter | null {
if (cachedAdapter) {
return cachedAdapter;
}
for (const adapter of adapters) {
if (adapter.detect()) {
cachedAdapter = adapter;
return adapter;
}
}
return null;
}
/**
* Get a specific terminal adapter by name.
*
* @param name - The adapter name (e.g., "tmux", "iTerm2", "zellij", "WezTerm")
* @returns The adapter instance, or undefined if not found
*/
export function getAdapterByName(name: string): TerminalAdapter | undefined {
return adapters.find((a) => a.name === name);
}
/**
* Get all available adapters.
*
* @returns Array of all registered adapters
*/
export function getAllAdapters(): TerminalAdapter[] {
return [...adapters];
}
/**
* Clear the cached adapter (useful for testing or environment changes)
*/
export function clearAdapterCache(): void {
cachedAdapter = null;
}
/**
* Set a specific adapter (useful for testing or forced selection)
*/
export function setAdapter(adapter: TerminalAdapter): void {
cachedAdapter = adapter;
}
/**
* Check if any terminal adapter is available.
*
* @returns true if a terminal adapter was detected
*/
export function hasTerminalAdapter(): boolean {
return getTerminalAdapter() !== null;
}
/**
* Check if the current terminal supports spawning separate OS windows.
*
* @returns true if the detected terminal supports windows (iTerm2, WezTerm)
*/
export function supportsWindows(): boolean {
const adapter = getTerminalAdapter();
return adapter?.supportsWindows() ?? false;
}
/**
* Get the name of the currently detected terminal adapter.
*
* @returns The adapter name, or null if none detected
*/
export function getTerminalName(): string | null {
return getTerminalAdapter()?.name ?? null;
}

View file

@ -0,0 +1,134 @@
/**
* Tmux Terminal Adapter
*
* Implements the TerminalAdapter interface for tmux terminal multiplexer.
*/
import { execSync } from "node:child_process";
import {
execCommand,
type SpawnOptions,
type TerminalAdapter,
} from "../utils/terminal-adapter";
export class TmuxAdapter implements TerminalAdapter {
readonly name = "tmux";
detect(): boolean {
// tmux is available if TMUX environment variable is set
return !!process.env.TMUX;
}
spawn(options: SpawnOptions): string {
const envArgs = Object.entries(options.env)
.filter(([k]) => k.startsWith("PI_"))
.map(([k, v]) => `${k}=${v}`);
const tmuxArgs = [
"split-window",
"-h",
"-dP",
"-F",
"#{pane_id}",
"-c",
options.cwd,
"env",
...envArgs,
"sh",
"-c",
options.command,
];
const result = execCommand("tmux", tmuxArgs);
if (result.status !== 0) {
throw new Error(
`tmux spawn failed with status ${result.status}: ${result.stderr}`,
);
}
// Apply layout after spawning
execCommand("tmux", ["set-window-option", "main-pane-width", "60%"]);
execCommand("tmux", ["select-layout", "main-vertical"]);
return result.stdout.trim();
}
kill(paneId: string): void {
if (
!paneId ||
paneId.startsWith("iterm_") ||
paneId.startsWith("zellij_")
) {
return; // Not a tmux pane
}
try {
execCommand("tmux", ["kill-pane", "-t", paneId.trim()]);
} catch {
// Ignore errors - pane may already be dead
}
}
isAlive(paneId: string): boolean {
if (
!paneId ||
paneId.startsWith("iterm_") ||
paneId.startsWith("zellij_")
) {
return false; // Not a tmux pane
}
try {
execSync(`tmux has-session -t ${paneId}`);
return true;
} catch {
return false;
}
}
setTitle(title: string): void {
try {
execCommand("tmux", ["select-pane", "-T", title]);
} catch {
// Ignore errors
}
}
/**
* tmux does not support spawning separate OS windows
*/
supportsWindows(): boolean {
return false;
}
/**
* Not supported - throws error
*/
spawnWindow(_options: SpawnOptions): string {
throw new Error(
"tmux does not support spawning separate OS windows. Use iTerm2 or WezTerm instead.",
);
}
/**
* Not supported - no-op
*/
setWindowTitle(_windowId: string, _title: string): void {
// Not supported
}
/**
* Not supported - no-op
*/
killWindow(_windowId: string): void {
// Not supported
}
/**
* Not supported - always returns false
*/
isWindowAlive(_windowId: string): boolean {
return false;
}
}

View file

@ -0,0 +1,122 @@
/**
* WezTerm Adapter Tests
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as terminalAdapter from "../utils/terminal-adapter";
import { WezTermAdapter } from "./wezterm-adapter";
describe("WezTermAdapter", () => {
let adapter: WezTermAdapter;
let mockExecCommand: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
adapter = new WezTermAdapter();
mockExecCommand = vi.spyOn(terminalAdapter, "execCommand");
delete process.env.WEZTERM_PANE;
delete process.env.TMUX;
delete process.env.ZELLIJ;
process.env.WEZTERM_PANE = "0";
});
afterEach(() => {
vi.clearAllMocks();
});
describe("name", () => {
it("should have the correct name", () => {
expect(adapter.name).toBe("WezTerm");
});
});
describe("detect", () => {
it("should detect when WEZTERM_PANE is set", () => {
mockExecCommand.mockReturnValue({
stdout: "version 1.0",
stderr: "",
status: 0,
});
expect(adapter.detect()).toBe(true);
});
});
describe("spawn", () => {
it("should spawn first pane to the right with 50%", () => {
// Mock getPanes finding only current pane
mockExecCommand.mockImplementation((_bin: string, args: string[]) => {
if (args.includes("list")) {
return {
stdout: JSON.stringify([{ pane_id: 0, tab_id: 0 }]),
stderr: "",
status: 0,
};
}
if (args.includes("split-pane")) {
return { stdout: "1", stderr: "", status: 0 };
}
return { stdout: "", stderr: "", status: 0 };
});
const result = adapter.spawn({
name: "test-agent",
cwd: "/home/user/project",
command: "pi --agent test",
env: { PI_AGENT_ID: "test-123" },
});
expect(result).toBe("wezterm_1");
expect(mockExecCommand).toHaveBeenCalledWith(
expect.stringContaining("wezterm"),
expect.arrayContaining([
"cli",
"split-pane",
"--right",
"--percent",
"50",
]),
);
});
it("should spawn subsequent panes by splitting the sidebar", () => {
// Mock getPanes finding current pane (0) and sidebar pane (1)
mockExecCommand.mockImplementation((_bin: string, args: string[]) => {
if (args.includes("list")) {
return {
stdout: JSON.stringify([
{ pane_id: 0, tab_id: 0 },
{ pane_id: 1, tab_id: 0 },
]),
stderr: "",
status: 0,
};
}
if (args.includes("split-pane")) {
return { stdout: "2", stderr: "", status: 0 };
}
return { stdout: "", stderr: "", status: 0 };
});
const result = adapter.spawn({
name: "agent2",
cwd: "/home/user/project",
command: "pi",
env: {},
});
expect(result).toBe("wezterm_2");
// 1 sidebar pane already exists, so percent should be floor(100/(1+1)) = 50%
expect(mockExecCommand).toHaveBeenCalledWith(
expect.stringContaining("wezterm"),
expect.arrayContaining([
"cli",
"split-pane",
"--bottom",
"--pane-id",
"1",
"--percent",
"50",
]),
);
});
});
});

View file

@ -0,0 +1,366 @@
/**
* WezTerm Terminal Adapter
*
* Implements the TerminalAdapter interface for WezTerm terminal emulator.
* Uses wezterm cli split-pane for pane management.
*/
import {
execCommand,
type SpawnOptions,
type TerminalAdapter,
} from "../utils/terminal-adapter";
export class WezTermAdapter implements TerminalAdapter {
readonly name = "WezTerm";
// Common paths where wezterm CLI might be found
private possiblePaths = [
"wezterm", // In PATH
"/Applications/WezTerm.app/Contents/MacOS/wezterm", // macOS
"/usr/local/bin/wezterm", // Linux/macOS common
"/usr/bin/wezterm", // Linux system
];
private weztermPath: string | null = null;
private findWeztermBinary(): string | null {
if (this.weztermPath !== null) {
return this.weztermPath;
}
for (const path of this.possiblePaths) {
try {
const result = execCommand(path, ["--version"]);
if (result.status === 0) {
this.weztermPath = path;
return path;
}
} catch {
// Continue to next path
}
}
this.weztermPath = null;
return null;
}
detect(): boolean {
if (!process.env.WEZTERM_PANE || process.env.TMUX || process.env.ZELLIJ) {
return false;
}
return this.findWeztermBinary() !== null;
}
/**
* Get all panes in the current tab to determine layout state.
*/
private getPanes(): any[] {
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return [];
const result = execCommand(weztermBin, ["cli", "list", "--format", "json"]);
if (result.status !== 0) return [];
try {
const allPanes = JSON.parse(result.stdout);
const currentPaneId = parseInt(process.env.WEZTERM_PANE || "0", 10);
// Find the tab of the current pane
const currentPane = allPanes.find(
(p: any) => p.pane_id === currentPaneId,
);
if (!currentPane) return [];
// Return all panes in the same tab
return allPanes.filter((p: any) => p.tab_id === currentPane.tab_id);
} catch {
return [];
}
}
spawn(options: SpawnOptions): string {
const weztermBin = this.findWeztermBinary();
if (!weztermBin) {
throw new Error("WezTerm CLI binary not found.");
}
const panes = this.getPanes();
const envArgs = Object.entries(options.env)
.filter(([k]) => k.startsWith("PI_"))
.map(([k, v]) => `${k}=${v}`);
let weztermArgs: string[];
// First pane: split to the right with 50% (matches iTerm2/tmux behavior)
const isFirstPane = panes.length === 1;
if (isFirstPane) {
weztermArgs = [
"cli",
"split-pane",
"--right",
"--percent",
"50",
"--cwd",
options.cwd,
"--",
"env",
...envArgs,
"sh",
"-c",
options.command,
];
} else {
// Subsequent teammates stack in the sidebar on the right.
// currentPaneId (id 0) is the main pane on the left.
// All other panes are in the sidebar.
const currentPaneId = parseInt(process.env.WEZTERM_PANE || "0", 10);
const sidebarPanes = panes
.filter((p) => p.pane_id !== currentPaneId)
.sort((a, b) => b.cursor_y - a.cursor_y); // Sort by vertical position (bottom-most first)
// To add a new pane to the bottom of the sidebar stack:
// We always split the BOTTOM-MOST pane (sidebarPanes[0])
// and use 50% so the new pane and the previous bottom pane are equal.
// This progressively fills the sidebar from top to bottom.
const targetPane = sidebarPanes[0];
weztermArgs = [
"cli",
"split-pane",
"--bottom",
"--pane-id",
targetPane.pane_id.toString(),
"--percent",
"50",
"--cwd",
options.cwd,
"--",
"env",
...envArgs,
"sh",
"-c",
options.command,
];
}
const result = execCommand(weztermBin, weztermArgs);
if (result.status !== 0) {
throw new Error(`wezterm spawn failed: ${result.stderr}`);
}
// New: After spawning, tell WezTerm to equalize the panes in this tab
// This ensures that regardless of the split math, they all end up the same height.
try {
execCommand(weztermBin, ["cli", "zoom-pane", "--unzoom"]); // Ensure not zoomed
// WezTerm doesn't have a single "equalize" command like tmux,
// but splitting with no percentage usually balances, or we can use
// the 'AdjustPaneSize' sequence.
// For now, let's stick to the 50/50 split of the LAST pane which is most reliable.
} catch {}
const paneId = result.stdout.trim();
return `wezterm_${paneId}`;
}
kill(paneId: string): void {
if (!paneId?.startsWith("wezterm_")) return;
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return;
const weztermId = paneId.replace("wezterm_", "");
try {
execCommand(weztermBin, ["cli", "kill-pane", "--pane-id", weztermId]);
} catch {}
}
isAlive(paneId: string): boolean {
if (!paneId?.startsWith("wezterm_")) return false;
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return false;
const weztermId = parseInt(paneId.replace("wezterm_", ""), 10);
const panes = this.getPanes();
return panes.some((p) => p.pane_id === weztermId);
}
setTitle(title: string): void {
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return;
try {
execCommand(weztermBin, ["cli", "set-tab-title", title]);
} catch {}
}
/**
* WezTerm supports spawning separate OS windows via CLI
*/
supportsWindows(): boolean {
return this.findWeztermBinary() !== null;
}
/**
* Spawn a new separate OS window with the given options.
* Uses `wezterm cli spawn --new-window` and sets the window title.
*/
spawnWindow(options: SpawnOptions): string {
const weztermBin = this.findWeztermBinary();
if (!weztermBin) {
throw new Error("WezTerm CLI binary not found.");
}
const envArgs = Object.entries(options.env)
.filter(([k]) => k.startsWith("PI_"))
.map(([k, v]) => `${k}=${v}`);
// Format window title as "teamName: agentName" if teamName is provided
const windowTitle = options.teamName
? `${options.teamName}: ${options.name}`
: options.name;
// Spawn a new window
const spawnArgs = [
"cli",
"spawn",
"--new-window",
"--cwd",
options.cwd,
"--",
"env",
...envArgs,
"sh",
"-c",
options.command,
];
const result = execCommand(weztermBin, spawnArgs);
if (result.status !== 0) {
throw new Error(`wezterm spawn-window failed: ${result.stderr}`);
}
// The output is the pane ID, we need to find the window ID
const paneId = result.stdout.trim();
// Query to get window ID from pane ID
const windowId = this.getWindowIdFromPaneId(parseInt(paneId, 10));
// Set the window title if we found the window
if (windowId !== null) {
this.setWindowTitle(`wezterm_win_${windowId}`, windowTitle);
}
return `wezterm_win_${windowId || paneId}`;
}
/**
* Get window ID from a pane ID by querying WezTerm
*/
private getWindowIdFromPaneId(paneId: number): number | null {
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return null;
const result = execCommand(weztermBin, ["cli", "list", "--format", "json"]);
if (result.status !== 0) return null;
try {
const allPanes = JSON.parse(result.stdout);
const pane = allPanes.find((p: any) => p.pane_id === paneId);
return pane?.window_id ?? null;
} catch {
return null;
}
}
/**
* Set the title of a specific window.
*/
setWindowTitle(windowId: string, title: string): void {
if (!windowId?.startsWith("wezterm_win_")) return;
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return;
const weztermWindowId = windowId.replace("wezterm_win_", "");
try {
execCommand(weztermBin, [
"cli",
"set-window-title",
"--window-id",
weztermWindowId,
title,
]);
} catch {
// Silently fail
}
}
/**
* Kill/terminate a window.
*/
killWindow(windowId: string): void {
if (!windowId?.startsWith("wezterm_win_")) return;
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return;
const weztermWindowId = windowId.replace("wezterm_win_", "");
try {
// WezTerm doesn't have a direct kill-window command, so we kill all panes in the window
const result = execCommand(weztermBin, [
"cli",
"list",
"--format",
"json",
]);
if (result.status !== 0) return;
const allPanes = JSON.parse(result.stdout);
const windowPanes = allPanes.filter(
(p: any) => p.window_id.toString() === weztermWindowId,
);
for (const pane of windowPanes) {
execCommand(weztermBin, [
"cli",
"kill-pane",
"--pane-id",
pane.pane_id.toString(),
]);
}
} catch {
// Silently fail
}
}
/**
* Check if a window is still alive/active.
*/
isWindowAlive(windowId: string): boolean {
if (!windowId?.startsWith("wezterm_win_")) return false;
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return false;
const weztermWindowId = windowId.replace("wezterm_win_", "");
try {
const result = execCommand(weztermBin, [
"cli",
"list",
"--format",
"json",
]);
if (result.status !== 0) return false;
const allPanes = JSON.parse(result.stdout);
return allPanes.some(
(p: any) => p.window_id.toString() === weztermWindowId,
);
} catch {
return false;
}
}
}

View file

@ -0,0 +1,109 @@
/**
* Zellij Terminal Adapter
*
* Implements the TerminalAdapter interface for Zellij terminal multiplexer.
* Note: Zellij uses --close-on-exit, so explicit kill is not needed.
*/
import {
execCommand,
type SpawnOptions,
type TerminalAdapter,
} from "../utils/terminal-adapter";
export class ZellijAdapter implements TerminalAdapter {
readonly name = "zellij";
detect(): boolean {
// Zellij is available if ZELLIJ env is set and not in tmux
return !!process.env.ZELLIJ && !process.env.TMUX;
}
spawn(options: SpawnOptions): string {
const zellijArgs = [
"run",
"--name",
options.name,
"--cwd",
options.cwd,
"--close-on-exit",
"--",
"env",
...Object.entries(options.env)
.filter(([k]) => k.startsWith("PI_"))
.map(([k, v]) => `${k}=${v}`),
"sh",
"-c",
options.command,
];
const result = execCommand("zellij", zellijArgs);
if (result.status !== 0) {
throw new Error(
`zellij spawn failed with status ${result.status}: ${result.stderr}`,
);
}
// Zellij doesn't return a pane ID, so we create a synthetic one
return `zellij_${options.name}`;
}
kill(_paneId: string): void {
// Zellij uses --close-on-exit, so panes close automatically
// when the process exits. No explicit kill needed.
}
isAlive(paneId: string): boolean {
// Zellij doesn't have a straightforward way to check if a pane is alive
// For now, we assume alive if it's a zellij pane ID
if (!paneId || !paneId.startsWith("zellij_")) {
return false;
}
// Could potentially use `zellij list-sessions` or similar in the future
return true;
}
setTitle(_title: string): void {
// Zellij pane titles are set via --name at spawn time
// No runtime title changing supported
}
/**
* Zellij does not support spawning separate OS windows
*/
supportsWindows(): boolean {
return false;
}
/**
* Not supported - throws error
*/
spawnWindow(_options: SpawnOptions): string {
throw new Error(
"Zellij does not support spawning separate OS windows. Use iTerm2 or WezTerm instead.",
);
}
/**
* Not supported - no-op
*/
setWindowTitle(_windowId: string, _title: string): void {
// Not supported
}
/**
* Not supported - no-op
*/
killWindow(_windowId: string): void {
// Not supported
}
/**
* Not supported - always returns false
*/
isWindowAlive(_windowId: string): boolean {
return false;
}
}

View file

@ -0,0 +1,79 @@
import fs from "node:fs";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { runHook } from "./hooks";
describe("runHook", () => {
const hooksDir = path.join(process.cwd(), ".pi", "team-hooks");
beforeAll(() => {
if (!fs.existsSync(hooksDir)) {
fs.mkdirSync(hooksDir, { recursive: true });
}
});
afterAll(() => {
// Optional: Clean up created scripts
const files = ["success_hook.sh", "fail_hook.sh"];
files.forEach((f) => {
const p = path.join(hooksDir, f);
if (fs.existsSync(p)) fs.unlinkSync(p);
});
});
it("should return true if hook script does not exist", async () => {
const result = await runHook("test_team", "non_existent_hook", {
data: "test",
});
expect(result).toBe(true);
});
it("should return true if hook script succeeds", async () => {
const hookName = "success_hook";
const scriptPath = path.join(hooksDir, `${hookName}.sh`);
// Create a simple script that exits with 0
fs.writeFileSync(scriptPath, "#!/bin/bash\nexit 0", { mode: 0o755 });
const result = await runHook("test_team", hookName, { data: "test" });
expect(result).toBe(true);
});
it("should return false if hook script fails", async () => {
const hookName = "fail_hook";
const scriptPath = path.join(hooksDir, `${hookName}.sh`);
// Create a simple script that exits with 1
fs.writeFileSync(scriptPath, "#!/bin/bash\nexit 1", { mode: 0o755 });
// Mock console.error to avoid noise in test output
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const result = await runHook("test_team", hookName, { data: "test" });
expect(result).toBe(false);
consoleSpy.mockRestore();
});
it("should pass the payload to the hook script", async () => {
const hookName = "payload_hook";
const scriptPath = path.join(hooksDir, `${hookName}.sh`);
const outputFile = path.join(hooksDir, "payload_output.txt");
// Create a script that writes its first argument to a file
fs.writeFileSync(scriptPath, `#!/bin/bash\necho "$1" > "${outputFile}"`, {
mode: 0o755,
});
const payload = { key: "value", "special'char": true };
const result = await runHook("test_team", hookName, payload);
expect(result).toBe(true);
const output = fs.readFileSync(outputFile, "utf-8").trim();
expect(JSON.parse(output)).toEqual(payload);
// Clean up
fs.unlinkSync(scriptPath);
if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile);
});
});

View file

@ -0,0 +1,44 @@
import { execFile } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/**
* Runs a hook script asynchronously if it exists.
* Hooks are located in .pi/team-hooks/{hookName}.sh relative to the CWD.
*
* @param teamName The name of the team.
* @param hookName The name of the hook to run (e.g., 'task_completed').
* @param payload The payload to pass to the hook script as the first argument.
* @returns true if the hook doesn't exist or executes successfully; false otherwise.
*/
export async function runHook(
teamName: string,
hookName: string,
payload: any,
): Promise<boolean> {
const hookPath = path.join(
process.cwd(),
".pi",
"team-hooks",
`${hookName}.sh`,
);
if (!fs.existsSync(hookPath)) {
return true;
}
try {
const payloadStr = JSON.stringify(payload);
// Use execFile: More secure (no shell interpolation) and asynchronous
await execFileAsync(hookPath, [payloadStr], {
env: { ...process.env, PI_TEAM: teamName },
});
return true;
} catch (error) {
console.error(`Hook ${hookName} failed:`, error);
return false;
}
}

View file

@ -0,0 +1,46 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { withLock } from "./lock";
describe("withLock race conditions", () => {
const testDir = path.join(os.tmpdir(), `pi-lock-race-test-${Date.now()}`);
const lockPath = path.join(testDir, "test");
beforeEach(() => {
if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
});
it("should handle multiple concurrent attempts to acquire the lock", async () => {
let counter = 0;
const iterations = 20;
const concurrentCount = 5;
const runTask = async () => {
for (let i = 0; i < iterations; i++) {
await withLock(lockPath, async () => {
const current = counter;
// Add a small delay to increase the chance of race conditions if locking fails
await new Promise((resolve) =>
setTimeout(resolve, Math.random() * 10),
);
counter = current + 1;
});
}
};
const promises = [];
for (let i = 0; i < concurrentCount; i++) {
promises.push(runTask());
}
await Promise.all(promises);
expect(counter).toBe(iterations * concurrentCount);
});
});

View file

@ -0,0 +1,51 @@
// Project: pi-teams
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withLock } from "./lock";
describe("withLock", () => {
const testDir = path.join(os.tmpdir(), `pi-lock-test-${Date.now()}`);
const lockPath = path.join(testDir, "test");
const lockFile = `${lockPath}.lock`;
beforeEach(() => {
if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
vi.restoreAllMocks();
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
});
it("should successfully acquire and release the lock", async () => {
const fn = vi.fn().mockResolvedValue("result");
const result = await withLock(lockPath, fn);
expect(result).toBe("result");
expect(fn).toHaveBeenCalled();
expect(fs.existsSync(lockFile)).toBe(false);
});
it("should fail to acquire lock if already held", async () => {
// Manually create lock file
fs.writeFileSync(lockFile, "9999");
const fn = vi.fn().mockResolvedValue("result");
// Test with only 2 retries to speed up the failure
await expect(withLock(lockPath, fn, 2)).rejects.toThrow(
"Could not acquire lock",
);
expect(fn).not.toHaveBeenCalled();
});
it("should release lock even if function fails", async () => {
const fn = vi.fn().mockRejectedValue(new Error("failure"));
await expect(withLock(lockPath, fn)).rejects.toThrow("failure");
expect(fs.existsSync(lockFile)).toBe(false);
});
});

View file

@ -0,0 +1,50 @@
// Project: pi-teams
import fs from "node:fs";
const STALE_LOCK_TIMEOUT = 30000; // 30 seconds for a lock to be considered stale
export async function withLock<T>(
lockPath: string,
fn: () => Promise<T>,
retries: number = 50,
): Promise<T> {
const lockFile = `${lockPath}.lock`;
while (retries > 0) {
try {
// Check if lock exists and is stale
if (fs.existsSync(lockFile)) {
const stats = fs.statSync(lockFile);
const age = Date.now() - stats.mtimeMs;
if (age > STALE_LOCK_TIMEOUT) {
// Attempt to remove stale lock
try {
fs.unlinkSync(lockFile);
} catch (_error) {
// ignore, another process might have already removed it
}
}
}
fs.writeFileSync(lockFile, process.pid.toString(), { flag: "wx" });
break;
} catch (_error) {
retries--;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
if (retries === 0) {
throw new Error("Could not acquire lock");
}
try {
return await fn();
} finally {
try {
fs.unlinkSync(lockFile);
} catch (_error) {
// ignore
}
}
}

View file

@ -0,0 +1,130 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
appendMessage,
broadcastMessage,
readInbox,
sendPlainMessage,
} from "./messaging";
import * as paths from "./paths";
// Mock the paths to use a temporary directory
const testDir = path.join(os.tmpdir(), `pi-teams-test-${Date.now()}`);
describe("Messaging Utilities", () => {
beforeEach(() => {
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
fs.mkdirSync(testDir, { recursive: true });
// Override paths to use testDir
vi.spyOn(paths, "inboxPath").mockImplementation((_teamName, agentName) => {
return path.join(testDir, "inboxes", `${agentName}.json`);
});
vi.spyOn(paths, "teamDir").mockReturnValue(testDir);
vi.spyOn(paths, "configPath").mockImplementation((_teamName) => {
return path.join(testDir, "config.json");
});
});
afterEach(() => {
vi.restoreAllMocks();
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
});
it("should append a message successfully", async () => {
const msg = {
from: "sender",
text: "hello",
timestamp: "now",
read: false,
};
await appendMessage("test-team", "receiver", msg);
const inbox = await readInbox("test-team", "receiver", false, false);
expect(inbox.length).toBe(1);
expect(inbox[0].text).toBe("hello");
});
it("should handle concurrent appends (Stress Test)", async () => {
const numMessages = 100;
const promises = [];
for (let i = 0; i < numMessages; i++) {
promises.push(
sendPlainMessage(
"test-team",
`sender-${i}`,
"receiver",
`msg-${i}`,
`summary-${i}`,
),
);
}
await Promise.all(promises);
const inbox = await readInbox("test-team", "receiver", false, false);
expect(inbox.length).toBe(numMessages);
// Verify all messages are present
const texts = inbox.map((m) => m.text).sort();
for (let i = 0; i < numMessages; i++) {
expect(texts).toContain(`msg-${i}`);
}
});
it("should mark messages as read", async () => {
await sendPlainMessage(
"test-team",
"sender",
"receiver",
"msg1",
"summary1",
);
await sendPlainMessage(
"test-team",
"sender",
"receiver",
"msg2",
"summary2",
);
// Read only unread messages
const unread = await readInbox("test-team", "receiver", true, true);
expect(unread.length).toBe(2);
// Now all should be read
const all = await readInbox("test-team", "receiver", false, false);
expect(all.length).toBe(2);
expect(all.every((m) => m.read)).toBe(true);
});
it("should broadcast message to all members except the sender", async () => {
// Setup team config
const config = {
name: "test-team",
members: [{ name: "sender" }, { name: "member1" }, { name: "member2" }],
};
const configFilePath = path.join(testDir, "config.json");
fs.writeFileSync(configFilePath, JSON.stringify(config));
await broadcastMessage("test-team", "sender", "broadcast text", "summary");
// Check member1's inbox
const inbox1 = await readInbox("test-team", "member1", false, false);
expect(inbox1.length).toBe(1);
expect(inbox1[0].text).toBe("broadcast text");
expect(inbox1[0].from).toBe("sender");
// Check member2's inbox
const inbox2 = await readInbox("test-team", "member2", false, false);
expect(inbox2.length).toBe(1);
expect(inbox2[0].text).toBe("broadcast text");
expect(inbox2[0].from).toBe("sender");
// Check sender's inbox (should be empty)
const inboxSender = await readInbox("test-team", "sender", false, false);
expect(inboxSender.length).toBe(0);
});
});

View file

@ -0,0 +1,120 @@
import fs from "node:fs";
import path from "node:path";
import { withLock } from "./lock";
import type { InboxMessage } from "./models";
import { inboxPath } from "./paths";
import { readConfig } from "./teams";
export function nowIso(): string {
return new Date().toISOString();
}
export async function appendMessage(
teamName: string,
agentName: string,
message: InboxMessage,
) {
const p = inboxPath(teamName, agentName);
const dir = path.dirname(p);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
await withLock(p, async () => {
let msgs: InboxMessage[] = [];
if (fs.existsSync(p)) {
msgs = JSON.parse(fs.readFileSync(p, "utf-8"));
}
msgs.push(message);
fs.writeFileSync(p, JSON.stringify(msgs, null, 2));
});
}
export async function readInbox(
teamName: string,
agentName: string,
unreadOnly = false,
markAsRead = true,
): Promise<InboxMessage[]> {
const p = inboxPath(teamName, agentName);
if (!fs.existsSync(p)) return [];
return await withLock(p, async () => {
const allMsgs: InboxMessage[] = JSON.parse(fs.readFileSync(p, "utf-8"));
let result = allMsgs;
if (unreadOnly) {
result = allMsgs.filter((m) => !m.read);
}
if (markAsRead && result.length > 0) {
for (const m of allMsgs) {
if (result.includes(m)) {
m.read = true;
}
}
fs.writeFileSync(p, JSON.stringify(allMsgs, null, 2));
}
return result;
});
}
export async function sendPlainMessage(
teamName: string,
fromName: string,
toName: string,
text: string,
summary: string,
color?: string,
) {
const msg: InboxMessage = {
from: fromName,
text,
timestamp: nowIso(),
read: false,
summary,
color,
};
await appendMessage(teamName, toName, msg);
}
/**
* Broadcasts a message to all team members except the sender.
* @param teamName The name of the team
* @param fromName The name of the sender
* @param text The message text
* @param summary A short summary of the message
* @param color An optional color for the message
*/
export async function broadcastMessage(
teamName: string,
fromName: string,
text: string,
summary: string,
color?: string,
) {
const config = await readConfig(teamName);
// Create an array of delivery promises for all members except the sender
const deliveryPromises = config.members
.filter((member) => member.name !== fromName)
.map((member) =>
sendPlainMessage(teamName, fromName, member.name, text, summary, color),
);
// Execute deliveries in parallel and wait for all to settle
const results = await Promise.allSettled(deliveryPromises);
// Log failures for diagnostics
const failures = results.filter(
(r): r is PromiseRejectedResult => r.status === "rejected",
);
if (failures.length > 0) {
console.error(
`Broadcast partially failed: ${failures.length} messages could not be delivered.`,
);
// Optionally log individual errors
for (const failure of failures) {
console.error("- Delivery error:", failure.reason);
}
}
}

View file

@ -0,0 +1,51 @@
export interface Member {
agentId: string;
name: string;
agentType: string;
model?: string;
joinedAt: number;
tmuxPaneId: string;
windowId?: string;
cwd: string;
subscriptions: any[];
prompt?: string;
color?: string;
thinking?: "off" | "minimal" | "low" | "medium" | "high";
planModeRequired?: boolean;
backendType?: string;
isActive?: boolean;
}
export interface TeamConfig {
name: string;
description: string;
createdAt: number;
leadAgentId: string;
leadSessionId: string;
members: Member[];
defaultModel?: string;
separateWindows?: boolean;
}
export interface TaskFile {
id: string;
subject: string;
description: string;
activeForm?: string;
status: "pending" | "planning" | "in_progress" | "completed" | "deleted";
plan?: string;
planFeedback?: string;
blocks: string[];
blockedBy: string[];
owner?: string;
metadata?: Record<string, any>;
}
export interface InboxMessage {
from: string;
text: string;
timestamp: string;
read: boolean;
summary?: string;
color?: string;
}

View file

@ -0,0 +1,43 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
export const PI_DIR = path.join(os.homedir(), ".pi");
export const TEAMS_DIR = path.join(PI_DIR, "teams");
export const TASKS_DIR = path.join(PI_DIR, "tasks");
export function ensureDirs() {
if (!fs.existsSync(PI_DIR)) fs.mkdirSync(PI_DIR);
if (!fs.existsSync(TEAMS_DIR)) fs.mkdirSync(TEAMS_DIR);
if (!fs.existsSync(TASKS_DIR)) fs.mkdirSync(TASKS_DIR);
}
export function sanitizeName(name: string): string {
// Allow only alphanumeric characters, hyphens, and underscores.
if (/[^a-zA-Z0-9_-]/.test(name)) {
throw new Error(
`Invalid name: "${name}". Only alphanumeric characters, hyphens, and underscores are allowed.`,
);
}
return name;
}
export function teamDir(teamName: string) {
return path.join(TEAMS_DIR, sanitizeName(teamName));
}
export function taskDir(teamName: string) {
return path.join(TASKS_DIR, sanitizeName(teamName));
}
export function inboxPath(teamName: string, agentName: string) {
return path.join(
teamDir(teamName),
"inboxes",
`${sanitizeName(agentName)}.json`,
);
}
export function configPath(teamName: string) {
return path.join(teamDir(teamName), "config.json");
}

View file

@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { inboxPath, sanitizeName, teamDir } from "./paths";
describe("Security Audit - Path Traversal (Prevention Check)", () => {
it("should throw an error for path traversal via teamName", () => {
const maliciousTeamName = "../../etc";
expect(() => teamDir(maliciousTeamName)).toThrow();
});
it("should throw an error for path traversal via agentName", () => {
const teamName = "audit-team";
const maliciousAgentName = "../../../.ssh/id_rsa";
expect(() => inboxPath(teamName, maliciousAgentName)).toThrow();
});
it("should throw an error for path traversal via taskId", () => {
const maliciousTaskId = "../../../etc/passwd";
// We need to import readTask/updateTask or just sanitizeName directly if we want to test the logic
// But since we already tested sanitizeName via other paths, this is just for completeness.
expect(() => sanitizeName(maliciousTaskId)).toThrow();
});
});
describe("Security Audit - Command Injection (Fixed)", () => {
it("should not be vulnerable to command injection in spawn_teammate (via parameters)", () => {
const maliciousCwd = "; rm -rf / ;";
const name = "attacker";
const team_name = "audit-team";
const piBinary = "pi";
const cmd = `PI_TEAM_NAME=${team_name} PI_AGENT_NAME=${name} ${piBinary}`;
// Simulating what happens in spawn_teammate (extensions/index.ts)
const itermCmd = `cd '${maliciousCwd}' && ${cmd}`;
// The command becomes: cd '; rm -rf / ;' && PI_TEAM_NAME=audit-team PI_AGENT_NAME=attacker pi
expect(itermCmd).toContain("cd '; rm -rf / ;' &&");
expect(itermCmd).not.toContain("cd ; rm -rf / ; &&");
});
});

View file

@ -0,0 +1,49 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as paths from "./paths";
import { createTask } from "./tasks";
const testDir = path.join(os.tmpdir(), `pi-tasks-race-test-${Date.now()}`);
describe("Tasks Race Condition Bug", () => {
beforeEach(() => {
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
fs.mkdirSync(testDir, { recursive: true });
vi.spyOn(paths, "taskDir").mockReturnValue(testDir);
vi.spyOn(paths, "configPath").mockReturnValue(
path.join(testDir, "config.json"),
);
fs.writeFileSync(
path.join(testDir, "config.json"),
JSON.stringify({ name: "test-team" }),
);
});
afterEach(() => {
vi.restoreAllMocks();
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
});
it("should potentially fail to create unique IDs under high concurrency (Demonstrating Bug 1)", async () => {
const numTasks = 20;
const promises = [];
for (let i = 0; i < numTasks; i++) {
promises.push(createTask("test-team", `Task ${i}`, `Desc ${i}`));
}
const results = await Promise.all(promises);
const ids = results.map((r) => r.id);
const uniqueIds = new Set(ids);
// If Bug 1 exists (getTaskId outside the lock but actually it is inside the lock in createTask),
// this test might still pass because createTask locks the directory.
// WAIT: I noticed createTask uses withLock(lockPath, ...) where lockPath = dir.
// Let's re-verify createTask in src/utils/tasks.ts
expect(uniqueIds.size).toBe(numTasks);
});
});

View file

@ -0,0 +1,215 @@
// Project: pi-teams
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as paths from "./paths";
import {
createTask,
evaluatePlan,
listTasks,
readTask,
submitPlan,
updateTask,
} from "./tasks";
// Mock the paths to use a temporary directory
const testDir = path.join(os.tmpdir(), `pi-teams-test-${Date.now()}`);
describe("Tasks Utilities", () => {
beforeEach(() => {
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
fs.mkdirSync(testDir, { recursive: true });
// Override paths to use testDir
vi.spyOn(paths, "taskDir").mockReturnValue(testDir);
vi.spyOn(paths, "configPath").mockReturnValue(
path.join(testDir, "config.json"),
);
// Create a dummy team config
fs.writeFileSync(
path.join(testDir, "config.json"),
JSON.stringify({ name: "test-team" }),
);
});
afterEach(() => {
vi.restoreAllMocks();
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
});
it("should create a task successfully", async () => {
const task = await createTask(
"test-team",
"Test Subject",
"Test Description",
);
expect(task.id).toBe("1");
expect(task.subject).toBe("Test Subject");
expect(fs.existsSync(path.join(testDir, "1.json"))).toBe(true);
});
it("should update a task successfully", async () => {
await createTask("test-team", "Test Subject", "Test Description");
const updated = await updateTask("test-team", "1", {
status: "in_progress",
});
expect(updated.status).toBe("in_progress");
const taskData = JSON.parse(
fs.readFileSync(path.join(testDir, "1.json"), "utf-8"),
);
expect(taskData.status).toBe("in_progress");
});
it("should submit a plan successfully", async () => {
const task = await createTask(
"test-team",
"Test Subject",
"Test Description",
);
const plan = "Step 1: Do something\nStep 2: Profit";
const updated = await submitPlan("test-team", task.id, plan);
expect(updated.status).toBe("planning");
expect(updated.plan).toBe(plan);
const taskData = JSON.parse(
fs.readFileSync(path.join(testDir, `${task.id}.json`), "utf-8"),
);
expect(taskData.status).toBe("planning");
expect(taskData.plan).toBe(plan);
});
it("should fail to submit an empty plan", async () => {
const task = await createTask("test-team", "Empty Test", "Should fail");
await expect(submitPlan("test-team", task.id, "")).rejects.toThrow(
"Plan must not be empty",
);
await expect(submitPlan("test-team", task.id, " ")).rejects.toThrow(
"Plan must not be empty",
);
});
it("should list tasks", async () => {
await createTask("test-team", "Task 1", "Desc 1");
await createTask("test-team", "Task 2", "Desc 2");
const tasksList = await listTasks("test-team");
expect(tasksList.length).toBe(2);
expect(tasksList[0].id).toBe("1");
expect(tasksList[1].id).toBe("2");
});
it("should have consistent lock paths (Fixed BUG 2)", async () => {
// This test verifies that both updateTask and readTask now use the same lock path
// Both should now lock `${taskId}.json.lock`
await createTask("test-team", "Bug Test", "Testing lock consistency");
const taskId = "1";
const taskFile = path.join(testDir, `${taskId}.json`);
const commonLockFile = `${taskFile}.lock`;
// 1. Holding the common lock
fs.writeFileSync(commonLockFile, "9999");
// 2. Try updateTask, it should fail
// Using small retries to speed up the test and avoid fake timer issues with native setTimeout
await expect(
updateTask("test-team", taskId, { status: "in_progress" }, 2),
).rejects.toThrow("Could not acquire lock");
// 3. Try readTask, it should fail too
await expect(readTask("test-team", taskId, 2)).rejects.toThrow(
"Could not acquire lock",
);
fs.unlinkSync(commonLockFile);
});
it("should approve a plan successfully", async () => {
const task = await createTask(
"test-team",
"Plan Test",
"Should be approved",
);
await submitPlan("test-team", task.id, "Wait for it...");
const approved = await evaluatePlan("test-team", task.id, "approve");
expect(approved.status).toBe("in_progress");
expect(approved.planFeedback).toBe("");
});
it("should reject a plan with feedback", async () => {
const task = await createTask(
"test-team",
"Plan Test",
"Should be rejected",
);
await submitPlan("test-team", task.id, "Wait for it...");
const feedback = "Not good enough!";
const rejected = await evaluatePlan(
"test-team",
task.id,
"reject",
feedback,
);
expect(rejected.status).toBe("planning");
expect(rejected.planFeedback).toBe(feedback);
});
it("should fail to evaluate a task not in 'planning' status", async () => {
const task = await createTask(
"test-team",
"Status Test",
"Invalid status for eval",
);
// status is "pending"
await expect(evaluatePlan("test-team", task.id, "approve")).rejects.toThrow(
"must be in 'planning' status",
);
});
it("should fail to evaluate a task without a plan", async () => {
const task = await createTask(
"test-team",
"Plan Missing Test",
"No plan submitted",
);
await updateTask("test-team", task.id, { status: "planning" }); // bypass submitPlan to have no plan
await expect(evaluatePlan("test-team", task.id, "approve")).rejects.toThrow(
"no plan has been submitted",
);
});
it("should fail to reject a plan without feedback", async () => {
const task = await createTask(
"test-team",
"Feedback Test",
"Should require feedback",
);
await submitPlan("test-team", task.id, "My plan");
await expect(evaluatePlan("test-team", task.id, "reject")).rejects.toThrow(
"Feedback is required when rejecting a plan",
);
await expect(
evaluatePlan("test-team", task.id, "reject", " "),
).rejects.toThrow("Feedback is required when rejecting a plan");
});
it("should sanitize task IDs in all file operations", async () => {
const dirtyId = "../evil-id";
// sanitizeName should throw on this dirtyId
await expect(readTask("test-team", dirtyId)).rejects.toThrow(
/Invalid name: "..\/evil-id"/,
);
await expect(
updateTask("test-team", dirtyId, { status: "in_progress" }),
).rejects.toThrow(/Invalid name: "..\/evil-id"/);
await expect(evaluatePlan("test-team", dirtyId, "approve")).rejects.toThrow(
/Invalid name: "..\/evil-id"/,
);
});
});

View file

@ -0,0 +1,214 @@
// Project: pi-teams
import fs from "node:fs";
import path from "node:path";
import { runHook } from "./hooks";
import { withLock } from "./lock";
import type { TaskFile } from "./models";
import { sanitizeName, taskDir } from "./paths";
import { teamExists } from "./teams";
export function getTaskId(teamName: string): string {
const dir = taskDir(teamName);
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
const ids = files
.map((f) => parseInt(path.parse(f).name, 10))
.filter((id) => !Number.isNaN(id));
return ids.length > 0 ? (Math.max(...ids) + 1).toString() : "1";
}
function getTaskPath(teamName: string, taskId: string): string {
const dir = taskDir(teamName);
const safeTaskId = sanitizeName(taskId);
return path.join(dir, `${safeTaskId}.json`);
}
export async function createTask(
teamName: string,
subject: string,
description: string,
activeForm = "",
metadata?: Record<string, any>,
): Promise<TaskFile> {
if (!subject || !subject.trim())
throw new Error("Task subject must not be empty");
if (!teamExists(teamName)) throw new Error(`Team ${teamName} does not exist`);
const dir = taskDir(teamName);
const lockPath = dir;
return await withLock(lockPath, async () => {
const id = getTaskId(teamName);
const task: TaskFile = {
id,
subject,
description,
activeForm,
status: "pending",
blocks: [],
blockedBy: [],
metadata,
};
fs.writeFileSync(
path.join(dir, `${id}.json`),
JSON.stringify(task, null, 2),
);
return task;
});
}
export async function updateTask(
teamName: string,
taskId: string,
updates: Partial<TaskFile>,
retries?: number,
): Promise<TaskFile> {
const p = getTaskPath(teamName, taskId);
return await withLock(
p,
async () => {
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
const updated = { ...task, ...updates };
if (updates.status === "deleted") {
fs.unlinkSync(p);
return updated;
}
fs.writeFileSync(p, JSON.stringify(updated, null, 2));
if (updates.status === "completed") {
await runHook(teamName, "task_completed", updated);
}
return updated;
},
retries,
);
}
/**
* Submits a plan for a task, updating its status to "planning".
* @param teamName The name of the team
* @param taskId The ID of the task
* @param plan The content of the plan
* @returns The updated task
*/
export async function submitPlan(
teamName: string,
taskId: string,
plan: string,
): Promise<TaskFile> {
if (!plan || !plan.trim()) throw new Error("Plan must not be empty");
return await updateTask(teamName, taskId, { status: "planning", plan });
}
/**
* Evaluates a submitted plan for a task.
* @param teamName The name of the team
* @param taskId The ID of the task
* @param action The evaluation action: "approve" or "reject"
* @param feedback Optional feedback for the evaluation (required for rejection)
* @param retries Number of times to retry acquiring the lock
* @returns The updated task
*/
export async function evaluatePlan(
teamName: string,
taskId: string,
action: "approve" | "reject",
feedback?: string,
retries?: number,
): Promise<TaskFile> {
const p = getTaskPath(teamName, taskId);
return await withLock(
p,
async () => {
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
// 1. Validate state: Only "planning" tasks can be evaluated
if (task.status !== "planning") {
throw new Error(
`Cannot evaluate plan for task ${taskId} because its status is '${task.status}'. ` +
`Tasks must be in 'planning' status to be evaluated.`,
);
}
// 2. Validate plan presence
if (!task.plan || !task.plan.trim()) {
throw new Error(
`Cannot evaluate plan for task ${taskId} because no plan has been submitted.`,
);
}
// 3. Require feedback for rejections
if (action === "reject" && (!feedback || !feedback.trim())) {
throw new Error("Feedback is required when rejecting a plan.");
}
// 4. Perform update
const updates: Partial<TaskFile> =
action === "approve"
? { status: "in_progress", planFeedback: "" }
: { status: "planning", planFeedback: feedback };
const updated = { ...task, ...updates };
fs.writeFileSync(p, JSON.stringify(updated, null, 2));
return updated;
},
retries,
);
}
export async function readTask(
teamName: string,
taskId: string,
retries?: number,
): Promise<TaskFile> {
const p = getTaskPath(teamName, taskId);
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
return await withLock(
p,
async () => {
return JSON.parse(fs.readFileSync(p, "utf-8"));
},
retries,
);
}
export async function listTasks(teamName: string): Promise<TaskFile[]> {
const dir = taskDir(teamName);
return await withLock(dir, async () => {
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
const tasks: TaskFile[] = files
.map((f) => {
const id = parseInt(path.parse(f).name, 10);
if (Number.isNaN(id)) return null;
return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
})
.filter((t) => t !== null);
return tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
});
}
export async function resetOwnerTasks(teamName: string, agentName: string) {
const dir = taskDir(teamName);
const lockPath = dir;
await withLock(lockPath, async () => {
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
for (const f of files) {
const p = path.join(dir, f);
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
if (task.owner === agentName) {
task.owner = undefined;
if (task.status !== "completed") {
task.status = "pending";
}
fs.writeFileSync(p, JSON.stringify(task, null, 2));
}
}
});
}

View file

@ -0,0 +1,93 @@
import fs from "node:fs";
import { withLock } from "./lock";
import type { Member, TeamConfig } from "./models";
import { configPath, taskDir, teamDir } from "./paths";
export function teamExists(teamName: string) {
return fs.existsSync(configPath(teamName));
}
export function createTeam(
name: string,
sessionId: string,
leadAgentId: string,
description = "",
defaultModel?: string,
separateWindows?: boolean,
): TeamConfig {
const dir = teamDir(name);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const tasksDir = taskDir(name);
if (!fs.existsSync(tasksDir)) fs.mkdirSync(tasksDir, { recursive: true });
const leadMember: Member = {
agentId: leadAgentId,
name: "team-lead",
agentType: "lead",
joinedAt: Date.now(),
tmuxPaneId: process.env.TMUX_PANE || "",
cwd: process.cwd(),
subscriptions: [],
};
const config: TeamConfig = {
name,
description,
createdAt: Date.now(),
leadAgentId,
leadSessionId: sessionId,
members: [leadMember],
defaultModel,
separateWindows,
};
fs.writeFileSync(configPath(name), JSON.stringify(config, null, 2));
return config;
}
function readConfigRaw(p: string): TeamConfig {
return JSON.parse(fs.readFileSync(p, "utf-8"));
}
export async function readConfig(teamName: string): Promise<TeamConfig> {
const p = configPath(teamName);
if (!fs.existsSync(p)) throw new Error(`Team ${teamName} not found`);
return await withLock(p, async () => {
return readConfigRaw(p);
});
}
export async function addMember(teamName: string, member: Member) {
const p = configPath(teamName);
await withLock(p, async () => {
const config = readConfigRaw(p);
config.members.push(member);
fs.writeFileSync(p, JSON.stringify(config, null, 2));
});
}
export async function removeMember(teamName: string, agentName: string) {
const p = configPath(teamName);
await withLock(p, async () => {
const config = readConfigRaw(p);
config.members = config.members.filter((m) => m.name !== agentName);
fs.writeFileSync(p, JSON.stringify(config, null, 2));
});
}
export async function updateMember(
teamName: string,
agentName: string,
updates: Partial<Member>,
) {
const p = configPath(teamName);
await withLock(p, async () => {
const config = readConfigRaw(p);
const m = config.members.find((m) => m.name === agentName);
if (m) {
Object.assign(m, updates);
fs.writeFileSync(p, JSON.stringify(config, null, 2));
}
});
}

View file

@ -0,0 +1,133 @@
/**
* Terminal Adapter Interface
*
* Abstracts terminal multiplexer operations (tmux, iTerm2, Zellij)
* to provide a unified API for spawning, managing, and terminating panes.
*/
import { spawnSync } from "node:child_process";
/**
* Options for spawning a new terminal pane or window
*/
export interface SpawnOptions {
/** Name/identifier for the pane/window */
name: string;
/** Working directory for the new pane/window */
cwd: string;
/** Command to execute in the pane/window */
command: string;
/** Environment variables to set (key-value pairs) */
env: Record<string, string>;
/** Team name for window title formatting (e.g., "team: agent") */
teamName?: string;
}
/**
* Terminal Adapter Interface
*
* Implementations provide terminal-specific logic for pane management.
*/
export interface TerminalAdapter {
/** Unique name identifier for this terminal type */
readonly name: string;
/**
* Detect if this terminal is currently available/active.
* Should check for terminal-specific environment variables or processes.
*
* @returns true if this terminal should be used
*/
detect(): boolean;
/**
* Spawn a new terminal pane with the given options.
*
* @param options - Spawn configuration
* @returns Pane ID that can be used for subsequent operations
* @throws Error if spawn fails
*/
spawn(options: SpawnOptions): string;
/**
* Kill/terminate a terminal pane.
* Should be idempotent - no error if pane doesn't exist.
*
* @param paneId - The pane ID returned from spawn()
*/
kill(paneId: string): void;
/**
* Check if a terminal pane is still alive/active.
*
* @param paneId - The pane ID returned from spawn()
* @returns true if pane exists and is active
*/
isAlive(paneId: string): boolean;
/**
* Set the title of the current terminal pane/window.
* Used for identifying panes in the terminal UI.
*
* @param title - The title to set
*/
setTitle(title: string): void;
/**
* Check if this terminal supports spawning separate OS windows.
* Terminals like tmux and Zellij only support panes/tabs within a session.
*
* @returns true if spawnWindow() is supported
*/
supportsWindows(): boolean;
/**
* Spawn a new separate OS window with the given options.
* Only available if supportsWindows() returns true.
*
* @param options - Spawn configuration
* @returns Window ID that can be used for subsequent operations
* @throws Error if spawn fails or not supported
*/
spawnWindow(options: SpawnOptions): string;
/**
* Set the title of a specific window.
* Used for identifying windows in the OS window manager.
*
* @param windowId - The window ID returned from spawnWindow()
* @param title - The title to set
*/
setWindowTitle(windowId: string, title: string): void;
/**
* Kill/terminate a window.
* Should be idempotent - no error if window doesn't exist.
*
* @param windowId - The window ID returned from spawnWindow()
*/
killWindow(windowId: string): void;
/**
* Check if a window is still alive/active.
*
* @param windowId - The window ID returned from spawnWindow()
* @returns true if window exists and is active
*/
isWindowAlive(windowId: string): boolean;
}
/**
* Base helper for adapters to execute commands synchronously.
*/
export function execCommand(
command: string,
args: string[],
): { stdout: string; stderr: string; status: number | null } {
const result = spawnSync(command, args, { encoding: "utf-8" });
return {
stdout: result.stdout?.toString() ?? "",
stderr: result.stderr?.toString() ?? "",
status: result.status,
};
}