Merge pull request #3 from getcompanion-ai/fix/daemon-install-runtime

fix(runtime): keep daemon alive and localize package installs
This commit is contained in:
Hari 2026-03-05 20:37:09 -05:00 committed by GitHub
commit 45cb0d0479
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 2051 additions and 1939 deletions

View file

@ -32,7 +32,7 @@ fi
if [[ "${CO_MONO_SKIP_BUILD:-0}" != "1" ]]; then if [[ "${CO_MONO_SKIP_BUILD:-0}" != "1" ]]; then
log "Building core packages" log "Building core packages"
BUILD_FAILED=0 BUILD_FAILED=0
for pkg in packages/agent packages/ai packages/tui packages/coding-agent; do for pkg in packages/tui packages/ai packages/agent packages/coding-agent; do
if ! npm run build --workspace "$pkg"; then if ! npm run build --workspace "$pkg"; then
BUILD_FAILED=1 BUILD_FAILED=1
echo "WARN: build failed for $pkg; falling back to source launch mode." echo "WARN: build failed for $pkg; falling back to source launch mode."
@ -43,7 +43,7 @@ else
fi fi
if [[ "$BUILD_FAILED" == "1" ]] && [[ ! -f "$ROOT_DIR/packages/coding-agent/src/cli.ts" ]]; then if [[ "$BUILD_FAILED" == "1" ]] && [[ ! -f "$ROOT_DIR/packages/coding-agent/src/cli.ts" ]]; then
fail "No usable coding-agent CLI source found for source launch fallback." fail "No usable coding-agent CLI source found for source launch fallback."
fi fi
LAUNCHER="$ROOT_DIR/co-mono" LAUNCHER="$ROOT_DIR/co-mono"

View file

@ -1,4 +1,4 @@
import { spawn, spawnSync } from "node:child_process"; import { spawn } from "node:child_process";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os"; import { homedir, tmpdir } from "node:os";
@ -636,7 +636,6 @@ export class DefaultPackageManager implements PackageManager {
private cwd: string; private cwd: string;
private agentDir: string; private agentDir: string;
private settingsManager: SettingsManager; private settingsManager: SettingsManager;
private globalNpmRoot: string | undefined;
private progressCallback: ProgressCallback | undefined; private progressCallback: ProgressCallback | undefined;
constructor(options: PackageManagerOptions) { constructor(options: PackageManagerOptions) {
@ -1157,20 +1156,12 @@ export class DefaultPackageManager implements PackageManager {
} }
private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise<void> { private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise<void> {
if (scope === "user" && !temporary) {
await this.runCommand("npm", ["install", "-g", source.spec]);
return;
}
const installRoot = this.getNpmInstallRoot(scope, temporary); const installRoot = this.getNpmInstallRoot(scope, temporary);
this.ensureNpmProject(installRoot); this.ensureNpmProject(installRoot);
await this.runCommand("npm", ["install", source.spec, "--prefix", installRoot]); await this.runCommand("npm", ["install", source.spec, "--prefix", installRoot]);
} }
private async uninstallNpm(source: NpmSource, scope: SourceScope): Promise<void> { private async uninstallNpm(source: NpmSource, scope: SourceScope): Promise<void> {
if (scope === "user") {
await this.runCommand("npm", ["uninstall", "-g", source.name]);
return;
}
const installRoot = this.getNpmInstallRoot(scope, false); const installRoot = this.getNpmInstallRoot(scope, false);
if (!existsSync(installRoot)) { if (!existsSync(installRoot)) {
return; return;
@ -1297,16 +1288,7 @@ export class DefaultPackageManager implements PackageManager {
if (scope === "project") { if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "npm"); return join(this.cwd, CONFIG_DIR_NAME, "npm");
} }
return join(this.getGlobalNpmRoot(), ".."); return join(this.agentDir, "npm");
}
private getGlobalNpmRoot(): string {
if (this.globalNpmRoot) {
return this.globalNpmRoot;
}
const result = this.runCommandSync("npm", ["root", "-g"]);
this.globalNpmRoot = result.trim();
return this.globalNpmRoot;
} }
private getNpmInstallPath(source: NpmSource, scope: SourceScope): string { private getNpmInstallPath(source: NpmSource, scope: SourceScope): string {
@ -1316,7 +1298,7 @@ export class DefaultPackageManager implements PackageManager {
if (scope === "project") { if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name); return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name);
} }
return join(this.getGlobalNpmRoot(), source.name); return join(this.agentDir, "npm", "node_modules", source.name);
} }
private getGitInstallPath(source: GitSource, scope: SourceScope): string { private getGitInstallPath(source: GitSource, scope: SourceScope): string {
@ -1777,16 +1759,4 @@ export class DefaultPackageManager implements PackageManager {
}); });
}); });
} }
private runCommandSync(command: string, args: string[]): string {
const result = spawnSync(command, args, {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
shell: process.platform === "win32",
});
if (result.status !== 0) {
throw new Error(`Failed to run ${command} ${args.join(" ")}: ${result.stderr || result.stdout}`);
}
return (result.stdout || result.stderr || "").trim();
}
} }

View file

@ -138,6 +138,12 @@ export async function runDaemonMode(session: AgentSession, options: DaemonModeOp
console.error(`[co-mono-daemon] startup complete (session=${session.sessionId ?? "unknown"})`); console.error(`[co-mono-daemon] startup complete (session=${session.sessionId ?? "unknown"})`);
// Keep process alive forever. // Keep process alive forever.
const keepAlive = setInterval(() => {
// Intentionally keep the daemon event loop active.
}, 1000);
ready.finally(() => {
clearInterval(keepAlive);
});
await ready; await ready;
process.exit(0); process.exit(0);
} }

View file

@ -39,13 +39,8 @@
import { SocketModeClient } from "@slack/socket-mode"; import { SocketModeClient } from "@slack/socket-mode";
import { WebClient } from "@slack/web-api"; import { WebClient } from "@slack/web-api";
import type {
ChannelAdapter,
ChannelMessage,
AdapterConfig,
OnIncomingMessage,
} from "../types.ts";
import { getChannelSetting } from "../config.ts"; import { getChannelSetting } from "../config.ts";
import type { AdapterConfig, ChannelAdapter, ChannelMessage, OnIncomingMessage } from "../types.ts";
const MAX_LENGTH = 3000; // Slack block text limit; actual API limit is 4000 but leave margin const MAX_LENGTH = 3000; // Slack block text limit; actual API limit is 4000 but leave margin
@ -88,17 +83,17 @@ export type SlackAdapterLogger = (event: string, data: Record<string, unknown>,
export function createSlackAdapter(config: AdapterConfig, cwd?: string, log?: SlackAdapterLogger): ChannelAdapter { export function createSlackAdapter(config: AdapterConfig, cwd?: string, log?: SlackAdapterLogger): ChannelAdapter {
// Tokens live in settings under pi-channels.slack (not in the adapter config block) // Tokens live in settings under pi-channels.slack (not in the adapter config block)
const appToken = (cwd ? getChannelSetting(cwd, "slack.appToken") as string : null) const appToken = (cwd ? (getChannelSetting(cwd, "slack.appToken") as string) : null) ?? (config.appToken as string);
?? config.appToken as string; const botToken = (cwd ? (getChannelSetting(cwd, "slack.botToken") as string) : null) ?? (config.botToken as string);
const botToken = (cwd ? getChannelSetting(cwd, "slack.botToken") as string : null)
?? config.botToken as string;
const allowedChannelIds = config.allowedChannelIds as string[] | undefined; const allowedChannelIds = config.allowedChannelIds as string[] | undefined;
const respondToMentionsOnly = config.respondToMentionsOnly === true; const respondToMentionsOnly = config.respondToMentionsOnly === true;
const slashCommand = (config.slashCommand as string) ?? "/aivena"; const slashCommand = (config.slashCommand as string) ?? "/aivena";
if (!appToken) throw new Error("Slack adapter requires appToken (xapp-...) in settings under pi-channels.slack.appToken"); if (!appToken)
if (!botToken) throw new Error("Slack adapter requires botToken (xoxb-...) in settings under pi-channels.slack.botToken"); throw new Error("Slack adapter requires appToken (xapp-...) in settings under pi-channels.slack.appToken");
if (!botToken)
throw new Error("Slack adapter requires botToken (xoxb-...) in settings under pi-channels.slack.botToken");
let socketClient: SocketModeClient | null = null; let socketClient: SocketModeClient | null = null;
const webClient = new WebClient(botToken); const webClient = new WebClient(botToken);
@ -119,7 +114,10 @@ export function createSlackAdapter(config: AdapterConfig, cwd?: string, log?: Sl
} }
/** Build metadata common to all incoming messages */ /** Build metadata common to all incoming messages */
function buildMetadata(event: { channel?: string; user?: string; ts?: string; thread_ts?: string; channel_type?: string }, extra?: Record<string, unknown>): Record<string, unknown> { function buildMetadata(
event: { channel?: string; user?: string; ts?: string; thread_ts?: string; channel_type?: string },
extra?: Record<string, unknown>,
): Record<string, unknown> {
return { return {
channelId: event.channel, channelId: event.channel,
userId: event.user, userId: event.user,
@ -184,7 +182,7 @@ export function createSlackAdapter(config: AdapterConfig, cwd?: string, log?: Sl
// Resolve bot user ID (for stripping self-mentions) // Resolve bot user ID (for stripping self-mentions)
try { try {
const authResult = await webClient.auth.test(); const authResult = await webClient.auth.test();
botUserId = authResult.user_id as string ?? null; botUserId = (authResult.user_id as string) ?? null;
} catch { } catch {
// Non-fatal — mention stripping just won't work // Non-fatal — mention stripping just won't work
} }
@ -215,16 +213,20 @@ export function createSlackAdapter(config: AdapterConfig, cwd?: string, log?: Sl
// handled by the app_mention listener to avoid duplicate responses. // handled by the app_mention listener to avoid duplicate responses.
// DMs (im) and multi-party DMs (mpim) don't fire app_mention, so we // DMs (im) and multi-party DMs (mpim) don't fire app_mention, so we
// must NOT skip those here. // must NOT skip those here.
if (botUserId && (event.channel_type === "channel" || event.channel_type === "group") && event.text.includes(`<@${botUserId}>`)) return; if (
botUserId &&
(event.channel_type === "channel" || event.channel_type === "group") &&
event.text.includes(`<@${botUserId}>`)
)
return;
// In channels/groups, optionally only respond to @mentions // In channels/groups, optionally only respond to @mentions
// (app_mention events are handled separately below) // (app_mention events are handled separately below)
if (respondToMentionsOnly && (event.channel_type === "channel" || event.channel_type === "group")) return; if (respondToMentionsOnly && (event.channel_type === "channel" || event.channel_type === "group"))
return;
// Use channel:threadTs as sender key for threaded conversations // Use channel:threadTs as sender key for threaded conversations
const sender = event.thread_ts const sender = event.thread_ts ? `${event.channel}:${event.thread_ts}` : event.channel;
? `${event.channel}:${event.thread_ts}`
: event.channel;
onMessage({ onMessage({
adapter: "slack", adapter: "slack",
@ -234,74 +236,86 @@ export function createSlackAdapter(config: AdapterConfig, cwd?: string, log?: Sl
eventType: "message", eventType: "message",
}), }),
}); });
} catch (err) { log?.("slack-handler-error", { handler: "message", error: String(err) }, "ERROR"); } } catch (err) {
log?.("slack-handler-error", { handler: "message", error: String(err) }, "ERROR");
}
}); });
// ── App mention events ────────────────────────── // ── App mention events ──────────────────────────
socketClient.on("app_mention", async ({ event, ack }: { event: SlackMentionEvent; ack: () => Promise<void> }) => { socketClient.on(
try { "app_mention",
await ack(); async ({ event, ack }: { event: SlackMentionEvent; ack: () => Promise<void> }) => {
try {
await ack();
if (!isAllowed(event.channel)) return; if (!isAllowed(event.channel)) return;
const sender = event.thread_ts const sender = event.thread_ts ? `${event.channel}:${event.thread_ts}` : event.channel;
? `${event.channel}:${event.thread_ts}`
: event.channel;
onMessage({ onMessage({
adapter: "slack", adapter: "slack",
sender, sender,
text: stripBotMention(event.text), text: stripBotMention(event.text),
metadata: buildMetadata(event, { metadata: buildMetadata(event, {
eventType: "app_mention", eventType: "app_mention",
}), }),
}); });
} catch (err) { log?.("slack-handler-error", { handler: "app_mention", error: String(err) }, "ERROR"); } } catch (err) {
}); log?.("slack-handler-error", { handler: "app_mention", error: String(err) }, "ERROR");
}
},
);
// ── Slash commands ─────────────────────────────── // ── Slash commands ───────────────────────────────
socketClient.on("slash_commands", async ({ body, ack }: { body: SlackCommandPayload; ack: (response?: any) => Promise<void> }) => { socketClient.on(
try { "slash_commands",
if (body.command !== slashCommand) { async ({ body, ack }: { body: SlackCommandPayload; ack: (response?: any) => Promise<void> }) => {
await ack(); try {
return; if (body.command !== slashCommand) {
await ack();
return;
}
if (!body.text?.trim()) {
await ack({ text: `Usage: ${slashCommand} [your message]` });
return;
}
if (!isAllowed(body.channel_id)) {
await ack({ text: "⛔ This command is not available in this channel." });
return;
}
// Acknowledge immediately (Slack requires <3s response)
await ack({ text: "🤔 Thinking..." });
onMessage({
adapter: "slack",
sender: body.channel_id,
text: body.text.trim(),
metadata: {
channelId: body.channel_id,
channelName: body.channel_name,
userId: body.user_id,
userName: body.user_name,
eventType: "slash_command",
command: body.command,
},
});
} catch (err) {
log?.("slack-handler-error", { handler: "slash_commands", error: String(err) }, "ERROR");
} }
},
if (!body.text?.trim()) { );
await ack({ text: `Usage: ${slashCommand} [your message]` });
return;
}
if (!isAllowed(body.channel_id)) {
await ack({ text: "⛔ This command is not available in this channel." });
return;
}
// Acknowledge immediately (Slack requires <3s response)
await ack({ text: "🤔 Thinking..." });
onMessage({
adapter: "slack",
sender: body.channel_id,
text: body.text.trim(),
metadata: {
channelId: body.channel_id,
channelName: body.channel_name,
userId: body.user_id,
userName: body.user_name,
eventType: "slash_command",
command: body.command,
},
});
} catch (err) { log?.("slack-handler-error", { handler: "slash_commands", error: String(err) }, "ERROR"); }
});
// ── Interactive payloads (future: button clicks, modals) ── // ── Interactive payloads (future: button clicks, modals) ──
socketClient.on("interactive", async ({ body, ack }: { body: any; ack: () => Promise<void> }) => { socketClient.on("interactive", async ({ body, ack }: { body: any; ack: () => Promise<void> }) => {
try { try {
await ack(); await ack();
// TODO: handle interactive payloads (block actions, modals) // TODO: handle interactive payloads (block actions, modals)
} catch (err) { log?.("slack-handler-error", { handler: "interactive", error: String(err) }, "ERROR"); } } catch (err) {
log?.("slack-handler-error", { handler: "interactive", error: String(err) }, "ERROR");
}
}); });
await socketClient.start(); await socketClient.start();

View file

@ -26,15 +26,15 @@
*/ */
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path";
import type { import type {
AdapterConfig,
ChannelAdapter, ChannelAdapter,
ChannelMessage, ChannelMessage,
AdapterConfig,
OnIncomingMessage,
IncomingMessage,
IncomingAttachment, IncomingAttachment,
IncomingMessage,
OnIncomingMessage,
TranscriptionConfig, TranscriptionConfig,
} from "../types.ts"; } from "../types.ts";
import { createTranscriptionProvider, type TranscriptionProvider } from "./transcription.ts"; import { createTranscriptionProvider, type TranscriptionProvider } from "./transcription.ts";
@ -63,12 +63,49 @@ const TEXT_MIME_TYPES = new Set([
/** File extensions we treat as text even if MIME is generic (application/octet-stream). */ /** File extensions we treat as text even if MIME is generic (application/octet-stream). */
const TEXT_EXTENSIONS = new Set([ const TEXT_EXTENSIONS = new Set([
".md", ".markdown", ".txt", ".csv", ".json", ".jsonl", ".yaml", ".yml", ".md",
".toml", ".xml", ".html", ".htm", ".css", ".js", ".ts", ".tsx", ".jsx", ".markdown",
".py", ".rs", ".go", ".rb", ".php", ".java", ".kt", ".c", ".cpp", ".h", ".txt",
".sh", ".bash", ".zsh", ".fish", ".sql", ".graphql", ".gql", ".csv",
".env", ".ini", ".cfg", ".conf", ".properties", ".log", ".json",
".gitignore", ".dockerignore", ".editorconfig", ".jsonl",
".yaml",
".yml",
".toml",
".xml",
".html",
".htm",
".css",
".js",
".ts",
".tsx",
".jsx",
".py",
".rs",
".go",
".rb",
".php",
".java",
".kt",
".c",
".cpp",
".h",
".sh",
".bash",
".zsh",
".fish",
".sql",
".graphql",
".gql",
".env",
".ini",
".cfg",
".conf",
".properties",
".log",
".gitignore",
".dockerignore",
".editorconfig",
]); ]);
/** Image MIME prefixes. */ /** Image MIME prefixes. */
@ -80,15 +117,22 @@ function isImageMime(mime: string | undefined): boolean {
/** Audio MIME types that can be transcribed. */ /** Audio MIME types that can be transcribed. */
const AUDIO_MIME_PREFIXES = ["audio/"]; const AUDIO_MIME_PREFIXES = ["audio/"];
const AUDIO_MIME_TYPES = new Set([ const AUDIO_MIME_TYPES = new Set([
"audio/mpeg", "audio/mp4", "audio/ogg", "audio/wav", "audio/webm", "audio/mpeg",
"audio/x-m4a", "audio/flac", "audio/aac", "audio/mp3", "audio/mp4",
"audio/ogg",
"audio/wav",
"audio/webm",
"audio/x-m4a",
"audio/flac",
"audio/aac",
"audio/mp3",
"video/ogg", // .ogg containers can be audio-only "video/ogg", // .ogg containers can be audio-only
]); ]);
function isAudioMime(mime: string | undefined): boolean { function isAudioMime(mime: string | undefined): boolean {
if (!mime) return false; if (!mime) return false;
if (AUDIO_MIME_TYPES.has(mime)) return true; if (AUDIO_MIME_TYPES.has(mime)) return true;
return AUDIO_MIME_PREFIXES.some(p => mime.startsWith(p)); return AUDIO_MIME_PREFIXES.some((p) => mime.startsWith(p));
} }
function isTextDocument(mimeType: string | undefined, filename: string | undefined): boolean { function isTextDocument(mimeType: string | undefined, filename: string | undefined): boolean {
@ -166,13 +210,17 @@ export function createTelegramAdapter(config: AdapterConfig): ChannelAdapter {
* Download a file from Telegram by file_id. * Download a file from Telegram by file_id.
* Returns { path, size } or null on failure. * Returns { path, size } or null on failure.
*/ */
async function downloadFile(fileId: string, suggestedName?: string, maxSize = MAX_FILE_SIZE): Promise<{ localPath: string; size: number } | null> { async function downloadFile(
fileId: string,
suggestedName?: string,
maxSize = MAX_FILE_SIZE,
): Promise<{ localPath: string; size: number } | null> {
try { try {
// Get file info // Get file info
const infoRes = await fetch(`${apiBase}/getFile?file_id=${fileId}`); const infoRes = await fetch(`${apiBase}/getFile?file_id=${fileId}`);
if (!infoRes.ok) return null; if (!infoRes.ok) return null;
const info = await infoRes.json() as { const info = (await infoRes.json()) as {
ok: boolean; ok: boolean;
result?: { file_id: string; file_size?: number; file_path?: string }; result?: { file_id: string; file_size?: number; file_path?: string };
}; };
@ -237,7 +285,7 @@ export function createTelegramAdapter(config: AdapterConfig): ChannelAdapter {
continue; continue;
} }
const data = await res.json() as { const data = (await res.json()) as {
ok: boolean; ok: boolean;
result: Array<{ update_id: number; message?: TelegramMessage }>; result: Array<{ update_id: number; message?: TelegramMessage }>;
}; };
@ -574,7 +622,11 @@ export function createTelegramAdapter(config: AdapterConfig): ChannelAdapter {
function cleanupTempFiles(): void { function cleanupTempFiles(): void {
for (const f of tempFiles) { for (const f of tempFiles) {
try { fs.unlinkSync(f); } catch { /* ignore */ } try {
fs.unlinkSync(f);
} catch {
/* ignore */
}
} }
tempFiles.length = 0; tempFiles.length = 0;
} }
@ -663,7 +715,7 @@ interface TelegramMessage {
} }
function sleep(ms: number): Promise<void> { function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
function formatSize(bytes: number): string { function formatSize(bytes: number): string {

View file

@ -11,9 +11,9 @@
* const result = await provider.transcribe("/path/to/audio.ogg", "en"); * const result = await provider.transcribe("/path/to/audio.ogg", "en");
*/ */
import { execFile } from "node:child_process";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import { execFile } from "node:child_process";
import type { TranscriptionConfig } from "../types.ts"; import type { TranscriptionConfig } from "../types.ts";
// ── Public interface ──────────────────────────────────────────── // ── Public interface ────────────────────────────────────────────
@ -187,7 +187,7 @@ class OpenAIProvider implements TranscriptionProvider {
return { ok: false, error: `OpenAI API error (${response.status}): ${body.slice(0, 200)}` }; return { ok: false, error: `OpenAI API error (${response.status}): ${body.slice(0, 200)}` };
} }
const data = await response.json() as { text?: string }; const data = (await response.json()) as { text?: string };
if (!data.text) { if (!data.text) {
return { ok: false, error: "OpenAI returned empty transcription" }; return { ok: false, error: "OpenAI returned empty transcription" };
} }
@ -238,7 +238,7 @@ class ElevenLabsProvider implements TranscriptionProvider {
return { ok: false, error: `ElevenLabs API error (${response.status}): ${body.slice(0, 200)}` }; return { ok: false, error: `ElevenLabs API error (${response.status}): ${body.slice(0, 200)}` };
} }
const data = await response.json() as { text?: string }; const data = (await response.json()) as { text?: string };
if (!data.text) { if (!data.text) {
return { ok: false, error: "ElevenLabs returned empty transcription" }; return { ok: false, error: "ElevenLabs returned empty transcription" };
} }

View file

@ -11,7 +11,7 @@
* } * }
*/ */
import type { ChannelAdapter, ChannelMessage, AdapterConfig } from "../types.ts"; import type { AdapterConfig, ChannelAdapter, ChannelMessage } from "../types.ts";
export function createWebhookAdapter(config: AdapterConfig): ChannelAdapter { export function createWebhookAdapter(config: AdapterConfig): ChannelAdapter {
const method = (config.method as string) ?? "POST"; const method = (config.method as string) ?? "POST";

View file

@ -7,18 +7,12 @@
* senders run concurrently up to maxConcurrent. * senders run concurrently up to maxConcurrent.
*/ */
import type {
IncomingMessage,
IncomingAttachment,
QueuedPrompt,
SenderSession,
BridgeConfig,
} from "../types.ts";
import type { ChannelRegistry } from "../registry.ts";
import type { EventBus } from "@mariozechner/pi-coding-agent"; import type { EventBus } from "@mariozechner/pi-coding-agent";
import { runPrompt } from "./runner.ts"; import type { ChannelRegistry } from "../registry.ts";
import type { BridgeConfig, IncomingAttachment, IncomingMessage, QueuedPrompt, SenderSession } from "../types.ts";
import { type CommandContext, handleCommand, isCommand } from "./commands.ts";
import { RpcSessionManager } from "./rpc-runner.ts"; import { RpcSessionManager } from "./rpc-runner.ts";
import { isCommand, handleCommand, type CommandContext } from "./commands.ts"; import { runPrompt } from "./runner.ts";
import { startTyping } from "./typing.ts"; import { startTyping } from "./typing.ts";
const BRIDGE_DEFAULTS: Required<BridgeConfig> = { const BRIDGE_DEFAULTS: Required<BridgeConfig> = {
@ -144,7 +138,7 @@ export class ChatBridge {
message.adapter, message.adapter,
message.sender, message.sender,
`⚠️ Queue full (${this.config.maxQueuePerSender} pending). ` + `⚠️ Queue full (${this.config.maxQueuePerSender} pending). ` +
`Wait for current prompts to finish or use /abort.`, `Wait for current prompts to finish or use /abort.`,
); );
return; return;
} }
@ -163,7 +157,9 @@ export class ChatBridge {
session.messageCount++; session.messageCount++;
this.events.emit("bridge:enqueue", { this.events.emit("bridge:enqueue", {
id: queued.id, adapter: message.adapter, sender: message.sender, id: queued.id,
adapter: message.adapter,
sender: message.sender,
queueDepth: session.queue.length, queueDepth: session.queue.length,
}); });
@ -183,9 +179,7 @@ export class ChatBridge {
// Typing indicator // Typing indicator
const adapter = this.registry.getAdapter(prompt.adapter); const adapter = this.registry.getAdapter(prompt.adapter);
const typing = this.config.typingIndicators const typing = this.config.typingIndicators ? startTyping(adapter, prompt.sender) : { stop() {} };
? startTyping(adapter, prompt.sender)
: { stop() {} };
const ac = new AbortController(); const ac = new AbortController();
session.abortController = ac; session.abortController = ac;
@ -193,7 +187,9 @@ export class ChatBridge {
const usePersistent = this.shouldUsePersistent(senderKey); const usePersistent = this.shouldUsePersistent(senderKey);
this.events.emit("bridge:start", { this.events.emit("bridge:start", {
id: prompt.id, adapter: prompt.adapter, sender: prompt.sender, id: prompt.id,
adapter: prompt.adapter,
sender: prompt.sender,
text: prompt.text.slice(0, 100), text: prompt.text.slice(0, 100),
persistent: usePersistent, persistent: usePersistent,
}); });
@ -225,22 +221,28 @@ export class ChatBridge {
this.sendReply(prompt.adapter, prompt.sender, "⏹ Aborted."); this.sendReply(prompt.adapter, prompt.sender, "⏹ Aborted.");
} else { } else {
const userError = sanitizeError(result.error); const userError = sanitizeError(result.error);
this.sendReply( this.sendReply(prompt.adapter, prompt.sender, result.response || `${userError}`);
prompt.adapter, prompt.sender,
result.response || `${userError}`,
);
} }
this.events.emit("bridge:complete", { this.events.emit("bridge:complete", {
id: prompt.id, adapter: prompt.adapter, sender: prompt.sender, id: prompt.id,
ok: result.ok, durationMs: result.durationMs, adapter: prompt.adapter,
sender: prompt.sender,
ok: result.ok,
durationMs: result.durationMs,
persistent: usePersistent, persistent: usePersistent,
}); });
this.log("bridge-complete", { this.log(
id: prompt.id, adapter: prompt.adapter, ok: result.ok, "bridge-complete",
durationMs: result.durationMs, persistent: usePersistent, {
}, result.ok ? "INFO" : "WARN"); id: prompt.id,
adapter: prompt.adapter,
ok: result.ok,
durationMs: result.durationMs,
persistent: usePersistent,
},
result.ok ? "INFO" : "WARN",
);
} catch (err: any) { } catch (err: any) {
typing.stop(); typing.stop();
this.log("bridge-error", { adapter: prompt.adapter, sender: prompt.sender, error: err.message }, "ERROR"); this.log("bridge-error", { adapter: prompt.adapter, sender: prompt.sender, error: err.message }, "ERROR");
@ -296,9 +298,7 @@ export class ChatBridge {
adapter: message.adapter, adapter: message.adapter,
sender: message.sender, sender: message.sender,
displayName: displayName:
(message.metadata?.firstName as string) || (message.metadata?.firstName as string) || (message.metadata?.username as string) || message.sender,
(message.metadata?.username as string) ||
message.sender,
queue: [], queue: [],
processing: false, processing: false,
abortController: null, abortController: null,
@ -413,21 +413,20 @@ function sanitizeError(error: string | undefined): string {
if (!error) return "Something went wrong. Please try again."; if (!error) return "Something went wrong. Please try again.";
// Extract the most meaningful line — skip "Extension error" noise and stack traces // Extract the most meaningful line — skip "Extension error" noise and stack traces
const lines = error.split("\n").filter(l => l.trim()); const lines = error.split("\n").filter((l) => l.trim());
// Find the first line that isn't an extension loading error or stack frame // Find the first line that isn't an extension loading error or stack frame
const meaningful = lines.find(l => const meaningful = lines.find(
!l.startsWith("Extension error") && (l) =>
!l.startsWith(" at ") && !l.startsWith("Extension error") &&
!l.startsWith("node:") && !l.startsWith(" at ") &&
!l.includes("NODE_MODULE_VERSION") && !l.startsWith("node:") &&
!l.includes("compiled against a different") && !l.includes("NODE_MODULE_VERSION") &&
!l.includes("Emitted 'error' event") !l.includes("compiled against a different") &&
!l.includes("Emitted 'error' event"),
); );
const msg = meaningful?.trim() || "Something went wrong. Please try again."; const msg = meaningful?.trim() || "Something went wrong. Please try again.";
return msg.length > MAX_ERROR_LENGTH return msg.length > MAX_ERROR_LENGTH ? msg.slice(0, MAX_ERROR_LENGTH) + "…" : msg;
? msg.slice(0, MAX_ERROR_LENGTH) + "…"
: msg;
} }

View file

@ -51,11 +51,7 @@ export function getAllCommands(): BotCommand[] {
* Handle a command. Returns reply text, or null if unrecognized * Handle a command. Returns reply text, or null if unrecognized
* (fall through to agent). * (fall through to agent).
*/ */
export function handleCommand( export function handleCommand(text: string, session: SenderSession | undefined, ctx: CommandContext): string | null {
text: string,
session: SenderSession | undefined,
ctx: CommandContext,
): string | null {
const { command } = parseCommand(text); const { command } = parseCommand(text);
if (!command) return null; if (!command) return null;
const cmd = commands.get(command); const cmd = commands.get(command);
@ -89,9 +85,7 @@ registerCommand({
handler: (_args, session, ctx) => { handler: (_args, session, ctx) => {
if (!session) return "No active session."; if (!session) return "No active session.";
if (!session.processing) return "Nothing is running right now."; if (!session.processing) return "Nothing is running right now.";
return ctx.abortCurrent(session.sender) return ctx.abortCurrent(session.sender) ? "⏹ Aborting current prompt..." : "Failed to abort — nothing running.";
? "⏹ Aborting current prompt..."
: "Failed to abort — nothing running.";
}, },
}); });

View file

@ -12,9 +12,9 @@
* 4. Subprocess crash triggers auto-restart on next message * 4. Subprocess crash triggers auto-restart on next message
*/ */
import { spawn, type ChildProcess } from "node:child_process"; import { type ChildProcess, spawn } from "node:child_process";
import * as readline from "node:readline"; import * as readline from "node:readline";
import type { RunResult, IncomingAttachment } from "../types.ts"; import type { IncomingAttachment, RunResult } from "../types.ts";
export interface RpcRunnerOptions { export interface RpcRunnerOptions {
cwd: string; cwd: string;
@ -176,8 +176,7 @@ export class RpcSession {
return; return;
} }
options.signal.addEventListener("abort", onAbort, { once: true }); options.signal.addEventListener("abort", onAbort, { once: true });
this.pending.abortHandler = () => this.pending.abortHandler = () => options.signal?.removeEventListener("abort", onAbort);
options.signal?.removeEventListener("abort", onAbort);
} }
// Build prompt command // Build prompt command

View file

@ -6,8 +6,8 @@
* Same pattern as pi-cron and pi-heartbeat. * Same pattern as pi-cron and pi-heartbeat.
*/ */
import { spawn, type ChildProcess } from "node:child_process"; import { type ChildProcess, spawn } from "node:child_process";
import type { RunResult, IncomingAttachment } from "../types.ts"; import type { IncomingAttachment, RunResult } from "../types.ts";
export interface RunOptions { export interface RunOptions {
prompt: string; prompt: string;
@ -56,25 +56,37 @@ export function runPrompt(options: RunOptions): Promise<RunResult> {
}); });
} catch (err: any) { } catch (err: any) {
resolve({ resolve({
ok: false, response: "", error: `Failed to spawn: ${err.message}`, ok: false,
durationMs: Date.now() - startTime, exitCode: 1, response: "",
error: `Failed to spawn: ${err.message}`,
durationMs: Date.now() - startTime,
exitCode: 1,
}); });
return; return;
} }
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString(); }); child.stdout?.on("data", (chunk: Buffer) => {
child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString(); }); stdout += chunk.toString();
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
const onAbort = () => { const onAbort = () => {
child.kill("SIGTERM"); child.kill("SIGTERM");
setTimeout(() => { if (!child.killed) child.kill("SIGKILL"); }, 3000); setTimeout(() => {
if (!child.killed) child.kill("SIGKILL");
}, 3000);
}; };
if (signal) { if (signal) {
if (signal.aborted) { onAbort(); } if (signal.aborted) {
else { signal.addEventListener("abort", onAbort, { once: true }); } onAbort();
} else {
signal.addEventListener("abort", onAbort, { once: true });
}
} }
child.on("close", (code) => { child.on("close", (code) => {
@ -84,7 +96,13 @@ export function runPrompt(options: RunOptions): Promise<RunResult> {
const exitCode = code ?? 1; const exitCode = code ?? 1;
if (signal?.aborted) { if (signal?.aborted) {
resolve({ ok: false, response: response || "(aborted)", error: "Aborted by user", durationMs, exitCode: 130 }); resolve({
ok: false,
response: response || "(aborted)",
error: "Aborted by user",
durationMs,
exitCode: 130,
});
} else if (exitCode !== 0) { } else if (exitCode !== 0) {
resolve({ ok: false, response, error: stderr.trim() || `Exit code ${exitCode}`, durationMs, exitCode }); resolve({ ok: false, response, error: stderr.trim() || `Exit code ${exitCode}`, durationMs, exitCode });
} else { } else {

View file

@ -14,10 +14,7 @@ const TYPING_INTERVAL_MS = 4_000;
* Start sending typing indicators. Returns a stop() handle. * Start sending typing indicators. Returns a stop() handle.
* No-op if the adapter doesn't support sendTyping. * No-op if the adapter doesn't support sendTyping.
*/ */
export function startTyping( export function startTyping(adapter: ChannelAdapter | undefined, recipient: string): { stop: () => void } {
adapter: ChannelAdapter | undefined,
recipient: string,
): { stop: () => void } {
if (!adapter?.sendTyping) return { stop() {} }; if (!adapter?.sendTyping) return { stop() {} };
// Fire immediately // Fire immediately

View file

@ -15,9 +15,9 @@
*/ */
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import type { ChatBridge } from "./bridge/bridge.ts";
import type { ChannelRegistry } from "./registry.ts"; import type { ChannelRegistry } from "./registry.ts";
import type { ChannelAdapter, ChannelMessage, IncomingMessage } from "./types.ts"; import type { ChannelAdapter, ChannelMessage, IncomingMessage } from "./types.ts";
import type { ChatBridge } from "./bridge/bridge.ts";
/** Reference to the active bridge, set by index.ts after construction. */ /** Reference to the active bridge, set by index.ts after construction. */
let activeBridge: ChatBridge | null = null; let activeBridge: ChatBridge | null = null;
@ -27,7 +27,6 @@ export function setBridge(bridge: ChatBridge | null): void {
} }
export function registerChannelEvents(pi: ExtensionAPI, registry: ChannelRegistry): void { export function registerChannelEvents(pi: ExtensionAPI, registry: ChannelRegistry): void {
// ── Incoming messages → channel:receive (+ bridge) ────── // ── Incoming messages → channel:receive (+ bridge) ──────
registry.setOnIncoming((message: IncomingMessage) => { registry.setOnIncoming((message: IncomingMessage) => {
@ -53,9 +52,7 @@ export function registerChannelEvents(pi: ExtensionAPI, registry: ChannelRegistr
if (!event.job.channel) return; if (!event.job.channel) return;
if (!event.response && !event.error) return; if (!event.response && !event.error) return;
const text = event.ok const text = event.ok ? (event.response ?? "(no output)") : `❌ Error: ${event.error ?? "unknown"}`;
? event.response ?? "(no output)"
: `❌ Error: ${event.error ?? "unknown"}`;
registry.send({ registry.send({
adapter: event.job.channel, adapter: event.job.channel,
@ -70,7 +67,7 @@ export function registerChannelEvents(pi: ExtensionAPI, registry: ChannelRegistr
pi.events.on("channel:send", (raw: unknown) => { pi.events.on("channel:send", (raw: unknown) => {
const data = raw as ChannelMessage & { callback?: (result: { ok: boolean; error?: string }) => void }; const data = raw as ChannelMessage & { callback?: (result: { ok: boolean; error?: string }) => void };
registry.send(data).then(r => data.callback?.(r)); registry.send(data).then((r) => data.callback?.(r));
}); });
// ── channel:register — add a custom adapter ────────────── // ── channel:register — add a custom adapter ──────────────
@ -102,12 +99,18 @@ export function registerChannelEvents(pi: ExtensionAPI, registry: ChannelRegistr
// ── channel:test — send a test ping ────────────────────── // ── channel:test — send a test ping ──────────────────────
pi.events.on("channel:test", (raw: unknown) => { pi.events.on("channel:test", (raw: unknown) => {
const data = raw as { adapter: string; recipient: string; callback?: (result: { ok: boolean; error?: string }) => void }; const data = raw as {
registry.send({ adapter: string;
adapter: data.adapter, recipient: string;
recipient: data.recipient ?? "", callback?: (result: { ok: boolean; error?: string }) => void;
text: `🏓 pi-channels test — ${new Date().toISOString()}`, };
source: "channel:test", registry
}).then(r => data.callback?.(r)); .send({
adapter: data.adapter,
recipient: data.recipient ?? "",
text: `🏓 pi-channels test — ${new Date().toISOString()}`,
source: "channel:test",
})
.then((r) => data.callback?.(r));
}); });
} }

View file

@ -35,12 +35,12 @@
*/ */
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { loadConfig } from "./config.ts";
import { ChannelRegistry } from "./registry.ts";
import { registerChannelEvents, setBridge } from "./events.ts";
import { registerChannelTool } from "./tool.ts";
import { ChatBridge } from "./bridge/bridge.ts"; import { ChatBridge } from "./bridge/bridge.ts";
import { loadConfig } from "./config.ts";
import { registerChannelEvents, setBridge } from "./events.ts";
import { createLogger } from "./logger.ts"; import { createLogger } from "./logger.ts";
import { ChannelRegistry } from "./registry.ts";
import { registerChannelTool } from "./tool.ts";
export default function (pi: ExtensionAPI) { export default function (pi: ExtensionAPI) {
const log = createLogger(pi); const log = createLogger(pi);
@ -76,7 +76,7 @@ export default function (pi: ExtensionAPI) {
// Start incoming/bidirectional adapters // Start incoming/bidirectional adapters
await registry.startListening(); await registry.startListening();
const startErrors = registry.getErrors().filter(e => e.error.startsWith("Failed to start")); const startErrors = registry.getErrors().filter((e) => e.error.startsWith("Failed to start"));
for (const err of startErrors) { for (const err of startErrors) {
ctx.ui.notify(`pi-channels: ${err.adapter}: ${err.error}`, "warning"); ctx.ui.notify(`pi-channels: ${err.adapter}: ${err.error}`, "warning");
} }
@ -105,9 +105,7 @@ export default function (pi: ExtensionAPI) {
pi.registerCommand("chat-bridge", { pi.registerCommand("chat-bridge", {
description: "Manage chat bridge: /chat-bridge [on|off|status]", description: "Manage chat bridge: /chat-bridge [on|off|status]",
getArgumentCompletions: (prefix: string) => { getArgumentCompletions: (prefix: string) => {
return ["on", "off", "status"] return ["on", "off", "status"].filter((c) => c.startsWith(prefix)).map((c) => ({ value: c, label: c }));
.filter(c => c.startsWith(prefix))
.map(c => ({ value: c, label: c }));
}, },
handler: async (args, ctx) => { handler: async (args, ctx) => {
const cmd = args?.trim().toLowerCase(); const cmd = args?.trim().toLowerCase();

View file

@ -2,10 +2,18 @@
* pi-channels Adapter registry + route resolution. * pi-channels Adapter registry + route resolution.
*/ */
import type { ChannelAdapter, ChannelMessage, AdapterConfig, ChannelConfig, AdapterDirection, OnIncomingMessage, IncomingMessage } from "./types.ts"; import { createSlackAdapter } from "./adapters/slack.ts";
import { createTelegramAdapter } from "./adapters/telegram.ts"; import { createTelegramAdapter } from "./adapters/telegram.ts";
import { createWebhookAdapter } from "./adapters/webhook.ts"; import { createWebhookAdapter } from "./adapters/webhook.ts";
import { createSlackAdapter } from "./adapters/slack.ts"; import type {
AdapterConfig,
AdapterDirection,
ChannelAdapter,
ChannelConfig,
ChannelMessage,
IncomingMessage,
OnIncomingMessage,
} from "./types.ts";
// ── Built-in adapter factories ────────────────────────────────── // ── Built-in adapter factories ──────────────────────────────────
@ -161,7 +169,8 @@ export class ChannelRegistry {
/** List all registered adapters and route aliases. */ /** List all registered adapters and route aliases. */
list(): Array<{ name: string; type: "adapter" | "route"; direction?: AdapterDirection; target?: string }> { list(): Array<{ name: string; type: "adapter" | "route"; direction?: AdapterDirection; target?: string }> {
const result: Array<{ name: string; type: "adapter" | "route"; direction?: AdapterDirection; target?: string }> = []; const result: Array<{ name: string; type: "adapter" | "route"; direction?: AdapterDirection; target?: string }> =
[];
for (const [name, adapter] of this.adapters) { for (const [name, adapter] of this.adapters) {
result.push({ name, type: "adapter", direction: adapter.direction }); result.push({ name, type: "adapter", direction: adapter.direction });
} }

View file

@ -2,9 +2,9 @@
* pi-channels LLM tool registration. * pi-channels LLM tool registration.
*/ */
import { StringEnum } from "@mariozechner/pi-ai";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import { StringEnum } from "@mariozechner/pi-ai";
import type { ChannelRegistry } from "./registry.ts"; import type { ChannelRegistry } from "./registry.ts";
interface ChannelToolParams { interface ChannelToolParams {
@ -23,22 +23,15 @@ export function registerChannelTool(pi: ExtensionAPI, registry: ChannelRegistry)
"Send notifications via configured adapters (Telegram, webhooks, custom). " + "Send notifications via configured adapters (Telegram, webhooks, custom). " +
"Actions: send (deliver a message), list (show adapters + routes), test (send a ping).", "Actions: send (deliver a message), list (show adapters + routes), test (send a ping).",
parameters: Type.Object({ parameters: Type.Object({
action: StringEnum( action: StringEnum(["send", "list", "test"] as const, { description: "Action to perform" }) as any,
["send", "list", "test"] as const, adapter: Type.Optional(Type.String({ description: "Adapter name or route alias (required for send, test)" })),
{ description: "Action to perform" },
) as any,
adapter: Type.Optional(
Type.String({ description: "Adapter name or route alias (required for send, test)" }),
),
recipient: Type.Optional( recipient: Type.Optional(
Type.String({ description: "Recipient — chat ID, webhook URL, etc. (required for send unless using a route)" }), Type.String({
), description: "Recipient — chat ID, webhook URL, etc. (required for send unless using a route)",
text: Type.Optional( }),
Type.String({ description: "Message text (required for send)" }),
),
source: Type.Optional(
Type.String({ description: "Source label (optional)" }),
), ),
text: Type.Optional(Type.String({ description: "Message text (required for send)" })),
source: Type.Optional(Type.String({ description: "Source label (optional)" })),
}) as any, }) as any,
async execute(_toolCallId, _params) { async execute(_toolCallId, _params) {
@ -51,10 +44,10 @@ export function registerChannelTool(pi: ExtensionAPI, registry: ChannelRegistry)
if (items.length === 0) { if (items.length === 0) {
result = 'No adapters configured. Add "pi-channels" to your settings.json.'; result = 'No adapters configured. Add "pi-channels" to your settings.json.';
} else { } else {
const lines = items.map(i => const lines = items.map((i) =>
i.type === "route" i.type === "route"
? `- **${i.name}** (route → ${i.target})` ? `- **${i.name}** (route → ${i.target})`
: `- **${i.name}** (${i.direction ?? "adapter"})` : `- **${i.name}** (${i.direction ?? "adapter"})`,
); );
result = `**Channel (${items.length}):**\n${lines.join("\n")}`; result = `**Channel (${items.length}):**\n${lines.join("\n")}`;
} }

View file

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

@ -5,53 +5,53 @@
* Uses AppleScript for all operations. * Uses AppleScript for all operations.
*/ */
import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { execCommand, type SpawnOptions, type TerminalAdapter } from "../utils/terminal-adapter";
/** /**
* Context needed for iTerm2 spawning (tracks last pane for layout) * Context needed for iTerm2 spawning (tracks last pane for layout)
*/ */
export interface Iterm2SpawnContext { export interface Iterm2SpawnContext {
/** ID of the last spawned session, used for layout decisions */ /** ID of the last spawned session, used for layout decisions */
lastSessionId?: string; lastSessionId?: string;
} }
export class Iterm2Adapter implements TerminalAdapter { export class Iterm2Adapter implements TerminalAdapter {
readonly name = "iTerm2"; readonly name = "iTerm2";
private spawnContext: Iterm2SpawnContext = {}; private spawnContext: Iterm2SpawnContext = {};
detect(): boolean { detect(): boolean {
return process.env.TERM_PROGRAM === "iTerm.app" && !process.env.TMUX && !process.env.ZELLIJ; 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 * Helper to execute AppleScript via stdin to avoid escaping issues with -e
*/ */
private runAppleScript(script: string): { stdout: string; stderr: string; status: number | null } { private runAppleScript(script: string): { stdout: string; stderr: string; status: number | null } {
const result = spawnSync("osascript", ["-"], { const result = spawnSync("osascript", ["-"], {
input: script, input: script,
encoding: "utf-8", encoding: "utf-8",
}); });
return { return {
stdout: result.stdout?.toString() ?? "", stdout: result.stdout?.toString() ?? "",
stderr: result.stderr?.toString() ?? "", stderr: result.stderr?.toString() ?? "",
status: result.status, status: result.status,
}; };
} }
spawn(options: SpawnOptions): string { spawn(options: SpawnOptions): string {
const envStr = Object.entries(options.env) const envStr = Object.entries(options.env)
.filter(([k]) => k.startsWith("PI_")) .filter(([k]) => k.startsWith("PI_"))
.map(([k, v]) => `${k}=${v}`) .map(([k, v]) => `${k}=${v}`)
.join(" "); .join(" ");
const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`; const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`;
const escapedCmd = itermCmd.replace(/"/g, '\\"'); const escapedCmd = itermCmd.replace(/"/g, '\\"');
let script: string; let script: string;
if (!this.spawnContext.lastSessionId) { if (!this.spawnContext.lastSessionId) {
script = `tell application "iTerm2" script = `tell application "iTerm2"
tell current session of current window tell current session of current window
set newSession to split vertically with default profile set newSession to split vertically with default profile
tell newSession tell newSession
@ -60,8 +60,8 @@ export class Iterm2Adapter implements TerminalAdapter {
end tell end tell
end tell end tell
end tell`; end tell`;
} else { } else {
script = `tell application "iTerm2" script = `tell application "iTerm2"
repeat with aWindow in windows repeat with aWindow in windows
repeat with aTab in tabs of aWindow repeat with aTab in tabs of aWindow
repeat with aSession in sessions of aTab repeat with aSession in sessions of aTab
@ -78,27 +78,27 @@ end tell`;
end repeat end repeat
end repeat end repeat
end tell`; end tell`;
} }
const result = this.runAppleScript(script); const result = this.runAppleScript(script);
if (result.status !== 0) { if (result.status !== 0) {
throw new Error(`osascript failed with status ${result.status}: ${result.stderr}`); throw new Error(`osascript failed with status ${result.status}: ${result.stderr}`);
} }
const sessionId = result.stdout.toString().trim(); const sessionId = result.stdout.toString().trim();
this.spawnContext.lastSessionId = sessionId; this.spawnContext.lastSessionId = sessionId;
return `iterm_${sessionId}`; return `iterm_${sessionId}`;
} }
kill(paneId: string): void { kill(paneId: string): void {
if (!paneId || !paneId.startsWith("iterm_") || paneId.startsWith("iterm_win_")) { if (!paneId || !paneId.startsWith("iterm_") || paneId.startsWith("iterm_win_")) {
return; return;
} }
const itermId = paneId.replace("iterm_", ""); const itermId = paneId.replace("iterm_", "");
const script = `tell application "iTerm2" const script = `tell application "iTerm2"
repeat with aWindow in windows repeat with aWindow in windows
repeat with aTab in tabs of aWindow repeat with aTab in tabs of aWindow
repeat with aSession in sessions of aTab repeat with aSession in sessions of aTab
@ -111,20 +111,20 @@ end tell`;
end repeat end repeat
end tell`; end tell`;
try { try {
this.runAppleScript(script); this.runAppleScript(script);
} catch { } catch {
// Ignore errors // Ignore errors
} }
} }
isAlive(paneId: string): boolean { isAlive(paneId: string): boolean {
if (!paneId || !paneId.startsWith("iterm_") || paneId.startsWith("iterm_win_")) { if (!paneId || !paneId.startsWith("iterm_") || paneId.startsWith("iterm_win_")) {
return false; return false;
} }
const itermId = paneId.replace("iterm_", ""); const itermId = paneId.replace("iterm_", "");
const script = `tell application "iTerm2" const script = `tell application "iTerm2"
repeat with aWindow in windows repeat with aWindow in windows
repeat with aTab in tabs of aWindow repeat with aTab in tabs of aWindow
repeat with aSession in sessions of aTab repeat with aSession in sessions of aTab
@ -136,52 +136,50 @@ end tell`;
end repeat end repeat
end tell`; end tell`;
try { try {
const result = this.runAppleScript(script); const result = this.runAppleScript(script);
return result.stdout.includes("Alive"); return result.stdout.includes("Alive");
} catch { } catch {
return false; return false;
} }
} }
setTitle(title: string): void { setTitle(title: string): void {
const escapedTitle = title.replace(/"/g, '\\"'); const escapedTitle = title.replace(/"/g, '\\"');
const script = `tell application "iTerm2" to tell current session of current window const script = `tell application "iTerm2" to tell current session of current window
set name to "${escapedTitle}" set name to "${escapedTitle}"
end tell`; end tell`;
try { try {
this.runAppleScript(script); this.runAppleScript(script);
} catch { } catch {
// Ignore errors // Ignore errors
} }
} }
/** /**
* iTerm2 supports spawning separate OS windows via AppleScript * iTerm2 supports spawning separate OS windows via AppleScript
*/ */
supportsWindows(): boolean { supportsWindows(): boolean {
return true; return true;
} }
/** /**
* Spawn a new separate OS window with the given options. * Spawn a new separate OS window with the given options.
*/ */
spawnWindow(options: SpawnOptions): string { spawnWindow(options: SpawnOptions): string {
const envStr = Object.entries(options.env) const envStr = Object.entries(options.env)
.filter(([k]) => k.startsWith("PI_")) .filter(([k]) => k.startsWith("PI_"))
.map(([k, v]) => `${k}=${v}`) .map(([k, v]) => `${k}=${v}`)
.join(" "); .join(" ");
const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`; const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`;
const escapedCmd = itermCmd.replace(/"/g, '\\"'); const escapedCmd = itermCmd.replace(/"/g, '\\"');
const windowTitle = options.teamName const windowTitle = options.teamName ? `${options.teamName}: ${options.name}` : options.name;
? `${options.teamName}: ${options.name}`
: options.name;
const escapedTitle = windowTitle.replace(/"/g, '\\"'); const escapedTitle = windowTitle.replace(/"/g, '\\"');
const script = `tell application "iTerm2" const script = `tell application "iTerm2"
set newWindow to (create window with default profile) set newWindow to (create window with default profile)
tell current session of newWindow tell current session of newWindow
-- Set the session name (tab title) -- Set the session name (tab title)
@ -195,28 +193,28 @@ end tell`;
end tell end tell
end tell`; end tell`;
const result = this.runAppleScript(script); const result = this.runAppleScript(script);
if (result.status !== 0) { if (result.status !== 0) {
throw new Error(`osascript failed with status ${result.status}: ${result.stderr}`); throw new Error(`osascript failed with status ${result.status}: ${result.stderr}`);
} }
const windowId = result.stdout.toString().trim(); const windowId = result.stdout.toString().trim();
return `iterm_win_${windowId}`; return `iterm_win_${windowId}`;
} }
/** /**
* Set the title of a specific window. * Set the title of a specific window.
*/ */
setWindowTitle(windowId: string, title: string): void { setWindowTitle(windowId: string, title: string): void {
if (!windowId || !windowId.startsWith("iterm_win_")) { if (!windowId || !windowId.startsWith("iterm_win_")) {
return; return;
} }
const itermId = windowId.replace("iterm_win_", ""); const itermId = windowId.replace("iterm_win_", "");
const escapedTitle = title.replace(/"/g, '\\"'); const escapedTitle = title.replace(/"/g, '\\"');
const script = `tell application "iTerm2" const script = `tell application "iTerm2"
repeat with aWindow in windows repeat with aWindow in windows
if id of aWindow is "${itermId}" then if id of aWindow is "${itermId}" then
tell current session of aWindow tell current session of aWindow
@ -227,23 +225,23 @@ end tell`;
end repeat end repeat
end tell`; end tell`;
try { try {
this.runAppleScript(script); this.runAppleScript(script);
} catch { } catch {
// Silently fail // Silently fail
} }
} }
/** /**
* Kill/terminate a window. * Kill/terminate a window.
*/ */
killWindow(windowId: string): void { killWindow(windowId: string): void {
if (!windowId || !windowId.startsWith("iterm_win_")) { if (!windowId || !windowId.startsWith("iterm_win_")) {
return; return;
} }
const itermId = windowId.replace("iterm_win_", ""); const itermId = windowId.replace("iterm_win_", "");
const script = `tell application "iTerm2" const script = `tell application "iTerm2"
repeat with aWindow in windows repeat with aWindow in windows
if id of aWindow is "${itermId}" then if id of aWindow is "${itermId}" then
close aWindow close aWindow
@ -252,23 +250,23 @@ end tell`;
end repeat end repeat
end tell`; end tell`;
try { try {
this.runAppleScript(script); this.runAppleScript(script);
} catch { } catch {
// Silently fail // Silently fail
} }
} }
/** /**
* Check if a window is still alive/active. * Check if a window is still alive/active.
*/ */
isWindowAlive(windowId: string): boolean { isWindowAlive(windowId: string): boolean {
if (!windowId || !windowId.startsWith("iterm_win_")) { if (!windowId || !windowId.startsWith("iterm_win_")) {
return false; return false;
} }
const itermId = windowId.replace("iterm_win_", ""); const itermId = windowId.replace("iterm_win_", "");
const script = `tell application "iTerm2" const script = `tell application "iTerm2"
repeat with aWindow in windows repeat with aWindow in windows
if id of aWindow is "${itermId}" then if id of aWindow is "${itermId}" then
return "Alive" return "Alive"
@ -276,25 +274,25 @@ end tell`;
end repeat end repeat
end tell`; end tell`;
try { try {
const result = this.runAppleScript(script); const result = this.runAppleScript(script);
return result.stdout.includes("Alive"); return result.stdout.includes("Alive");
} catch { } catch {
return false; return false;
} }
} }
/** /**
* Set the spawn context (used to restore state when needed) * Set the spawn context (used to restore state when needed)
*/ */
setSpawnContext(context: Iterm2SpawnContext): void { setSpawnContext(context: Iterm2SpawnContext): void {
this.spawnContext = context; this.spawnContext = context;
} }
/** /**
* Get current spawn context (useful for persisting state) * Get current spawn context (useful for persisting state)
*/ */
getSpawnContext(): Iterm2SpawnContext { getSpawnContext(): Iterm2SpawnContext {
return { ...this.spawnContext }; return { ...this.spawnContext };
} }
} }

View file

@ -1,16 +1,16 @@
/** /**
* Terminal Registry * Terminal Registry
* *
* Manages terminal adapters and provides automatic selection based on * Manages terminal adapters and provides automatic selection based on
* the current environment. * the current environment.
*/ */
import { TerminalAdapter } from "../utils/terminal-adapter"; import type { TerminalAdapter } from "../utils/terminal-adapter";
import { TmuxAdapter } from "./tmux-adapter";
import { Iterm2Adapter } from "./iterm2-adapter";
import { ZellijAdapter } from "./zellij-adapter";
import { WezTermAdapter } from "./wezterm-adapter";
import { CmuxAdapter } from "./cmux-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 * Available terminal adapters, ordered by priority
@ -23,11 +23,11 @@ import { CmuxAdapter } from "./cmux-adapter";
* 4. WezTerm - if WEZTERM_PANE env is set and not in tmux/zellij * 4. WezTerm - if WEZTERM_PANE env is set and not in tmux/zellij
*/ */
const adapters: TerminalAdapter[] = [ const adapters: TerminalAdapter[] = [
new CmuxAdapter(), new CmuxAdapter(),
new TmuxAdapter(), new TmuxAdapter(),
new ZellijAdapter(), new ZellijAdapter(),
new Iterm2Adapter(), new Iterm2Adapter(),
new WezTermAdapter(), new WezTermAdapter(),
]; ];
/** /**
@ -47,18 +47,18 @@ let cachedAdapter: TerminalAdapter | null = null;
* @returns The detected terminal adapter, or null if none detected * @returns The detected terminal adapter, or null if none detected
*/ */
export function getTerminalAdapter(): TerminalAdapter | null { export function getTerminalAdapter(): TerminalAdapter | null {
if (cachedAdapter) { if (cachedAdapter) {
return cachedAdapter; return cachedAdapter;
} }
for (const adapter of adapters) { for (const adapter of adapters) {
if (adapter.detect()) { if (adapter.detect()) {
cachedAdapter = adapter; cachedAdapter = adapter;
return adapter; return adapter;
} }
} }
return null; return null;
} }
/** /**
@ -68,56 +68,56 @@ export function getTerminalAdapter(): TerminalAdapter | null {
* @returns The adapter instance, or undefined if not found * @returns The adapter instance, or undefined if not found
*/ */
export function getAdapterByName(name: string): TerminalAdapter | undefined { export function getAdapterByName(name: string): TerminalAdapter | undefined {
return adapters.find(a => a.name === name); return adapters.find((a) => a.name === name);
} }
/** /**
* Get all available adapters. * Get all available adapters.
* *
* @returns Array of all registered adapters * @returns Array of all registered adapters
*/ */
export function getAllAdapters(): TerminalAdapter[] { export function getAllAdapters(): TerminalAdapter[] {
return [...adapters]; return [...adapters];
} }
/** /**
* Clear the cached adapter (useful for testing or environment changes) * Clear the cached adapter (useful for testing or environment changes)
*/ */
export function clearAdapterCache(): void { export function clearAdapterCache(): void {
cachedAdapter = null; cachedAdapter = null;
} }
/** /**
* Set a specific adapter (useful for testing or forced selection) * Set a specific adapter (useful for testing or forced selection)
*/ */
export function setAdapter(adapter: TerminalAdapter): void { export function setAdapter(adapter: TerminalAdapter): void {
cachedAdapter = adapter; cachedAdapter = adapter;
} }
/** /**
* Check if any terminal adapter is available. * Check if any terminal adapter is available.
* *
* @returns true if a terminal adapter was detected * @returns true if a terminal adapter was detected
*/ */
export function hasTerminalAdapter(): boolean { export function hasTerminalAdapter(): boolean {
return getTerminalAdapter() !== null; return getTerminalAdapter() !== null;
} }
/** /**
* Check if the current terminal supports spawning separate OS windows. * Check if the current terminal supports spawning separate OS windows.
* *
* @returns true if the detected terminal supports windows (iTerm2, WezTerm) * @returns true if the detected terminal supports windows (iTerm2, WezTerm)
*/ */
export function supportsWindows(): boolean { export function supportsWindows(): boolean {
const adapter = getTerminalAdapter(); const adapter = getTerminalAdapter();
return adapter?.supportsWindows() ?? false; return adapter?.supportsWindows() ?? false;
} }
/** /**
* Get the name of the currently detected terminal adapter. * Get the name of the currently detected terminal adapter.
* *
* @returns The adapter name, or null if none detected * @returns The adapter name, or null if none detected
*/ */
export function getTerminalName(): string | null { export function getTerminalName(): string | null {
return getTerminalAdapter()?.name ?? null; return getTerminalAdapter()?.name ?? null;
} }

View file

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

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

View file

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

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

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

@ -1,7 +1,7 @@
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@ -15,21 +15,21 @@ const execFileAsync = promisify(execFile);
* @returns true if the hook doesn't exist or executes successfully; false otherwise. * @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> { export async function runHook(teamName: string, hookName: string, payload: any): Promise<boolean> {
const hookPath = path.join(process.cwd(), ".pi", "team-hooks", `${hookName}.sh`); const hookPath = path.join(process.cwd(), ".pi", "team-hooks", `${hookName}.sh`);
if (!fs.existsSync(hookPath)) { if (!fs.existsSync(hookPath)) {
return true; return true;
} }
try { try {
const payloadStr = JSON.stringify(payload); const payloadStr = JSON.stringify(payload);
// Use execFile: More secure (no shell interpolation) and asynchronous // Use execFile: More secure (no shell interpolation) and asynchronous
await execFileAsync(hookPath, [payloadStr], { await execFileAsync(hookPath, [payloadStr], {
env: { ...process.env, PI_TEAM: teamName }, env: { ...process.env, PI_TEAM: teamName },
}); });
return true; return true;
} catch (error) { } catch (error) {
console.error(`Hook ${hookName} failed:`, error); console.error(`Hook ${hookName} failed:`, error);
return false; return false;
} }
} }

View file

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

View file

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

View file

@ -6,43 +6,43 @@ const LOCK_TIMEOUT = 5000; // 5 seconds of retrying
const STALE_LOCK_TIMEOUT = 30000; // 30 seconds for a lock to be considered stale 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> { export async function withLock<T>(lockPath: string, fn: () => Promise<T>, retries: number = 50): Promise<T> {
const lockFile = `${lockPath}.lock`; 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 (e) {
// ignore, another process might have already removed it
}
}
}
fs.writeFileSync(lockFile, process.pid.toString(), { flag: "wx" });
break;
} catch (e) {
retries--;
await new Promise(resolve => setTimeout(resolve, 100));
}
}
if (retries === 0) { while (retries > 0) {
throw new Error("Could not acquire lock"); 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 (e) {
// ignore, another process might have already removed it
}
}
}
try { fs.writeFileSync(lockFile, process.pid.toString(), { flag: "wx" });
return await fn(); break;
} finally { } catch (e) {
try { retries--;
fs.unlinkSync(lockFile); await new Promise((resolve) => setTimeout(resolve, 100));
} catch (e) { }
// ignore }
}
} if (retries === 0) {
throw new Error("Could not acquire lock");
}
try {
return await fn();
} finally {
try {
fs.unlinkSync(lockFile);
} catch (e) {
// ignore
}
}
} }

View file

@ -1,104 +1,100 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path";
import os from "node:os"; import os from "node:os";
import { appendMessage, readInbox, sendPlainMessage, broadcastMessage } from "./messaging"; 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"; import * as paths from "./paths";
// Mock the paths to use a temporary directory // Mock the paths to use a temporary directory
const testDir = path.join(os.tmpdir(), "pi-teams-test-" + Date.now()); const testDir = path.join(os.tmpdir(), "pi-teams-test-" + Date.now());
describe("Messaging Utilities", () => { describe("Messaging Utilities", () => {
beforeEach(() => { beforeEach(() => {
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true }); if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
fs.mkdirSync(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(() => { // Override paths to use testDir
vi.restoreAllMocks(); vi.spyOn(paths, "inboxPath").mockImplementation((teamName, agentName) => {
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true }); 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");
});
});
it("should append a message successfully", async () => { afterEach(() => {
const msg = { from: "sender", text: "hello", timestamp: "now", read: false }; vi.restoreAllMocks();
await appendMessage("test-team", "receiver", msg); if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
});
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 () => { it("should append a message successfully", async () => {
const numMessages = 100; const msg = { from: "sender", text: "hello", timestamp: "now", read: false };
const promises = []; await appendMessage("test-team", "receiver", msg);
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 () => { const inbox = await readInbox("test-team", "receiver", false, false);
await sendPlainMessage("test-team", "sender", "receiver", "msg1", "summary1"); expect(inbox.length).toBe(1);
await sendPlainMessage("test-team", "sender", "receiver", "msg2", "summary2"); expect(inbox[0].text).toBe("hello");
});
// 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 () => { it("should handle concurrent appends (Stress Test)", async () => {
// Setup team config const numMessages = 100;
const config = { const promises = [];
name: "test-team", for (let i = 0; i < numMessages; i++) {
members: [ promises.push(sendPlainMessage("test-team", `sender-${i}`, "receiver", `msg-${i}`, `summary-${i}`));
{ 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 await Promise.all(promises);
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 inbox = await readInbox("test-team", "receiver", false, false);
const inbox2 = await readInbox("test-team", "member2", false, false); expect(inbox.length).toBe(numMessages);
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) // Verify all messages are present
const inboxSender = await readInbox("test-team", "sender", false, false); const texts = inbox.map((m) => m.text).sort();
expect(inboxSender.length).toBe(0); 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

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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