diff --git a/install.sh b/install.sh index 93db194e..e9228473 100755 --- a/install.sh +++ b/install.sh @@ -32,7 +32,7 @@ fi if [[ "${CO_MONO_SKIP_BUILD:-0}" != "1" ]]; then log "Building core packages" 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 BUILD_FAILED=1 echo "WARN: build failed for $pkg; falling back to source launch mode." @@ -43,7 +43,7 @@ else fi 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 LAUNCHER="$ROOT_DIR/co-mono" diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 43240821..9829d34f 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -1,4 +1,4 @@ -import { spawn, spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; @@ -636,7 +636,6 @@ export class DefaultPackageManager implements PackageManager { private cwd: string; private agentDir: string; private settingsManager: SettingsManager; - private globalNpmRoot: string | undefined; private progressCallback: ProgressCallback | undefined; constructor(options: PackageManagerOptions) { @@ -1157,20 +1156,12 @@ export class DefaultPackageManager implements PackageManager { } private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise { - if (scope === "user" && !temporary) { - await this.runCommand("npm", ["install", "-g", source.spec]); - return; - } const installRoot = this.getNpmInstallRoot(scope, temporary); this.ensureNpmProject(installRoot); await this.runCommand("npm", ["install", source.spec, "--prefix", installRoot]); } private async uninstallNpm(source: NpmSource, scope: SourceScope): Promise { - if (scope === "user") { - await this.runCommand("npm", ["uninstall", "-g", source.name]); - return; - } const installRoot = this.getNpmInstallRoot(scope, false); if (!existsSync(installRoot)) { return; @@ -1297,16 +1288,7 @@ export class DefaultPackageManager implements PackageManager { if (scope === "project") { return join(this.cwd, CONFIG_DIR_NAME, "npm"); } - return join(this.getGlobalNpmRoot(), ".."); - } - - private getGlobalNpmRoot(): string { - if (this.globalNpmRoot) { - return this.globalNpmRoot; - } - const result = this.runCommandSync("npm", ["root", "-g"]); - this.globalNpmRoot = result.trim(); - return this.globalNpmRoot; + return join(this.agentDir, "npm"); } private getNpmInstallPath(source: NpmSource, scope: SourceScope): string { @@ -1316,7 +1298,7 @@ export class DefaultPackageManager implements PackageManager { if (scope === "project") { 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 { @@ -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(); - } } diff --git a/packages/coding-agent/src/modes/daemon-mode.ts b/packages/coding-agent/src/modes/daemon-mode.ts index fc1707e4..d3790a45 100644 --- a/packages/coding-agent/src/modes/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon-mode.ts @@ -138,6 +138,12 @@ export async function runDaemonMode(session: AgentSession, options: DaemonModeOp console.error(`[co-mono-daemon] startup complete (session=${session.sessionId ?? "unknown"})`); // Keep process alive forever. + const keepAlive = setInterval(() => { + // Intentionally keep the daemon event loop active. + }, 1000); + ready.finally(() => { + clearInterval(keepAlive); + }); await ready; process.exit(0); } diff --git a/packages/pi-channels/src/adapters/slack.ts b/packages/pi-channels/src/adapters/slack.ts index a11220f2..d58c5411 100644 --- a/packages/pi-channels/src/adapters/slack.ts +++ b/packages/pi-channels/src/adapters/slack.ts @@ -39,13 +39,8 @@ import { SocketModeClient } from "@slack/socket-mode"; import { WebClient } from "@slack/web-api"; -import type { - ChannelAdapter, - ChannelMessage, - AdapterConfig, - OnIncomingMessage, -} from "../types.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 @@ -88,17 +83,17 @@ export type SlackAdapterLogger = (event: string, data: Record, export function createSlackAdapter(config: AdapterConfig, cwd?: string, log?: SlackAdapterLogger): ChannelAdapter { // Tokens live in settings under pi-channels.slack (not in the adapter config block) - const appToken = (cwd ? getChannelSetting(cwd, "slack.appToken") as string : null) - ?? config.appToken as string; - const botToken = (cwd ? getChannelSetting(cwd, "slack.botToken") as string : null) - ?? config.botToken as string; + const appToken = (cwd ? (getChannelSetting(cwd, "slack.appToken") as string) : null) ?? (config.appToken as string); + const botToken = (cwd ? (getChannelSetting(cwd, "slack.botToken") as string) : null) ?? (config.botToken as string); const allowedChannelIds = config.allowedChannelIds as string[] | undefined; const respondToMentionsOnly = config.respondToMentionsOnly === true; 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 (!botToken) throw new Error("Slack adapter requires botToken (xoxb-...) in settings under pi-channels.slack.botToken"); + if (!appToken) + 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; 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 */ - function buildMetadata(event: { channel?: string; user?: string; ts?: string; thread_ts?: string; channel_type?: string }, extra?: Record): Record { + function buildMetadata( + event: { channel?: string; user?: string; ts?: string; thread_ts?: string; channel_type?: string }, + extra?: Record, + ): Record { return { channelId: event.channel, userId: event.user, @@ -184,7 +182,7 @@ export function createSlackAdapter(config: AdapterConfig, cwd?: string, log?: Sl // Resolve bot user ID (for stripping self-mentions) try { const authResult = await webClient.auth.test(); - botUserId = authResult.user_id as string ?? null; + botUserId = (authResult.user_id as string) ?? null; } catch { // 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. // DMs (im) and multi-party DMs (mpim) don't fire app_mention, so we // 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 // (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 - const sender = event.thread_ts - ? `${event.channel}:${event.thread_ts}` - : event.channel; + const sender = event.thread_ts ? `${event.channel}:${event.thread_ts}` : event.channel; onMessage({ adapter: "slack", @@ -234,74 +236,86 @@ export function createSlackAdapter(config: AdapterConfig, cwd?: string, log?: Sl 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 ────────────────────────── - socketClient.on("app_mention", async ({ event, ack }: { event: SlackMentionEvent; ack: () => Promise }) => { - try { - await ack(); + socketClient.on( + "app_mention", + async ({ event, ack }: { event: SlackMentionEvent; ack: () => Promise }) => { + try { + await ack(); - if (!isAllowed(event.channel)) return; + if (!isAllowed(event.channel)) return; - const sender = event.thread_ts - ? `${event.channel}:${event.thread_ts}` - : event.channel; + const sender = event.thread_ts ? `${event.channel}:${event.thread_ts}` : event.channel; - onMessage({ - adapter: "slack", - sender, - text: stripBotMention(event.text), - metadata: buildMetadata(event, { - eventType: "app_mention", - }), - }); - } catch (err) { log?.("slack-handler-error", { handler: "app_mention", error: String(err) }, "ERROR"); } - }); + onMessage({ + adapter: "slack", + sender, + text: stripBotMention(event.text), + metadata: buildMetadata(event, { + eventType: "app_mention", + }), + }); + } catch (err) { + log?.("slack-handler-error", { handler: "app_mention", error: String(err) }, "ERROR"); + } + }, + ); // ── Slash commands ─────────────────────────────── - socketClient.on("slash_commands", async ({ body, ack }: { body: SlackCommandPayload; ack: (response?: any) => Promise }) => { - try { - if (body.command !== slashCommand) { - await ack(); - return; + socketClient.on( + "slash_commands", + async ({ body, ack }: { body: SlackCommandPayload; ack: (response?: any) => Promise }) => { + try { + 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) ── socketClient.on("interactive", async ({ body, ack }: { body: any; ack: () => Promise }) => { try { await ack(); // 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(); diff --git a/packages/pi-channels/src/adapters/telegram.ts b/packages/pi-channels/src/adapters/telegram.ts index 15ced334..534cb54e 100644 --- a/packages/pi-channels/src/adapters/telegram.ts +++ b/packages/pi-channels/src/adapters/telegram.ts @@ -26,15 +26,15 @@ */ import * as fs from "node:fs"; -import * as path from "node:path"; import * as os from "node:os"; +import * as path from "node:path"; import type { + AdapterConfig, ChannelAdapter, ChannelMessage, - AdapterConfig, - OnIncomingMessage, - IncomingMessage, IncomingAttachment, + IncomingMessage, + OnIncomingMessage, TranscriptionConfig, } from "../types.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). */ const TEXT_EXTENSIONS = new Set([ - ".md", ".markdown", ".txt", ".csv", ".json", ".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", + ".md", + ".markdown", + ".txt", + ".csv", + ".json", + ".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. */ @@ -80,15 +117,22 @@ function isImageMime(mime: string | undefined): boolean { /** Audio MIME types that can be transcribed. */ const AUDIO_MIME_PREFIXES = ["audio/"]; const AUDIO_MIME_TYPES = new Set([ - "audio/mpeg", "audio/mp4", "audio/ogg", "audio/wav", "audio/webm", - "audio/x-m4a", "audio/flac", "audio/aac", "audio/mp3", + "audio/mpeg", + "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 ]); function isAudioMime(mime: string | undefined): boolean { if (!mime) return false; 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 { @@ -166,13 +210,17 @@ export function createTelegramAdapter(config: AdapterConfig): ChannelAdapter { * Download a file from Telegram by file_id. * 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 { // Get file info const infoRes = await fetch(`${apiBase}/getFile?file_id=${fileId}`); if (!infoRes.ok) return null; - const info = await infoRes.json() as { + const info = (await infoRes.json()) as { ok: boolean; result?: { file_id: string; file_size?: number; file_path?: string }; }; @@ -237,7 +285,7 @@ export function createTelegramAdapter(config: AdapterConfig): ChannelAdapter { continue; } - const data = await res.json() as { + const data = (await res.json()) as { ok: boolean; result: Array<{ update_id: number; message?: TelegramMessage }>; }; @@ -574,7 +622,11 @@ export function createTelegramAdapter(config: AdapterConfig): ChannelAdapter { function cleanupTempFiles(): void { for (const f of tempFiles) { - try { fs.unlinkSync(f); } catch { /* ignore */ } + try { + fs.unlinkSync(f); + } catch { + /* ignore */ + } } tempFiles.length = 0; } @@ -663,7 +715,7 @@ interface TelegramMessage { } function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } function formatSize(bytes: number): string { diff --git a/packages/pi-channels/src/adapters/transcription.ts b/packages/pi-channels/src/adapters/transcription.ts index b00a2b4a..50e6ec3c 100644 --- a/packages/pi-channels/src/adapters/transcription.ts +++ b/packages/pi-channels/src/adapters/transcription.ts @@ -11,9 +11,9 @@ * const result = await provider.transcribe("/path/to/audio.ogg", "en"); */ +import { execFile } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; -import { execFile } from "node:child_process"; import type { TranscriptionConfig } from "../types.ts"; // ── Public interface ──────────────────────────────────────────── @@ -187,7 +187,7 @@ class OpenAIProvider implements TranscriptionProvider { 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) { 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)}` }; } - const data = await response.json() as { text?: string }; + const data = (await response.json()) as { text?: string }; if (!data.text) { return { ok: false, error: "ElevenLabs returned empty transcription" }; } diff --git a/packages/pi-channels/src/adapters/webhook.ts b/packages/pi-channels/src/adapters/webhook.ts index 38a6112c..cfffa80f 100644 --- a/packages/pi-channels/src/adapters/webhook.ts +++ b/packages/pi-channels/src/adapters/webhook.ts @@ -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 { const method = (config.method as string) ?? "POST"; diff --git a/packages/pi-channels/src/bridge/bridge.ts b/packages/pi-channels/src/bridge/bridge.ts index 0d10b4bb..0d86a8ca 100644 --- a/packages/pi-channels/src/bridge/bridge.ts +++ b/packages/pi-channels/src/bridge/bridge.ts @@ -7,18 +7,12 @@ * 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 { 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 { isCommand, handleCommand, type CommandContext } from "./commands.ts"; +import { runPrompt } from "./runner.ts"; import { startTyping } from "./typing.ts"; const BRIDGE_DEFAULTS: Required = { @@ -144,7 +138,7 @@ export class ChatBridge { message.adapter, message.sender, `⚠️ 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; } @@ -163,7 +157,9 @@ export class ChatBridge { session.messageCount++; 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, }); @@ -183,9 +179,7 @@ export class ChatBridge { // Typing indicator const adapter = this.registry.getAdapter(prompt.adapter); - const typing = this.config.typingIndicators - ? startTyping(adapter, prompt.sender) - : { stop() {} }; + const typing = this.config.typingIndicators ? startTyping(adapter, prompt.sender) : { stop() {} }; const ac = new AbortController(); session.abortController = ac; @@ -193,7 +187,9 @@ export class ChatBridge { const usePersistent = this.shouldUsePersistent(senderKey); 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), persistent: usePersistent, }); @@ -225,22 +221,28 @@ export class ChatBridge { this.sendReply(prompt.adapter, prompt.sender, "⏹ Aborted."); } else { const userError = sanitizeError(result.error); - this.sendReply( - prompt.adapter, prompt.sender, - result.response || `❌ ${userError}`, - ); + this.sendReply(prompt.adapter, prompt.sender, result.response || `❌ ${userError}`); } this.events.emit("bridge:complete", { - id: prompt.id, adapter: prompt.adapter, sender: prompt.sender, - ok: result.ok, durationMs: result.durationMs, + id: prompt.id, + adapter: prompt.adapter, + sender: prompt.sender, + ok: result.ok, + durationMs: result.durationMs, persistent: usePersistent, }); - this.log("bridge-complete", { - id: prompt.id, adapter: prompt.adapter, ok: result.ok, - durationMs: result.durationMs, persistent: usePersistent, - }, result.ok ? "INFO" : "WARN"); - + this.log( + "bridge-complete", + { + id: prompt.id, + adapter: prompt.adapter, + ok: result.ok, + durationMs: result.durationMs, + persistent: usePersistent, + }, + result.ok ? "INFO" : "WARN", + ); } catch (err: any) { typing.stop(); this.log("bridge-error", { adapter: prompt.adapter, sender: prompt.sender, error: err.message }, "ERROR"); @@ -296,9 +298,7 @@ export class ChatBridge { adapter: message.adapter, sender: message.sender, displayName: - (message.metadata?.firstName as string) || - (message.metadata?.username as string) || - message.sender, + (message.metadata?.firstName as string) || (message.metadata?.username as string) || message.sender, queue: [], processing: false, abortController: null, @@ -413,21 +413,20 @@ function sanitizeError(error: string | undefined): string { if (!error) return "Something went wrong. Please try again."; // 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 - const meaningful = lines.find(l => - !l.startsWith("Extension error") && - !l.startsWith(" at ") && - !l.startsWith("node:") && - !l.includes("NODE_MODULE_VERSION") && - !l.includes("compiled against a different") && - !l.includes("Emitted 'error' event") + const meaningful = lines.find( + (l) => + !l.startsWith("Extension error") && + !l.startsWith(" at ") && + !l.startsWith("node:") && + !l.includes("NODE_MODULE_VERSION") && + !l.includes("compiled against a different") && + !l.includes("Emitted 'error' event"), ); const msg = meaningful?.trim() || "Something went wrong. Please try again."; - return msg.length > MAX_ERROR_LENGTH - ? msg.slice(0, MAX_ERROR_LENGTH) + "…" - : msg; + return msg.length > MAX_ERROR_LENGTH ? msg.slice(0, MAX_ERROR_LENGTH) + "…" : msg; } diff --git a/packages/pi-channels/src/bridge/commands.ts b/packages/pi-channels/src/bridge/commands.ts index 4c748f22..6f5e882e 100644 --- a/packages/pi-channels/src/bridge/commands.ts +++ b/packages/pi-channels/src/bridge/commands.ts @@ -51,11 +51,7 @@ export function getAllCommands(): BotCommand[] { * Handle a command. Returns reply text, or null if unrecognized * (fall through to agent). */ -export function handleCommand( - text: string, - session: SenderSession | undefined, - ctx: CommandContext, -): string | null { +export function handleCommand(text: string, session: SenderSession | undefined, ctx: CommandContext): string | null { const { command } = parseCommand(text); if (!command) return null; const cmd = commands.get(command); @@ -89,9 +85,7 @@ registerCommand({ handler: (_args, session, ctx) => { if (!session) return "No active session."; if (!session.processing) return "Nothing is running right now."; - return ctx.abortCurrent(session.sender) - ? "⏹ Aborting current prompt..." - : "Failed to abort — nothing running."; + return ctx.abortCurrent(session.sender) ? "⏹ Aborting current prompt..." : "Failed to abort — nothing running."; }, }); diff --git a/packages/pi-channels/src/bridge/rpc-runner.ts b/packages/pi-channels/src/bridge/rpc-runner.ts index f341d0d3..c9047a81 100644 --- a/packages/pi-channels/src/bridge/rpc-runner.ts +++ b/packages/pi-channels/src/bridge/rpc-runner.ts @@ -12,9 +12,9 @@ * 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 type { RunResult, IncomingAttachment } from "../types.ts"; +import type { IncomingAttachment, RunResult } from "../types.ts"; export interface RpcRunnerOptions { cwd: string; @@ -176,8 +176,7 @@ export class RpcSession { return; } options.signal.addEventListener("abort", onAbort, { once: true }); - this.pending.abortHandler = () => - options.signal?.removeEventListener("abort", onAbort); + this.pending.abortHandler = () => options.signal?.removeEventListener("abort", onAbort); } // Build prompt command diff --git a/packages/pi-channels/src/bridge/runner.ts b/packages/pi-channels/src/bridge/runner.ts index a9a54c01..828b510b 100644 --- a/packages/pi-channels/src/bridge/runner.ts +++ b/packages/pi-channels/src/bridge/runner.ts @@ -6,8 +6,8 @@ * Same pattern as pi-cron and pi-heartbeat. */ -import { spawn, type ChildProcess } from "node:child_process"; -import type { RunResult, IncomingAttachment } from "../types.ts"; +import { type ChildProcess, spawn } from "node:child_process"; +import type { IncomingAttachment, RunResult } from "../types.ts"; export interface RunOptions { prompt: string; @@ -56,25 +56,37 @@ export function runPrompt(options: RunOptions): Promise { }); } catch (err: any) { resolve({ - ok: false, response: "", error: `Failed to spawn: ${err.message}`, - durationMs: Date.now() - startTime, exitCode: 1, + ok: false, + response: "", + error: `Failed to spawn: ${err.message}`, + durationMs: Date.now() - startTime, + exitCode: 1, }); return; } let stdout = ""; let stderr = ""; - child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString(); }); - child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString(); }); + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); const onAbort = () => { child.kill("SIGTERM"); - setTimeout(() => { if (!child.killed) child.kill("SIGKILL"); }, 3000); + setTimeout(() => { + if (!child.killed) child.kill("SIGKILL"); + }, 3000); }; if (signal) { - if (signal.aborted) { onAbort(); } - else { signal.addEventListener("abort", onAbort, { once: true }); } + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } } child.on("close", (code) => { @@ -84,7 +96,13 @@ export function runPrompt(options: RunOptions): Promise { const exitCode = code ?? 1; 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) { resolve({ ok: false, response, error: stderr.trim() || `Exit code ${exitCode}`, durationMs, exitCode }); } else { diff --git a/packages/pi-channels/src/bridge/typing.ts b/packages/pi-channels/src/bridge/typing.ts index 21769ba8..ba880919 100644 --- a/packages/pi-channels/src/bridge/typing.ts +++ b/packages/pi-channels/src/bridge/typing.ts @@ -14,10 +14,7 @@ const TYPING_INTERVAL_MS = 4_000; * Start sending typing indicators. Returns a stop() handle. * No-op if the adapter doesn't support sendTyping. */ -export function startTyping( - adapter: ChannelAdapter | undefined, - recipient: string, -): { stop: () => void } { +export function startTyping(adapter: ChannelAdapter | undefined, recipient: string): { stop: () => void } { if (!adapter?.sendTyping) return { stop() {} }; // Fire immediately diff --git a/packages/pi-channels/src/events.ts b/packages/pi-channels/src/events.ts index b9e5902f..8b9cb683 100644 --- a/packages/pi-channels/src/events.ts +++ b/packages/pi-channels/src/events.ts @@ -15,9 +15,9 @@ */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import type { ChatBridge } from "./bridge/bridge.ts"; import type { ChannelRegistry } from "./registry.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. */ let activeBridge: ChatBridge | null = null; @@ -27,7 +27,6 @@ export function setBridge(bridge: ChatBridge | null): void { } export function registerChannelEvents(pi: ExtensionAPI, registry: ChannelRegistry): void { - // ── Incoming messages → channel:receive (+ bridge) ────── registry.setOnIncoming((message: IncomingMessage) => { @@ -53,9 +52,7 @@ export function registerChannelEvents(pi: ExtensionAPI, registry: ChannelRegistr if (!event.job.channel) return; if (!event.response && !event.error) return; - const text = event.ok - ? event.response ?? "(no output)" - : `❌ Error: ${event.error ?? "unknown"}`; + const text = event.ok ? (event.response ?? "(no output)") : `❌ Error: ${event.error ?? "unknown"}`; registry.send({ adapter: event.job.channel, @@ -70,7 +67,7 @@ export function registerChannelEvents(pi: ExtensionAPI, registry: ChannelRegistr pi.events.on("channel:send", (raw: unknown) => { 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 ────────────── @@ -102,12 +99,18 @@ export function registerChannelEvents(pi: ExtensionAPI, registry: ChannelRegistr // ── channel:test — send a test ping ────────────────────── pi.events.on("channel:test", (raw: unknown) => { - const data = raw as { adapter: string; recipient: string; callback?: (result: { ok: boolean; error?: string }) => void }; - registry.send({ - adapter: data.adapter, - recipient: data.recipient ?? "", - text: `🏓 pi-channels test — ${new Date().toISOString()}`, - source: "channel:test", - }).then(r => data.callback?.(r)); + const data = raw as { + adapter: string; + recipient: string; + callback?: (result: { ok: boolean; error?: string }) => void; + }; + registry + .send({ + adapter: data.adapter, + recipient: data.recipient ?? "", + text: `🏓 pi-channels test — ${new Date().toISOString()}`, + source: "channel:test", + }) + .then((r) => data.callback?.(r)); }); } diff --git a/packages/pi-channels/src/index.ts b/packages/pi-channels/src/index.ts index cb053241..f9661571 100644 --- a/packages/pi-channels/src/index.ts +++ b/packages/pi-channels/src/index.ts @@ -35,12 +35,12 @@ */ 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 { loadConfig } from "./config.ts"; +import { registerChannelEvents, setBridge } from "./events.ts"; import { createLogger } from "./logger.ts"; +import { ChannelRegistry } from "./registry.ts"; +import { registerChannelTool } from "./tool.ts"; export default function (pi: ExtensionAPI) { const log = createLogger(pi); @@ -76,7 +76,7 @@ export default function (pi: ExtensionAPI) { // Start incoming/bidirectional adapters 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) { ctx.ui.notify(`pi-channels: ${err.adapter}: ${err.error}`, "warning"); } @@ -105,9 +105,7 @@ export default function (pi: ExtensionAPI) { pi.registerCommand("chat-bridge", { description: "Manage chat bridge: /chat-bridge [on|off|status]", getArgumentCompletions: (prefix: string) => { - return ["on", "off", "status"] - .filter(c => c.startsWith(prefix)) - .map(c => ({ value: c, label: c })); + return ["on", "off", "status"].filter((c) => c.startsWith(prefix)).map((c) => ({ value: c, label: c })); }, handler: async (args, ctx) => { const cmd = args?.trim().toLowerCase(); diff --git a/packages/pi-channels/src/registry.ts b/packages/pi-channels/src/registry.ts index d7b518cc..ca8c27d8 100644 --- a/packages/pi-channels/src/registry.ts +++ b/packages/pi-channels/src/registry.ts @@ -2,10 +2,18 @@ * 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 { 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 ────────────────────────────────── @@ -161,7 +169,8 @@ export class ChannelRegistry { /** List all registered adapters and route aliases. */ 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) { result.push({ name, type: "adapter", direction: adapter.direction }); } diff --git a/packages/pi-channels/src/tool.ts b/packages/pi-channels/src/tool.ts index c0cabe0b..8f5d85e7 100644 --- a/packages/pi-channels/src/tool.ts +++ b/packages/pi-channels/src/tool.ts @@ -2,9 +2,9 @@ * pi-channels — LLM tool registration. */ +import { StringEnum } from "@mariozechner/pi-ai"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; -import { StringEnum } from "@mariozechner/pi-ai"; import type { ChannelRegistry } from "./registry.ts"; interface ChannelToolParams { @@ -23,22 +23,15 @@ export function registerChannelTool(pi: ExtensionAPI, registry: ChannelRegistry) "Send notifications via configured adapters (Telegram, webhooks, custom). " + "Actions: send (deliver a message), list (show adapters + routes), test (send a ping).", parameters: Type.Object({ - action: StringEnum( - ["send", "list", "test"] as const, - { description: "Action to perform" }, - ) as any, - adapter: Type.Optional( - Type.String({ description: "Adapter name or route alias (required for send, test)" }), - ), + action: StringEnum(["send", "list", "test"] as const, { description: "Action to perform" }) as any, + adapter: Type.Optional(Type.String({ description: "Adapter name or route alias (required for send, test)" })), recipient: Type.Optional( - 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)" }), + 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)" })), }) as any, async execute(_toolCallId, _params) { @@ -51,10 +44,10 @@ export function registerChannelTool(pi: ExtensionAPI, registry: ChannelRegistry) if (items.length === 0) { result = 'No adapters configured. Add "pi-channels" to your settings.json.'; } else { - const lines = items.map(i => + const lines = items.map((i) => i.type === "route" ? `- **${i.name}** (route → ${i.target})` - : `- **${i.name}** (${i.direction ?? "adapter"})` + : `- **${i.name}** (${i.direction ?? "adapter"})`, ); result = `**Channel (${items.length}):**\n${lines.join("\n")}`; } diff --git a/packages/pi-teams/src/adapters/cmux-adapter.ts b/packages/pi-teams/src/adapters/cmux-adapter.ts index 398e4ff9..4c2bc486 100644 --- a/packages/pi-teams/src/adapters/cmux-adapter.ts +++ b/packages/pi-teams/src/adapters/cmux-adapter.ts @@ -1,191 +1,191 @@ /** * CMUX Terminal Adapter - * + * * 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 { - readonly name = "cmux"; + readonly name = "cmux"; - detect(): boolean { - // Check for CMUX specific environment variables - return !!process.env.CMUX_SOCKET_PATH || !!process.env.CMUX_WORKSPACE_ID; - } + detect(): boolean { + // Check for CMUX specific environment variables + return !!process.env.CMUX_SOCKET_PATH || !!process.env.CMUX_WORKSPACE_ID; + } - spawn(options: SpawnOptions): string { - // We use new-split to create a new pane in CMUX. - // CMUX doesn't have a direct 'spawn' that returns a pane ID and runs a command - // in one go while also returning the ID in a way we can easily capture for 'isAlive'. - // However, 'new-split' returns the new surface ID. - - // Construct the command with environment variables - const envPrefix = Object.entries(options.env) - .filter(([k]) => k.startsWith("PI_")) - .map(([k, v]) => `${k}=${v}`) - .join(" "); - - const fullCommand = envPrefix ? `env ${envPrefix} ${options.command}` : options.command; + spawn(options: SpawnOptions): string { + // We use new-split to create a new pane in CMUX. + // CMUX doesn't have a direct 'spawn' that returns a pane ID and runs a command + // in one go while also returning the ID in a way we can easily capture for 'isAlive'. + // However, 'new-split' returns the new surface ID. - // CMUX new-split returns "OK " - const splitResult = execCommand("cmux", ["new-split", "right", "--command", fullCommand]); - - if (splitResult.status !== 0) { - throw new Error(`cmux new-split failed with status ${splitResult.status}: ${splitResult.stderr}`); - } + // Construct the command with environment variables + const envPrefix = Object.entries(options.env) + .filter(([k]) => k.startsWith("PI_")) + .map(([k, v]) => `${k}=${v}`) + .join(" "); - const output = splitResult.stdout.trim(); - if (output.startsWith("OK ")) { - const surfaceId = output.substring(3).trim(); - return surfaceId; - } + const fullCommand = envPrefix ? `env ${envPrefix} ${options.command}` : options.command; - throw new Error(`cmux new-split returned unexpected output: ${output}`); - } + // CMUX new-split returns "OK " + const splitResult = execCommand("cmux", ["new-split", "right", "--command", fullCommand]); - kill(paneId: string): void { - if (!paneId) return; - - try { - // CMUX calls them surfaces - execCommand("cmux", ["close-surface", "--surface", paneId]); - } catch { - // Ignore errors during kill - } - } + if (splitResult.status !== 0) { + throw new Error(`cmux new-split failed with status ${splitResult.status}: ${splitResult.stderr}`); + } - isAlive(paneId: string): boolean { - if (!paneId) return false; + const output = splitResult.stdout.trim(); + if (output.startsWith("OK ")) { + const surfaceId = output.substring(3).trim(); + return surfaceId; + } - try { - // We can use list-pane-surfaces and grep for the ID - // Or just 'identify' if we want to be precise, but list-pane-surfaces is safer - const result = execCommand("cmux", ["list-pane-surfaces"]); - return result.stdout.includes(paneId); - } catch { - return false; - } - } + throw new Error(`cmux new-split returned unexpected output: ${output}`); + } - setTitle(title: string): void { - try { - // rename-tab or rename-workspace? - // Usually agents want to rename their current "tab" or "surface" - execCommand("cmux", ["rename-tab", title]); - } catch { - // Ignore errors - } - } + kill(paneId: string): void { + if (!paneId) return; - /** - * CMUX supports spawning separate OS windows - */ - supportsWindows(): boolean { - return true; - } + try { + // CMUX calls them surfaces + execCommand("cmux", ["close-surface", "--surface", paneId]); + } catch { + // Ignore errors during kill + } + } - /** - * Spawn a new separate OS window. - */ - spawnWindow(options: SpawnOptions): string { - // CMUX new-window returns "OK " - const result = execCommand("cmux", ["new-window"]); - - if (result.status !== 0) { - throw new Error(`cmux new-window failed with status ${result.status}: ${result.stderr}`); - } + isAlive(paneId: string): boolean { + if (!paneId) return false; - const output = result.stdout.trim(); - if (output.startsWith("OK ")) { - const windowId = output.substring(3).trim(); - - // Now we need to run the command in this window. - // Usually new-window creates a default workspace/surface. - // We might need to find the workspace in that window. - - // For now, let's just use 'new-workspace' in that window if possible, - // but CMUX commands usually target the current window unless specified. - // Wait a bit for the window to be ready? - - const envPrefix = Object.entries(options.env) - .filter(([k]) => k.startsWith("PI_")) - .map(([k, v]) => `${k}=${v}`) - .join(" "); - - const fullCommand = envPrefix ? `env ${envPrefix} ${options.command}` : options.command; + try { + // We can use list-pane-surfaces and grep for the ID + // Or just 'identify' if we want to be precise, but list-pane-surfaces is safer + const result = execCommand("cmux", ["list-pane-surfaces"]); + return result.stdout.includes(paneId); + } catch { + return false; + } + } - // Target the new window - execCommand("cmux", ["new-workspace", "--window", windowId, "--command", fullCommand]); + setTitle(title: string): void { + try { + // rename-tab or rename-workspace? + // Usually agents want to rename their current "tab" or "surface" + execCommand("cmux", ["rename-tab", title]); + } catch { + // Ignore errors + } + } - 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 " + 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}`); + } - /** - * Set the title of a specific window. - */ - setWindowTitle(windowId: string, title: string): void { - try { - execCommand("cmux", ["rename-window", "--window", windowId, title]); - } catch { - // Ignore - } - } + const output = result.stdout.trim(); + if (output.startsWith("OK ")) { + const windowId = output.substring(3).trim(); - /** - * Kill/terminate a window. - */ - killWindow(windowId: string): void { - if (!windowId) return; - try { - execCommand("cmux", ["close-window", "--window", windowId]); - } catch { - // Ignore - } - } + // Now we need to run the command in this window. + // Usually new-window creates a default workspace/surface. + // We might need to find the workspace in that window. - /** - * 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; - } - } + // 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? - /** - * 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}`); - } + const envPrefix = Object.entries(options.env) + .filter(([k]) => k.startsWith("PI_")) + .map(([k, v]) => `${k}=${v}`) + .join(" "); + + const fullCommand = envPrefix ? `env ${envPrefix} ${options.command}` : options.command; + + // Target the new window + execCommand("cmux", ["new-workspace", "--window", windowId, "--command", fullCommand]); + + if (options.teamName) { + this.setWindowTitle(windowId, options.teamName); + } + + return windowId; + } + + throw new Error(`cmux new-window returned unexpected output: ${output}`); + } + + /** + * Set the title of a specific window. + */ + setWindowTitle(windowId: string, title: string): void { + try { + execCommand("cmux", ["rename-window", "--window", windowId, title]); + } catch { + // Ignore + } + } + + /** + * Kill/terminate a window. + */ + killWindow(windowId: string): void { + if (!windowId) return; + try { + execCommand("cmux", ["close-window", "--window", windowId]); + } catch { + // Ignore + } + } + + /** + * Check if a window is still alive. + */ + isWindowAlive(windowId: string): boolean { + if (!windowId) return false; + try { + const result = execCommand("cmux", ["list-windows"]); + return result.stdout.includes(windowId); + } catch { + return false; + } + } + + /** + * Custom CMUX capability: create a workspace for a problem. + * This isn't part of the TerminalAdapter interface but can be used via the adapter. + */ + createProblemWorkspace(title: string, command?: string): string { + const args = ["new-workspace"]; + if (command) { + args.push("--command", command); + } + + const result = execCommand("cmux", args); + if (result.status !== 0) { + throw new Error(`cmux new-workspace failed: ${result.stderr}`); + } + + const output = result.stdout.trim(); + if (output.startsWith("OK ")) { + const workspaceId = output.substring(3).trim(); + execCommand("cmux", ["workspace-action", "--action", "rename", "--title", title, "--workspace", workspaceId]); + return workspaceId; + } + + throw new Error(`cmux new-workspace returned unexpected output: ${output}`); + } } diff --git a/packages/pi-teams/src/adapters/iterm2-adapter.ts b/packages/pi-teams/src/adapters/iterm2-adapter.ts index 6bfc1953..e1676fb4 100644 --- a/packages/pi-teams/src/adapters/iterm2-adapter.ts +++ b/packages/pi-teams/src/adapters/iterm2-adapter.ts @@ -5,53 +5,53 @@ * Uses AppleScript for all operations. */ -import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter"; 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) */ export interface Iterm2SpawnContext { - /** ID of the last spawned session, used for layout decisions */ - lastSessionId?: string; + /** ID of the last spawned session, used for layout decisions */ + lastSessionId?: string; } export class Iterm2Adapter implements TerminalAdapter { - readonly name = "iTerm2"; - private spawnContext: Iterm2SpawnContext = {}; + readonly name = "iTerm2"; + private spawnContext: Iterm2SpawnContext = {}; - detect(): boolean { - return process.env.TERM_PROGRAM === "iTerm.app" && !process.env.TMUX && !process.env.ZELLIJ; - } + detect(): boolean { + return process.env.TERM_PROGRAM === "iTerm.app" && !process.env.TMUX && !process.env.ZELLIJ; + } - /** - * Helper to execute AppleScript via stdin to avoid escaping issues with -e - */ - private runAppleScript(script: string): { stdout: string; stderr: string; status: number | null } { - const result = spawnSync("osascript", ["-"], { - input: script, - encoding: "utf-8", - }); - return { - stdout: result.stdout?.toString() ?? "", - stderr: result.stderr?.toString() ?? "", - status: result.status, - }; - } + /** + * Helper to execute AppleScript via stdin to avoid escaping issues with -e + */ + private runAppleScript(script: string): { stdout: string; stderr: string; status: number | null } { + const result = spawnSync("osascript", ["-"], { + input: script, + encoding: "utf-8", + }); + return { + stdout: result.stdout?.toString() ?? "", + stderr: result.stderr?.toString() ?? "", + status: result.status, + }; + } - spawn(options: SpawnOptions): string { - const envStr = Object.entries(options.env) - .filter(([k]) => k.startsWith("PI_")) - .map(([k, v]) => `${k}=${v}`) - .join(" "); + spawn(options: SpawnOptions): string { + const envStr = Object.entries(options.env) + .filter(([k]) => k.startsWith("PI_")) + .map(([k, v]) => `${k}=${v}`) + .join(" "); - const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`; - const escapedCmd = itermCmd.replace(/"/g, '\\"'); + const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`; + const escapedCmd = itermCmd.replace(/"/g, '\\"'); - let script: string; + let script: string; - if (!this.spawnContext.lastSessionId) { - script = `tell application "iTerm2" + if (!this.spawnContext.lastSessionId) { + script = `tell application "iTerm2" tell current session of current window set newSession to split vertically with default profile tell newSession @@ -60,8 +60,8 @@ export class Iterm2Adapter implements TerminalAdapter { end tell end tell end tell`; - } else { - script = `tell application "iTerm2" + } else { + script = `tell application "iTerm2" repeat with aWindow in windows repeat with aTab in tabs of aWindow repeat with aSession in sessions of aTab @@ -78,27 +78,27 @@ end tell`; end repeat end repeat end tell`; - } + } - const result = this.runAppleScript(script); + const result = this.runAppleScript(script); - if (result.status !== 0) { - throw new Error(`osascript failed with status ${result.status}: ${result.stderr}`); - } + if (result.status !== 0) { + throw new Error(`osascript failed with status ${result.status}: ${result.stderr}`); + } - const sessionId = result.stdout.toString().trim(); - this.spawnContext.lastSessionId = sessionId; + const sessionId = result.stdout.toString().trim(); + this.spawnContext.lastSessionId = sessionId; - return `iterm_${sessionId}`; - } + return `iterm_${sessionId}`; + } - kill(paneId: string): void { - if (!paneId || !paneId.startsWith("iterm_") || paneId.startsWith("iterm_win_")) { - return; - } + kill(paneId: string): void { + if (!paneId || !paneId.startsWith("iterm_") || paneId.startsWith("iterm_win_")) { + return; + } - const itermId = paneId.replace("iterm_", ""); - const script = `tell application "iTerm2" + const itermId = paneId.replace("iterm_", ""); + const script = `tell application "iTerm2" repeat with aWindow in windows repeat with aTab in tabs of aWindow repeat with aSession in sessions of aTab @@ -111,20 +111,20 @@ end tell`; end repeat end tell`; - try { - this.runAppleScript(script); - } catch { - // Ignore errors - } - } + try { + this.runAppleScript(script); + } catch { + // Ignore errors + } + } - isAlive(paneId: string): boolean { - if (!paneId || !paneId.startsWith("iterm_") || paneId.startsWith("iterm_win_")) { - return false; - } + isAlive(paneId: string): boolean { + if (!paneId || !paneId.startsWith("iterm_") || paneId.startsWith("iterm_win_")) { + return false; + } - const itermId = paneId.replace("iterm_", ""); - const script = `tell application "iTerm2" + const itermId = paneId.replace("iterm_", ""); + const script = `tell application "iTerm2" repeat with aWindow in windows repeat with aTab in tabs of aWindow repeat with aSession in sessions of aTab @@ -136,52 +136,50 @@ end tell`; end repeat end tell`; - try { - const result = this.runAppleScript(script); - return result.stdout.includes("Alive"); - } catch { - return false; - } - } + try { + const result = this.runAppleScript(script); + return result.stdout.includes("Alive"); + } catch { + return false; + } + } - setTitle(title: string): void { - const escapedTitle = title.replace(/"/g, '\\"'); - const script = `tell application "iTerm2" to tell current session of current window + setTitle(title: string): void { + const escapedTitle = title.replace(/"/g, '\\"'); + const script = `tell application "iTerm2" to tell current session of current window set name to "${escapedTitle}" end tell`; - try { - this.runAppleScript(script); - } catch { - // Ignore errors - } - } + try { + this.runAppleScript(script); + } catch { + // Ignore errors + } + } - /** - * iTerm2 supports spawning separate OS windows via AppleScript - */ - supportsWindows(): boolean { - return true; - } + /** + * iTerm2 supports spawning separate OS windows via AppleScript + */ + supportsWindows(): boolean { + return true; + } - /** - * Spawn a new separate OS window with the given options. - */ - spawnWindow(options: SpawnOptions): string { - const envStr = Object.entries(options.env) - .filter(([k]) => k.startsWith("PI_")) - .map(([k, v]) => `${k}=${v}`) - .join(" "); + /** + * Spawn a new separate OS window with the given options. + */ + spawnWindow(options: SpawnOptions): string { + const envStr = Object.entries(options.env) + .filter(([k]) => k.startsWith("PI_")) + .map(([k, v]) => `${k}=${v}`) + .join(" "); - const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`; - const escapedCmd = itermCmd.replace(/"/g, '\\"'); + const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`; + const escapedCmd = itermCmd.replace(/"/g, '\\"'); - const windowTitle = options.teamName - ? `${options.teamName}: ${options.name}` - : options.name; + const windowTitle = options.teamName ? `${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) tell current session of newWindow -- Set the session name (tab title) @@ -195,28 +193,28 @@ end tell`; end tell end tell`; - const result = this.runAppleScript(script); + const result = this.runAppleScript(script); - if (result.status !== 0) { - throw new Error(`osascript failed with status ${result.status}: ${result.stderr}`); - } + if (result.status !== 0) { + throw new Error(`osascript failed with status ${result.status}: ${result.stderr}`); + } - const windowId = result.stdout.toString().trim(); - return `iterm_win_${windowId}`; - } + const windowId = result.stdout.toString().trim(); + return `iterm_win_${windowId}`; + } - /** - * Set the title of a specific window. - */ - setWindowTitle(windowId: string, title: string): void { - if (!windowId || !windowId.startsWith("iterm_win_")) { - return; - } + /** + * Set the title of a specific window. + */ + setWindowTitle(windowId: string, title: string): void { + if (!windowId || !windowId.startsWith("iterm_win_")) { + return; + } - const itermId = windowId.replace("iterm_win_", ""); - const escapedTitle = title.replace(/"/g, '\\"'); + const itermId = windowId.replace("iterm_win_", ""); + const escapedTitle = title.replace(/"/g, '\\"'); - const script = `tell application "iTerm2" + const script = `tell application "iTerm2" repeat with aWindow in windows if id of aWindow is "${itermId}" then tell current session of aWindow @@ -227,23 +225,23 @@ end tell`; end repeat end tell`; - try { - this.runAppleScript(script); - } catch { - // Silently fail - } - } + try { + this.runAppleScript(script); + } catch { + // Silently fail + } + } - /** - * Kill/terminate a window. - */ - killWindow(windowId: string): void { - if (!windowId || !windowId.startsWith("iterm_win_")) { - return; - } + /** + * Kill/terminate a window. + */ + killWindow(windowId: string): void { + if (!windowId || !windowId.startsWith("iterm_win_")) { + return; + } - const itermId = windowId.replace("iterm_win_", ""); - const script = `tell application "iTerm2" + const itermId = windowId.replace("iterm_win_", ""); + const script = `tell application "iTerm2" repeat with aWindow in windows if id of aWindow is "${itermId}" then close aWindow @@ -252,23 +250,23 @@ end tell`; end repeat end tell`; - try { - this.runAppleScript(script); - } catch { - // Silently fail - } - } + try { + this.runAppleScript(script); + } catch { + // Silently fail + } + } - /** - * Check if a window is still alive/active. - */ - isWindowAlive(windowId: string): boolean { - if (!windowId || !windowId.startsWith("iterm_win_")) { - return false; - } + /** + * Check if a window is still alive/active. + */ + isWindowAlive(windowId: string): boolean { + if (!windowId || !windowId.startsWith("iterm_win_")) { + return false; + } - const itermId = windowId.replace("iterm_win_", ""); - const script = `tell application "iTerm2" + const itermId = windowId.replace("iterm_win_", ""); + const script = `tell application "iTerm2" repeat with aWindow in windows if id of aWindow is "${itermId}" then return "Alive" @@ -276,25 +274,25 @@ end tell`; end repeat end tell`; - try { - const result = this.runAppleScript(script); - return result.stdout.includes("Alive"); - } catch { - return false; - } - } + try { + const result = this.runAppleScript(script); + return result.stdout.includes("Alive"); + } catch { + return false; + } + } - /** - * Set the spawn context (used to restore state when needed) - */ - setSpawnContext(context: Iterm2SpawnContext): void { - this.spawnContext = context; - } + /** + * Set the spawn context (used to restore state when needed) + */ + setSpawnContext(context: Iterm2SpawnContext): void { + this.spawnContext = context; + } - /** - * Get current spawn context (useful for persisting state) - */ - getSpawnContext(): Iterm2SpawnContext { - return { ...this.spawnContext }; - } + /** + * Get current spawn context (useful for persisting state) + */ + getSpawnContext(): Iterm2SpawnContext { + return { ...this.spawnContext }; + } } diff --git a/packages/pi-teams/src/adapters/terminal-registry.ts b/packages/pi-teams/src/adapters/terminal-registry.ts index 939d2a05..8aaddece 100644 --- a/packages/pi-teams/src/adapters/terminal-registry.ts +++ b/packages/pi-teams/src/adapters/terminal-registry.ts @@ -1,16 +1,16 @@ /** * Terminal Registry - * + * * Manages terminal adapters and provides automatic selection based on * the current environment. */ -import { 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 type { TerminalAdapter } from "../utils/terminal-adapter"; import { CmuxAdapter } from "./cmux-adapter"; +import { Iterm2Adapter } from "./iterm2-adapter"; +import { TmuxAdapter } from "./tmux-adapter"; +import { WezTermAdapter } from "./wezterm-adapter"; +import { ZellijAdapter } from "./zellij-adapter"; /** * Available terminal adapters, ordered by priority @@ -23,11 +23,11 @@ import { CmuxAdapter } from "./cmux-adapter"; * 4. WezTerm - if WEZTERM_PANE env is set and not in tmux/zellij */ const adapters: TerminalAdapter[] = [ - new CmuxAdapter(), - new TmuxAdapter(), - new ZellijAdapter(), - new Iterm2Adapter(), - new WezTermAdapter(), + new CmuxAdapter(), + new TmuxAdapter(), + new ZellijAdapter(), + new Iterm2Adapter(), + new WezTermAdapter(), ]; /** @@ -47,18 +47,18 @@ let cachedAdapter: TerminalAdapter | null = null; * @returns The detected terminal adapter, or null if none detected */ export function getTerminalAdapter(): TerminalAdapter | null { - if (cachedAdapter) { - return cachedAdapter; - } + if (cachedAdapter) { + return cachedAdapter; + } - for (const adapter of adapters) { - if (adapter.detect()) { - cachedAdapter = adapter; - return adapter; - } - } + for (const adapter of adapters) { + if (adapter.detect()) { + cachedAdapter = 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 */ 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. - * + * * @returns Array of all registered adapters */ export function getAllAdapters(): TerminalAdapter[] { - return [...adapters]; + return [...adapters]; } /** * Clear the cached adapter (useful for testing or environment changes) */ export function clearAdapterCache(): void { - cachedAdapter = null; + cachedAdapter = null; } /** * Set a specific adapter (useful for testing or forced selection) */ export function setAdapter(adapter: TerminalAdapter): void { - cachedAdapter = adapter; + cachedAdapter = adapter; } /** * Check if any terminal adapter is available. - * + * * @returns true if a terminal adapter was detected */ export function hasTerminalAdapter(): boolean { - return getTerminalAdapter() !== null; + return getTerminalAdapter() !== null; } /** * Check if the current terminal supports spawning separate OS windows. - * + * * @returns true if the detected terminal supports windows (iTerm2, WezTerm) */ export function supportsWindows(): boolean { - const adapter = getTerminalAdapter(); - return adapter?.supportsWindows() ?? false; + const adapter = getTerminalAdapter(); + return adapter?.supportsWindows() ?? false; } /** * Get the name of the currently detected terminal adapter. - * + * * @returns The adapter name, or null if none detected */ export function getTerminalName(): string | null { - return getTerminalAdapter()?.name ?? null; + return getTerminalAdapter()?.name ?? null; } diff --git a/packages/pi-teams/src/adapters/tmux-adapter.ts b/packages/pi-teams/src/adapters/tmux-adapter.ts index 4961b12c..5c91748f 100644 --- a/packages/pi-teams/src/adapters/tmux-adapter.ts +++ b/packages/pi-teams/src/adapters/tmux-adapter.ts @@ -1,112 +1,118 @@ /** * Tmux Terminal Adapter - * + * * Implements the TerminalAdapter interface for tmux terminal multiplexer. */ 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 { - readonly name = "tmux"; + readonly name = "tmux"; - detect(): boolean { - // tmux is available if TMUX environment variable is set - return !!process.env.TMUX; - } + detect(): boolean { + // tmux is available if TMUX environment variable is set + return !!process.env.TMUX; + } - spawn(options: SpawnOptions): string { - const envArgs = Object.entries(options.env) - .filter(([k]) => k.startsWith("PI_")) - .map(([k, v]) => `${k}=${v}`); + spawn(options: SpawnOptions): string { + const envArgs = Object.entries(options.env) + .filter(([k]) => k.startsWith("PI_")) + .map(([k, v]) => `${k}=${v}`); - const tmuxArgs = [ - "split-window", - "-h", "-dP", - "-F", "#{pane_id}", - "-c", options.cwd, - "env", ...envArgs, - "sh", "-c", options.command - ]; + const tmuxArgs = [ + "split-window", + "-h", + "-dP", + "-F", + "#{pane_id}", + "-c", + options.cwd, + "env", + ...envArgs, + "sh", + "-c", + options.command, + ]; - const result = execCommand("tmux", tmuxArgs); - - if (result.status !== 0) { - throw new Error(`tmux spawn failed with status ${result.status}: ${result.stderr}`); - } + const result = execCommand("tmux", tmuxArgs); - // Apply layout after spawning - execCommand("tmux", ["set-window-option", "main-pane-width", "60%"]); - execCommand("tmux", ["select-layout", "main-vertical"]); + if (result.status !== 0) { + throw new Error(`tmux spawn failed with status ${result.status}: ${result.stderr}`); + } - 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 { - 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 - } - } + return result.stdout.trim(); + } - isAlive(paneId: string): boolean { - if (!paneId || paneId.startsWith("iterm_") || paneId.startsWith("zellij_")) { - return false; // Not a tmux pane - } + kill(paneId: string): void { + if (!paneId || paneId.startsWith("iterm_") || paneId.startsWith("zellij_")) { + return; // Not a tmux pane + } - try { - execSync(`tmux has-session -t ${paneId}`); - return true; - } catch { - return false; - } - } + try { + execCommand("tmux", ["kill-pane", "-t", paneId.trim()]); + } catch { + // Ignore errors - pane may already be dead + } + } - setTitle(title: string): void { - try { - execCommand("tmux", ["select-pane", "-T", title]); - } catch { - // Ignore errors - } - } + isAlive(paneId: string): boolean { + if (!paneId || paneId.startsWith("iterm_") || paneId.startsWith("zellij_")) { + return false; // Not a tmux pane + } - /** - * tmux does not support spawning separate OS windows - */ - supportsWindows(): boolean { - return false; - } + try { + execSync(`tmux has-session -t ${paneId}`); + return true; + } catch { + return false; + } + } - /** - * Not supported - throws error - */ - spawnWindow(_options: SpawnOptions): string { - throw new Error("tmux does not support spawning separate OS windows. Use iTerm2 or WezTerm instead."); - } + setTitle(title: string): void { + try { + execCommand("tmux", ["select-pane", "-T", title]); + } catch { + // Ignore errors + } + } - /** - * Not supported - no-op - */ - setWindowTitle(_windowId: string, _title: string): void { - // Not supported - } + /** + * tmux does not support spawning separate OS windows + */ + supportsWindows(): boolean { + return false; + } - /** - * Not supported - no-op - */ - killWindow(_windowId: string): void { - // Not supported - } + /** + * Not supported - throws error + */ + spawnWindow(_options: SpawnOptions): string { + throw new Error("tmux does not support spawning separate OS windows. Use iTerm2 or WezTerm instead."); + } - /** - * Not supported - always returns false - */ - isWindowAlive(_windowId: string): boolean { - return false; - } + /** + * Not supported - no-op + */ + setWindowTitle(_windowId: string, _title: string): void { + // Not supported + } + + /** + * Not supported - no-op + */ + killWindow(_windowId: string): void { + // Not supported + } + + /** + * Not supported - always returns false + */ + isWindowAlive(_windowId: string): boolean { + return false; + } } diff --git a/packages/pi-teams/src/adapters/wezterm-adapter.test.ts b/packages/pi-teams/src/adapters/wezterm-adapter.test.ts index 044754f2..db67fd2f 100644 --- a/packages/pi-teams/src/adapters/wezterm-adapter.test.ts +++ b/packages/pi-teams/src/adapters/wezterm-adapter.test.ts @@ -2,100 +2,103 @@ * WezTerm Adapter Tests */ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { WezTermAdapter } from "./wezterm-adapter"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as terminalAdapter from "../utils/terminal-adapter"; +import { WezTermAdapter } from "./wezterm-adapter"; describe("WezTermAdapter", () => { - let adapter: WezTermAdapter; - let mockExecCommand: ReturnType; + let adapter: WezTermAdapter; + let mockExecCommand: ReturnType; - beforeEach(() => { - adapter = new WezTermAdapter(); - mockExecCommand = vi.spyOn(terminalAdapter, "execCommand"); - delete process.env.WEZTERM_PANE; - delete process.env.TMUX; - delete process.env.ZELLIJ; - process.env.WEZTERM_PANE = "0"; - }); + beforeEach(() => { + adapter = new WezTermAdapter(); + mockExecCommand = vi.spyOn(terminalAdapter, "execCommand"); + delete process.env.WEZTERM_PANE; + delete process.env.TMUX; + delete process.env.ZELLIJ; + process.env.WEZTERM_PANE = "0"; + }); - afterEach(() => { - vi.clearAllMocks(); - }); + afterEach(() => { + vi.clearAllMocks(); + }); - describe("name", () => { - it("should have the correct name", () => { - expect(adapter.name).toBe("WezTerm"); - }); - }); + describe("name", () => { + it("should have the correct name", () => { + expect(adapter.name).toBe("WezTerm"); + }); + }); - describe("detect", () => { - it("should detect when WEZTERM_PANE is set", () => { - mockExecCommand.mockReturnValue({ stdout: "version 1.0", stderr: "", status: 0 }); - expect(adapter.detect()).toBe(true); - }); - }); + describe("detect", () => { + it("should detect when WEZTERM_PANE is set", () => { + mockExecCommand.mockReturnValue({ stdout: "version 1.0", stderr: "", status: 0 }); + expect(adapter.detect()).toBe(true); + }); + }); - describe("spawn", () => { - it("should spawn first pane to the right with 50%", () => { - // Mock getPanes finding only current pane - mockExecCommand.mockImplementation((bin, args) => { - if (args.includes("list")) { - return { - stdout: JSON.stringify([{ pane_id: 0, tab_id: 0 }]), - stderr: "", - status: 0 - }; - } - if (args.includes("split-pane")) { - return { stdout: "1", stderr: "", status: 0 }; - } - return { stdout: "", stderr: "", status: 0 }; - }); + describe("spawn", () => { + it("should spawn first pane to the right with 50%", () => { + // Mock getPanes finding only current pane + mockExecCommand.mockImplementation((bin, args) => { + if (args.includes("list")) { + return { + stdout: JSON.stringify([{ pane_id: 0, tab_id: 0 }]), + stderr: "", + status: 0, + }; + } + if (args.includes("split-pane")) { + return { stdout: "1", stderr: "", status: 0 }; + } + return { stdout: "", stderr: "", status: 0 }; + }); - const result = adapter.spawn({ - name: "test-agent", - cwd: "/home/user/project", - command: "pi --agent test", - env: { PI_AGENT_ID: "test-123" }, - }); + const result = adapter.spawn({ + name: "test-agent", + cwd: "/home/user/project", + command: "pi --agent test", + env: { PI_AGENT_ID: "test-123" }, + }); - expect(result).toBe("wezterm_1"); - expect(mockExecCommand).toHaveBeenCalledWith( - expect.stringContaining("wezterm"), - expect.arrayContaining(["cli", "split-pane", "--right", "--percent", "50"]) - ); - }); + expect(result).toBe("wezterm_1"); + expect(mockExecCommand).toHaveBeenCalledWith( + expect.stringContaining("wezterm"), + expect.arrayContaining(["cli", "split-pane", "--right", "--percent", "50"]), + ); + }); - it("should spawn subsequent panes by splitting the sidebar", () => { - // Mock getPanes finding current pane (0) and sidebar pane (1) - mockExecCommand.mockImplementation((bin, args) => { - if (args.includes("list")) { - return { - stdout: JSON.stringify([{ pane_id: 0, tab_id: 0 }, { pane_id: 1, tab_id: 0 }]), - stderr: "", - status: 0 - }; - } - if (args.includes("split-pane")) { - return { stdout: "2", stderr: "", status: 0 }; - } - return { stdout: "", stderr: "", status: 0 }; - }); + it("should spawn subsequent panes by splitting the sidebar", () => { + // Mock getPanes finding current pane (0) and sidebar pane (1) + mockExecCommand.mockImplementation((bin, args) => { + if (args.includes("list")) { + return { + stdout: JSON.stringify([ + { pane_id: 0, tab_id: 0 }, + { pane_id: 1, tab_id: 0 }, + ]), + stderr: "", + status: 0, + }; + } + if (args.includes("split-pane")) { + return { stdout: "2", stderr: "", status: 0 }; + } + return { stdout: "", stderr: "", status: 0 }; + }); - const result = adapter.spawn({ - name: "agent2", - cwd: "/home/user/project", - command: "pi", - env: {}, - }); + const result = adapter.spawn({ + name: "agent2", + cwd: "/home/user/project", + command: "pi", + env: {}, + }); - expect(result).toBe("wezterm_2"); - // 1 sidebar pane already exists, so percent should be floor(100/(1+1)) = 50% - expect(mockExecCommand).toHaveBeenCalledWith( - expect.stringContaining("wezterm"), - expect.arrayContaining(["cli", "split-pane", "--bottom", "--pane-id", "1", "--percent", "50"]) - ); - }); - }); + expect(result).toBe("wezterm_2"); + // 1 sidebar pane already exists, so percent should be floor(100/(1+1)) = 50% + expect(mockExecCommand).toHaveBeenCalledWith( + expect.stringContaining("wezterm"), + expect.arrayContaining(["cli", "split-pane", "--bottom", "--pane-id", "1", "--percent", "50"]), + ); + }); + }); }); diff --git a/packages/pi-teams/src/adapters/wezterm-adapter.ts b/packages/pi-teams/src/adapters/wezterm-adapter.ts index 0bc7fa72..b93bfd4d 100644 --- a/packages/pi-teams/src/adapters/wezterm-adapter.ts +++ b/packages/pi-teams/src/adapters/wezterm-adapter.ts @@ -5,300 +5,327 @@ * 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 { - readonly name = "WezTerm"; + readonly name = "WezTerm"; - // Common paths where wezterm CLI might be found - private possiblePaths = [ - "wezterm", // In PATH - "/Applications/WezTerm.app/Contents/MacOS/wezterm", // macOS - "/usr/local/bin/wezterm", // Linux/macOS common - "/usr/bin/wezterm", // Linux system - ]; + // Common paths where wezterm CLI might be found + private possiblePaths = [ + "wezterm", // In PATH + "/Applications/WezTerm.app/Contents/MacOS/wezterm", // macOS + "/usr/local/bin/wezterm", // Linux/macOS common + "/usr/bin/wezterm", // Linux system + ]; - private weztermPath: string | null = null; + private weztermPath: string | null = null; - private findWeztermBinary(): string | null { - if (this.weztermPath !== null) { - return this.weztermPath; - } + private findWeztermBinary(): string | null { + if (this.weztermPath !== null) { + return this.weztermPath; + } - for (const path of this.possiblePaths) { - try { - const result = execCommand(path, ["--version"]); - if (result.status === 0) { - this.weztermPath = path; - return path; - } - } catch { - // Continue to next path - } - } + for (const path of this.possiblePaths) { + try { + const result = execCommand(path, ["--version"]); + if (result.status === 0) { + this.weztermPath = path; + return path; + } + } catch { + // Continue to next path + } + } - this.weztermPath = null; - return null; - } + this.weztermPath = null; + return null; + } - detect(): boolean { - if (!process.env.WEZTERM_PANE || process.env.TMUX || process.env.ZELLIJ) { - return false; - } - return this.findWeztermBinary() !== null; - } + detect(): boolean { + if (!process.env.WEZTERM_PANE || process.env.TMUX || process.env.ZELLIJ) { + return false; + } + return this.findWeztermBinary() !== null; + } - /** - * Get all panes in the current tab to determine layout state. - */ - private getPanes(): any[] { - const weztermBin = this.findWeztermBinary(); - if (!weztermBin) return []; + /** + * Get all panes in the current tab to determine layout state. + */ + private getPanes(): any[] { + const weztermBin = this.findWeztermBinary(); + if (!weztermBin) return []; - const result = execCommand(weztermBin, ["cli", "list", "--format", "json"]); - if (result.status !== 0) return []; + const result = execCommand(weztermBin, ["cli", "list", "--format", "json"]); + if (result.status !== 0) return []; - try { - const allPanes = JSON.parse(result.stdout); - const currentPaneId = parseInt(process.env.WEZTERM_PANE || "0", 10); - - // Find the tab of the current pane - const currentPane = allPanes.find((p: any) => p.pane_id === currentPaneId); - if (!currentPane) return []; + try { + const allPanes = JSON.parse(result.stdout); + const currentPaneId = parseInt(process.env.WEZTERM_PANE || "0", 10); - // Return all panes in the same tab - return allPanes.filter((p: any) => p.tab_id === currentPane.tab_id); - } catch { - return []; - } - } + // Find the tab of the current pane + const currentPane = allPanes.find((p: any) => p.pane_id === currentPaneId); + if (!currentPane) return []; - spawn(options: SpawnOptions): string { - const weztermBin = this.findWeztermBinary(); - if (!weztermBin) { - throw new Error("WezTerm CLI binary not found."); - } + // Return all panes in the same tab + return allPanes.filter((p: any) => p.tab_id === currentPane.tab_id); + } catch { + return []; + } + } - const panes = this.getPanes(); - const envArgs = Object.entries(options.env) - .filter(([k]) => k.startsWith("PI_")) - .map(([k, v]) => `${k}=${v}`); + spawn(options: SpawnOptions): string { + const weztermBin = this.findWeztermBinary(); + if (!weztermBin) { + throw new Error("WezTerm CLI binary not found."); + } - let weztermArgs: string[]; - - // First pane: split to the right with 50% (matches iTerm2/tmux behavior) - const isFirstPane = panes.length === 1; + const panes = this.getPanes(); + const envArgs = Object.entries(options.env) + .filter(([k]) => k.startsWith("PI_")) + .map(([k, v]) => `${k}=${v}`); - if (isFirstPane) { - weztermArgs = [ - "cli", "split-pane", "--right", "--percent", "50", - "--cwd", options.cwd, "--", "env", ...envArgs, "sh", "-c", options.command - ]; - } else { - // Subsequent teammates stack in the sidebar on the right. - // currentPaneId (id 0) is the main pane on the left. - // All other panes are in the sidebar. - const currentPaneId = parseInt(process.env.WEZTERM_PANE || "0", 10); - const sidebarPanes = panes - .filter(p => p.pane_id !== currentPaneId) - .sort((a, b) => b.cursor_y - a.cursor_y); // Sort by vertical position (bottom-most first) + let weztermArgs: string[]; - // To add a new pane to the bottom of the sidebar stack: - // We always split the BOTTOM-MOST pane (sidebarPanes[0]) - // and use 50% so the new pane and the previous bottom pane are equal. - // This progressively fills the sidebar from top to bottom. - const targetPane = sidebarPanes[0]; + // First pane: split to the right with 50% (matches iTerm2/tmux behavior) + const isFirstPane = panes.length === 1; - weztermArgs = [ - "cli", "split-pane", "--bottom", "--pane-id", targetPane.pane_id.toString(), - "--percent", "50", - "--cwd", options.cwd, "--", "env", ...envArgs, "sh", "-c", options.command - ]; - } + if (isFirstPane) { + weztermArgs = [ + "cli", + "split-pane", + "--right", + "--percent", + "50", + "--cwd", + options.cwd, + "--", + "env", + ...envArgs, + "sh", + "-c", + options.command, + ]; + } else { + // Subsequent teammates stack in the sidebar on the right. + // currentPaneId (id 0) is the main pane on the left. + // All other panes are in the sidebar. + const currentPaneId = parseInt(process.env.WEZTERM_PANE || "0", 10); + const sidebarPanes = panes.filter((p) => p.pane_id !== currentPaneId).sort((a, b) => b.cursor_y - a.cursor_y); // Sort by vertical position (bottom-most first) - const result = execCommand(weztermBin, weztermArgs); - if (result.status !== 0) { - throw new Error(`wezterm spawn failed: ${result.stderr}`); - } + // To add a new pane to the bottom of the sidebar stack: + // We always split the BOTTOM-MOST pane (sidebarPanes[0]) + // and use 50% so the new pane and the previous bottom pane are equal. + // This progressively fills the sidebar from top to bottom. + const targetPane = sidebarPanes[0]; - // New: After spawning, tell WezTerm to equalize the panes in this tab - // This ensures that regardless of the split math, they all end up the same height. - try { - execCommand(weztermBin, ["cli", "zoom-pane", "--unzoom"]); // Ensure not zoomed - // WezTerm doesn't have a single "equalize" command like tmux, - // but splitting with no percentage usually balances, or we can use - // the 'AdjustPaneSize' sequence. - // For now, let's stick to the 50/50 split of the LAST pane which is most reliable. - } catch {} + weztermArgs = [ + "cli", + "split-pane", + "--bottom", + "--pane-id", + targetPane.pane_id.toString(), + "--percent", + "50", + "--cwd", + options.cwd, + "--", + "env", + ...envArgs, + "sh", + "-c", + options.command, + ]; + } - const paneId = result.stdout.trim(); - return `wezterm_${paneId}`; - } + const result = execCommand(weztermBin, weztermArgs); + if (result.status !== 0) { + throw new Error(`wezterm spawn failed: ${result.stderr}`); + } - kill(paneId: string): void { - if (!paneId?.startsWith("wezterm_")) return; - const weztermBin = this.findWeztermBinary(); - if (!weztermBin) return; + // New: After spawning, tell WezTerm to equalize the panes in this tab + // This ensures that regardless of the split math, they all end up the same height. + try { + execCommand(weztermBin, ["cli", "zoom-pane", "--unzoom"]); // Ensure not zoomed + // WezTerm doesn't have a single "equalize" command like tmux, + // but splitting with no percentage usually balances, or we can use + // the 'AdjustPaneSize' sequence. + // For now, let's stick to the 50/50 split of the LAST pane which is most reliable. + } catch {} - const weztermId = paneId.replace("wezterm_", ""); - try { - execCommand(weztermBin, ["cli", "kill-pane", "--pane-id", weztermId]); - } catch {} - } + const paneId = result.stdout.trim(); + return `wezterm_${paneId}`; + } - isAlive(paneId: string): boolean { - if (!paneId?.startsWith("wezterm_")) return false; - const weztermBin = this.findWeztermBinary(); - if (!weztermBin) return false; + kill(paneId: string): void { + if (!paneId?.startsWith("wezterm_")) return; + const weztermBin = this.findWeztermBinary(); + if (!weztermBin) return; - const weztermId = parseInt(paneId.replace("wezterm_", ""), 10); - const panes = this.getPanes(); - return panes.some(p => p.pane_id === weztermId); - } + const weztermId = paneId.replace("wezterm_", ""); + try { + execCommand(weztermBin, ["cli", "kill-pane", "--pane-id", weztermId]); + } catch {} + } - setTitle(title: string): void { - const weztermBin = this.findWeztermBinary(); - if (!weztermBin) return; - try { - execCommand(weztermBin, ["cli", "set-tab-title", title]); - } catch {} - } + isAlive(paneId: string): boolean { + if (!paneId?.startsWith("wezterm_")) return false; + const weztermBin = this.findWeztermBinary(); + if (!weztermBin) return false; - /** - * WezTerm supports spawning separate OS windows via CLI - */ - supportsWindows(): boolean { - return this.findWeztermBinary() !== null; - } + const weztermId = parseInt(paneId.replace("wezterm_", ""), 10); + const panes = this.getPanes(); + return panes.some((p) => p.pane_id === weztermId); + } - /** - * Spawn a new separate OS window with the given options. - * Uses `wezterm cli spawn --new-window` and sets the window title. - */ - spawnWindow(options: SpawnOptions): string { - const weztermBin = this.findWeztermBinary(); - if (!weztermBin) { - throw new Error("WezTerm CLI binary not found."); - } + setTitle(title: string): void { + const weztermBin = this.findWeztermBinary(); + if (!weztermBin) return; + try { + execCommand(weztermBin, ["cli", "set-tab-title", title]); + } catch {} + } - const envArgs = Object.entries(options.env) - .filter(([k]) => k.startsWith("PI_")) - .map(([k, v]) => `${k}=${v}`); + /** + * WezTerm supports spawning separate OS windows via CLI + */ + supportsWindows(): boolean { + return this.findWeztermBinary() !== null; + } - // Format window title as "teamName: agentName" if teamName is provided - const windowTitle = options.teamName - ? `${options.teamName}: ${options.name}` - : options.name; + /** + * Spawn a new separate OS window with the given options. + * Uses `wezterm cli spawn --new-window` and sets the window title. + */ + spawnWindow(options: SpawnOptions): string { + const weztermBin = this.findWeztermBinary(); + if (!weztermBin) { + throw new Error("WezTerm CLI binary not found."); + } - // Spawn a new window - const spawnArgs = [ - "cli", "spawn", "--new-window", - "--cwd", options.cwd, - "--", "env", ...envArgs, "sh", "-c", options.command - ]; + const envArgs = Object.entries(options.env) + .filter(([k]) => k.startsWith("PI_")) + .map(([k, v]) => `${k}=${v}`); - const result = execCommand(weztermBin, spawnArgs); - if (result.status !== 0) { - throw new Error(`wezterm spawn-window failed: ${result.stderr}`); - } + // Format window title as "teamName: agentName" if teamName is provided + const windowTitle = options.teamName ? `${options.teamName}: ${options.name}` : options.name; - // The output is the pane ID, we need to find the window ID - const paneId = result.stdout.trim(); - - // Query to get window ID from pane ID - const windowId = this.getWindowIdFromPaneId(parseInt(paneId, 10)); - - // Set the window title if we found the window - if (windowId !== null) { - this.setWindowTitle(`wezterm_win_${windowId}`, windowTitle); - } + // Spawn a new window + const spawnArgs = [ + "cli", + "spawn", + "--new-window", + "--cwd", + options.cwd, + "--", + "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}`); + } - /** - * Get window ID from a pane ID by querying WezTerm - */ - private getWindowIdFromPaneId(paneId: number): number | null { - const weztermBin = this.findWeztermBinary(); - if (!weztermBin) return null; + // The output is the pane ID, we need to find the window ID + const paneId = result.stdout.trim(); - const result = execCommand(weztermBin, ["cli", "list", "--format", "json"]); - if (result.status !== 0) return null; + // Query to get window ID from pane ID + const windowId = this.getWindowIdFromPaneId(parseInt(paneId, 10)); - try { - const allPanes = JSON.parse(result.stdout); - const pane = allPanes.find((p: any) => p.pane_id === paneId); - return pane?.window_id ?? null; - } catch { - return null; - } - } + // Set the window title if we found the window + if (windowId !== null) { + this.setWindowTitle(`wezterm_win_${windowId}`, windowTitle); + } - /** - * 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; + return `wezterm_win_${windowId || paneId}`; + } - const weztermWindowId = windowId.replace("wezterm_win_", ""); - - try { - execCommand(weztermBin, ["cli", "set-window-title", "--window-id", weztermWindowId, title]); - } catch { - // Silently fail - } - } + /** + * Get window ID from a pane ID by querying WezTerm + */ + private getWindowIdFromPaneId(paneId: number): number | null { + const weztermBin = this.findWeztermBinary(); + if (!weztermBin) return null; - /** - * Kill/terminate a window. - */ - killWindow(windowId: string): void { - if (!windowId?.startsWith("wezterm_win_")) return; - - const weztermBin = this.findWeztermBinary(); - if (!weztermBin) return; + const result = execCommand(weztermBin, ["cli", "list", "--format", "json"]); + if (result.status !== 0) return null; - 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; + try { + const allPanes = JSON.parse(result.stdout); + const pane = allPanes.find((p: any) => p.pane_id === paneId); + return pane?.window_id ?? null; + } catch { + return null; + } + } - 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 - } - } + /** + * Set the title of a specific window. + */ + setWindowTitle(windowId: string, title: string): void { + if (!windowId?.startsWith("wezterm_win_")) return; - /** - * 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 weztermBin = this.findWeztermBinary(); + if (!weztermBin) return; - const weztermWindowId = windowId.replace("wezterm_win_", ""); - - try { - const result = execCommand(weztermBin, ["cli", "list", "--format", "json"]); - if (result.status !== 0) return false; + const weztermWindowId = windowId.replace("wezterm_win_", ""); - const allPanes = JSON.parse(result.stdout); - return allPanes.some((p: any) => p.window_id.toString() === weztermWindowId); - } catch { - return false; - } - } + try { + execCommand(weztermBin, ["cli", "set-window-title", "--window-id", weztermWindowId, title]); + } catch { + // Silently fail + } + } + + /** + * Kill/terminate a window. + */ + killWindow(windowId: string): void { + if (!windowId?.startsWith("wezterm_win_")) return; + + const weztermBin = this.findWeztermBinary(); + if (!weztermBin) return; + + const weztermWindowId = windowId.replace("wezterm_win_", ""); + + try { + // WezTerm doesn't have a direct kill-window command, so we kill all panes in the window + const result = execCommand(weztermBin, ["cli", "list", "--format", "json"]); + if (result.status !== 0) return; + + const allPanes = JSON.parse(result.stdout); + const windowPanes = allPanes.filter((p: any) => p.window_id.toString() === weztermWindowId); + + for (const pane of windowPanes) { + execCommand(weztermBin, ["cli", "kill-pane", "--pane-id", pane.pane_id.toString()]); + } + } catch { + // Silently fail + } + } + + /** + * Check if a window is still alive/active. + */ + isWindowAlive(windowId: string): boolean { + if (!windowId?.startsWith("wezterm_win_")) return false; + + const weztermBin = this.findWeztermBinary(); + if (!weztermBin) return false; + + const weztermWindowId = windowId.replace("wezterm_win_", ""); + + try { + const result = execCommand(weztermBin, ["cli", "list", "--format", "json"]); + if (result.status !== 0) return false; + + const allPanes = JSON.parse(result.stdout); + return allPanes.some((p: any) => p.window_id.toString() === weztermWindowId); + } catch { + return false; + } + } } diff --git a/packages/pi-teams/src/adapters/zellij-adapter.ts b/packages/pi-teams/src/adapters/zellij-adapter.ts index a3dde6cd..a331b78a 100644 --- a/packages/pi-teams/src/adapters/zellij-adapter.ts +++ b/packages/pi-teams/src/adapters/zellij-adapter.ts @@ -1,97 +1,101 @@ /** * Zellij Terminal Adapter - * + * * Implements the TerminalAdapter interface for Zellij terminal multiplexer. * 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 { - readonly name = "zellij"; + readonly name = "zellij"; - detect(): boolean { - // Zellij is available if ZELLIJ env is set and not in tmux - return !!process.env.ZELLIJ && !process.env.TMUX; - } + detect(): boolean { + // Zellij is available if ZELLIJ env is set and not in tmux + return !!process.env.ZELLIJ && !process.env.TMUX; + } - spawn(options: SpawnOptions): string { - const zellijArgs = [ - "run", - "--name", options.name, - "--cwd", options.cwd, - "--close-on-exit", - "--", - "env", - ...Object.entries(options.env) - .filter(([k]) => k.startsWith("PI_")) - .map(([k, v]) => `${k}=${v}`), - "sh", "-c", options.command - ]; + spawn(options: SpawnOptions): string { + const zellijArgs = [ + "run", + "--name", + options.name, + "--cwd", + options.cwd, + "--close-on-exit", + "--", + "env", + ...Object.entries(options.env) + .filter(([k]) => k.startsWith("PI_")) + .map(([k, v]) => `${k}=${v}`), + "sh", + "-c", + options.command, + ]; - const result = execCommand("zellij", zellijArgs); - - if (result.status !== 0) { - throw new Error(`zellij spawn failed with status ${result.status}: ${result.stderr}`); - } + const result = execCommand("zellij", zellijArgs); - // Zellij doesn't return a pane ID, so we create a synthetic one - return `zellij_${options.name}`; - } + if (result.status !== 0) { + throw new Error(`zellij spawn failed with status ${result.status}: ${result.stderr}`); + } - kill(_paneId: string): void { - // Zellij uses --close-on-exit, so panes close automatically - // when the process exits. No explicit kill needed. - } + // Zellij doesn't return a pane ID, so we create a synthetic one + return `zellij_${options.name}`; + } - isAlive(paneId: string): boolean { - // Zellij doesn't have a straightforward way to check if a pane is alive - // For now, we assume alive if it's a zellij pane ID - if (!paneId || !paneId.startsWith("zellij_")) { - return false; - } - - // Could potentially use `zellij list-sessions` or similar in the future - return true; - } + kill(_paneId: string): void { + // Zellij uses --close-on-exit, so panes close automatically + // when the process exits. No explicit kill needed. + } - setTitle(_title: string): void { - // Zellij pane titles are set via --name at spawn time - // No runtime title changing supported - } + isAlive(paneId: string): boolean { + // Zellij doesn't have a straightforward way to check if a pane is alive + // For now, we assume alive if it's a zellij pane ID + if (!paneId || !paneId.startsWith("zellij_")) { + return false; + } - /** - * Zellij does not support spawning separate OS windows - */ - supportsWindows(): boolean { - return false; - } + // Could potentially use `zellij list-sessions` or similar in the future + return true; + } - /** - * Not supported - throws error - */ - spawnWindow(_options: SpawnOptions): string { - throw new Error("Zellij does not support spawning separate OS windows. Use iTerm2 or WezTerm instead."); - } + setTitle(_title: string): void { + // Zellij pane titles are set via --name at spawn time + // No runtime title changing supported + } - /** - * Not supported - no-op - */ - setWindowTitle(_windowId: string, _title: string): void { - // Not supported - } + /** + * Zellij does not support spawning separate OS windows + */ + supportsWindows(): boolean { + return false; + } - /** - * Not supported - no-op - */ - killWindow(_windowId: string): void { - // Not supported - } + /** + * Not supported - throws error + */ + spawnWindow(_options: SpawnOptions): string { + throw new Error("Zellij does not support spawning separate OS windows. Use iTerm2 or WezTerm instead."); + } - /** - * Not supported - always returns false - */ - isWindowAlive(_windowId: string): boolean { - return false; - } + /** + * Not supported - no-op + */ + setWindowTitle(_windowId: string, _title: string): void { + // Not supported + } + + /** + * Not supported - no-op + */ + killWindow(_windowId: string): void { + // Not supported + } + + /** + * Not supported - always returns false + */ + isWindowAlive(_windowId: string): boolean { + return false; + } } diff --git a/packages/pi-teams/src/utils/hooks.test.ts b/packages/pi-teams/src/utils/hooks.test.ts index dd2691c6..a6ecee1a 100644 --- a/packages/pi-teams/src/utils/hooks.test.ts +++ b/packages/pi-teams/src/utils/hooks.test.ts @@ -1,75 +1,75 @@ import fs from "node:fs"; import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { runHook } from "./hooks"; -import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; describe("runHook", () => { - const hooksDir = path.join(process.cwd(), ".pi", "team-hooks"); + const hooksDir = path.join(process.cwd(), ".pi", "team-hooks"); - beforeAll(() => { - if (!fs.existsSync(hooksDir)) { - fs.mkdirSync(hooksDir, { recursive: true }); - } - }); + beforeAll(() => { + if (!fs.existsSync(hooksDir)) { + fs.mkdirSync(hooksDir, { recursive: true }); + } + }); - afterAll(() => { - // Optional: Clean up created scripts - const files = ["success_hook.sh", "fail_hook.sh"]; - files.forEach(f => { - const p = path.join(hooksDir, f); - if (fs.existsSync(p)) fs.unlinkSync(p); - }); - }); + afterAll(() => { + // Optional: Clean up created scripts + const files = ["success_hook.sh", "fail_hook.sh"]; + files.forEach((f) => { + const p = path.join(hooksDir, f); + if (fs.existsSync(p)) fs.unlinkSync(p); + }); + }); - it("should return true if hook script does not exist", async () => { - const result = await runHook("test_team", "non_existent_hook", { data: "test" }); - expect(result).toBe(true); - }); + it("should return true if hook script does not exist", async () => { + const result = await runHook("test_team", "non_existent_hook", { data: "test" }); + expect(result).toBe(true); + }); - it("should return true if hook script succeeds", async () => { - const hookName = "success_hook"; - const scriptPath = path.join(hooksDir, `${hookName}.sh`); - - // Create a simple script that exits with 0 - fs.writeFileSync(scriptPath, "#!/bin/bash\nexit 0", { mode: 0o755 }); + it("should return true if hook script succeeds", async () => { + const hookName = "success_hook"; + const scriptPath = path.join(hooksDir, `${hookName}.sh`); - const result = await runHook("test_team", hookName, { data: "test" }); - expect(result).toBe(true); - }); + // Create a simple script that exits with 0 + fs.writeFileSync(scriptPath, "#!/bin/bash\nexit 0", { mode: 0o755 }); - it("should return false if hook script fails", async () => { - const hookName = "fail_hook"; - const scriptPath = path.join(hooksDir, `${hookName}.sh`); - - // Create a simple script that exits with 1 - fs.writeFileSync(scriptPath, "#!/bin/bash\nexit 1", { mode: 0o755 }); + const result = await runHook("test_team", hookName, { data: "test" }); + expect(result).toBe(true); + }); - // Mock console.error to avoid noise in test output - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + it("should return false if hook script fails", async () => { + const hookName = "fail_hook"; + const scriptPath = path.join(hooksDir, `${hookName}.sh`); - const result = await runHook("test_team", hookName, { data: "test" }); - expect(result).toBe(false); + // Create a simple script that exits with 1 + 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 hookName = "payload_hook"; - const scriptPath = path.join(hooksDir, `${hookName}.sh`); - const outputFile = path.join(hooksDir, "payload_output.txt"); + const result = await runHook("test_team", hookName, { data: "test" }); + expect(result).toBe(false); - // Create a script that writes its first argument to a file - fs.writeFileSync(scriptPath, `#!/bin/bash\necho "$1" > "${outputFile}"`, { mode: 0o755 }); + consoleSpy.mockRestore(); + }); - const payload = { key: "value", "special'char": true }; - const result = await runHook("test_team", hookName, payload); + it("should pass the payload to the hook script", async () => { + const hookName = "payload_hook"; + const scriptPath = path.join(hooksDir, `${hookName}.sh`); + const outputFile = path.join(hooksDir, "payload_output.txt"); - expect(result).toBe(true); - const output = fs.readFileSync(outputFile, "utf-8").trim(); - expect(JSON.parse(output)).toEqual(payload); + // Create a script that writes its first argument to a file + fs.writeFileSync(scriptPath, `#!/bin/bash\necho "$1" > "${outputFile}"`, { mode: 0o755 }); - // Clean up - fs.unlinkSync(scriptPath); - if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile); - }); + const payload = { key: "value", "special'char": true }; + const result = await runHook("test_team", hookName, payload); + + expect(result).toBe(true); + const output = fs.readFileSync(outputFile, "utf-8").trim(); + expect(JSON.parse(output)).toEqual(payload); + + // Clean up + fs.unlinkSync(scriptPath); + if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile); + }); }); diff --git a/packages/pi-teams/src/utils/hooks.ts b/packages/pi-teams/src/utils/hooks.ts index db41b403..0f424d89 100644 --- a/packages/pi-teams/src/utils/hooks.ts +++ b/packages/pi-teams/src/utils/hooks.ts @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; -import { promisify } from "node:util"; import fs from "node:fs"; import path from "node:path"; +import { promisify } from "node:util"; 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. */ export async function runHook(teamName: string, hookName: string, payload: any): Promise { - 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)) { - return true; - } + if (!fs.existsSync(hookPath)) { + return true; + } - try { - const payloadStr = JSON.stringify(payload); - // Use execFile: More secure (no shell interpolation) and asynchronous - await execFileAsync(hookPath, [payloadStr], { - env: { ...process.env, PI_TEAM: teamName }, - }); - return true; - } catch (error) { - console.error(`Hook ${hookName} failed:`, error); - return false; - } + try { + const payloadStr = JSON.stringify(payload); + // Use execFile: More secure (no shell interpolation) and asynchronous + await execFileAsync(hookPath, [payloadStr], { + env: { ...process.env, PI_TEAM: teamName }, + }); + return true; + } catch (error) { + console.error(`Hook ${hookName} failed:`, error); + return false; + } } diff --git a/packages/pi-teams/src/utils/lock.race.test.ts b/packages/pi-teams/src/utils/lock.race.test.ts index 50280736..20cf25ff 100644 --- a/packages/pi-teams/src/utils/lock.race.test.ts +++ b/packages/pi-teams/src/utils/lock.race.test.ts @@ -1,45 +1,45 @@ -import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; import fs from "node:fs"; -import path from "node:path"; import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { withLock } from "./lock"; describe("withLock race conditions", () => { - const testDir = path.join(os.tmpdir(), "pi-lock-race-test-" + Date.now()); - const lockPath = path.join(testDir, "test"); - const lockFile = `${lockPath}.lock`; + const testDir = path.join(os.tmpdir(), "pi-lock-race-test-" + Date.now()); + const lockPath = path.join(testDir, "test"); + const lockFile = `${lockPath}.lock`; - beforeEach(() => { - if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true }); - }); + beforeEach(() => { + if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true }); + }); - afterEach(() => { - if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true }); - }); + afterEach(() => { + if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true }); + }); - it("should handle multiple concurrent attempts to acquire the lock", async () => { - let counter = 0; - const iterations = 20; - const concurrentCount = 5; + it("should handle multiple concurrent attempts to acquire the lock", async () => { + let counter = 0; + const iterations = 20; + const concurrentCount = 5; - const runTask = async () => { - for (let i = 0; i < iterations; i++) { - await withLock(lockPath, async () => { - const current = counter; - // Add a small delay to increase the chance of race conditions if locking fails - await new Promise(resolve => setTimeout(resolve, Math.random() * 10)); - counter = current + 1; - }); - } - }; + const runTask = async () => { + for (let i = 0; i < iterations; i++) { + await withLock(lockPath, async () => { + const current = counter; + // Add a small delay to increase the chance of race conditions if locking fails + await new Promise((resolve) => setTimeout(resolve, Math.random() * 10)); + counter = current + 1; + }); + } + }; - const promises = []; - for (let i = 0; i < concurrentCount; i++) { - promises.push(runTask()); - } + const promises = []; + for (let i = 0; i < concurrentCount; i++) { + promises.push(runTask()); + } - await Promise.all(promises); + await Promise.all(promises); - expect(counter).toBe(iterations * concurrentCount); - }); + expect(counter).toBe(iterations * concurrentCount); + }); }); diff --git a/packages/pi-teams/src/utils/lock.test.ts b/packages/pi-teams/src/utils/lock.test.ts index 96fa5fa4..a46da18e 100644 --- a/packages/pi-teams/src/utils/lock.test.ts +++ b/packages/pi-teams/src/utils/lock.test.ts @@ -1,48 +1,49 @@ // Project: pi-teams -import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; + import fs from "node:fs"; -import path from "node:path"; import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { withLock } from "./lock"; describe("withLock", () => { - const testDir = path.join(os.tmpdir(), "pi-lock-test-" + Date.now()); - const lockPath = path.join(testDir, "test"); - const lockFile = `${lockPath}.lock`; + const testDir = path.join(os.tmpdir(), "pi-lock-test-" + Date.now()); + const lockPath = path.join(testDir, "test"); + const lockFile = `${lockPath}.lock`; - beforeEach(() => { - if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true }); - }); + beforeEach(() => { + if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true }); + }); - afterEach(() => { - vi.restoreAllMocks(); - if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true }); - }); + afterEach(() => { + vi.restoreAllMocks(); + if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true }); + }); - it("should successfully acquire and release the lock", async () => { - const fn = vi.fn().mockResolvedValue("result"); - const result = await withLock(lockPath, fn); + it("should successfully acquire and release the lock", async () => { + const fn = vi.fn().mockResolvedValue("result"); + const result = await withLock(lockPath, fn); - expect(result).toBe("result"); - expect(fn).toHaveBeenCalled(); - expect(fs.existsSync(lockFile)).toBe(false); - }); + expect(result).toBe("result"); + expect(fn).toHaveBeenCalled(); + expect(fs.existsSync(lockFile)).toBe(false); + }); - it("should fail to acquire lock if already held", async () => { - // Manually create lock file - fs.writeFileSync(lockFile, "9999"); + it("should fail to acquire lock if already held", async () => { + // Manually create lock file + fs.writeFileSync(lockFile, "9999"); - const fn = vi.fn().mockResolvedValue("result"); - - // Test with only 2 retries to speed up the failure - await expect(withLock(lockPath, fn, 2)).rejects.toThrow("Could not acquire lock"); - expect(fn).not.toHaveBeenCalled(); - }); + const fn = vi.fn().mockResolvedValue("result"); - it("should release lock even if function fails", async () => { - const fn = vi.fn().mockRejectedValue(new Error("failure")); + // 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(); + }); - await expect(withLock(lockPath, fn)).rejects.toThrow("failure"); - expect(fs.existsSync(lockFile)).toBe(false); - }); + it("should release lock even if function fails", async () => { + const fn = vi.fn().mockRejectedValue(new Error("failure")); + + await expect(withLock(lockPath, fn)).rejects.toThrow("failure"); + expect(fs.existsSync(lockFile)).toBe(false); + }); }); diff --git a/packages/pi-teams/src/utils/lock.ts b/packages/pi-teams/src/utils/lock.ts index 8e8073b1..fd69f4fc 100644 --- a/packages/pi-teams/src/utils/lock.ts +++ b/packages/pi-teams/src/utils/lock.ts @@ -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 export async function withLock(lockPath: string, fn: () => Promise, retries: number = 50): Promise { - 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)); - } - } + const lockFile = `${lockPath}.lock`; - if (retries === 0) { - throw new Error("Could not acquire 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 + } + } + } - try { - return await fn(); - } finally { - try { - fs.unlinkSync(lockFile); - } catch (e) { - // ignore - } - } + fs.writeFileSync(lockFile, process.pid.toString(), { flag: "wx" }); + break; + } catch (e) { + retries--; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + if (retries === 0) { + throw new Error("Could not acquire lock"); + } + + try { + return await fn(); + } finally { + try { + fs.unlinkSync(lockFile); + } catch (e) { + // ignore + } + } } diff --git a/packages/pi-teams/src/utils/messaging.test.ts b/packages/pi-teams/src/utils/messaging.test.ts index 4c7ec131..cde6fc50 100644 --- a/packages/pi-teams/src/utils/messaging.test.ts +++ b/packages/pi-teams/src/utils/messaging.test.ts @@ -1,104 +1,100 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import fs from "node:fs"; -import path from "node:path"; 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"; // Mock the paths to use a temporary directory const testDir = path.join(os.tmpdir(), "pi-teams-test-" + Date.now()); describe("Messaging Utilities", () => { - beforeEach(() => { - if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true }); - fs.mkdirSync(testDir, { recursive: true }); - - // Override paths to use testDir - vi.spyOn(paths, "inboxPath").mockImplementation((teamName, agentName) => { - return path.join(testDir, "inboxes", `${agentName}.json`); - }); - vi.spyOn(paths, "teamDir").mockReturnValue(testDir); - vi.spyOn(paths, "configPath").mockImplementation((teamName) => { - return path.join(testDir, "config.json"); - }); - }); + beforeEach(() => { + if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true }); + fs.mkdirSync(testDir, { recursive: true }); - afterEach(() => { - vi.restoreAllMocks(); - if (fs.existsSync(testDir)) fs.rmSync(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"); + }); + }); - it("should append a message successfully", async () => { - const msg = { from: "sender", text: "hello", timestamp: "now", read: false }; - await appendMessage("test-team", "receiver", msg); - - const inbox = await readInbox("test-team", "receiver", false, false); - expect(inbox.length).toBe(1); - expect(inbox[0].text).toBe("hello"); - }); + afterEach(() => { + vi.restoreAllMocks(); + if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true }); + }); - it("should handle concurrent appends (Stress Test)", async () => { - const numMessages = 100; - const promises = []; - for (let i = 0; i < numMessages; i++) { - promises.push(sendPlainMessage("test-team", `sender-${i}`, "receiver", `msg-${i}`, `summary-${i}`)); - } - - await Promise.all(promises); - - const inbox = await readInbox("test-team", "receiver", false, false); - expect(inbox.length).toBe(numMessages); - - // Verify all messages are present - const texts = inbox.map(m => m.text).sort(); - for (let i = 0; i < numMessages; i++) { - expect(texts).toContain(`msg-${i}`); - } - }); + it("should append a message successfully", async () => { + const msg = { from: "sender", text: "hello", timestamp: "now", read: false }; + await appendMessage("test-team", "receiver", msg); - 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); - }); + const inbox = await readInbox("test-team", "receiver", false, false); + expect(inbox.length).toBe(1); + expect(inbox[0].text).toBe("hello"); + }); - 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"); + it("should handle concurrent appends (Stress Test)", async () => { + const numMessages = 100; + const promises = []; + for (let i = 0; i < numMessages; i++) { + promises.push(sendPlainMessage("test-team", `sender-${i}`, "receiver", `msg-${i}`, `summary-${i}`)); + } - // 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"); + await Promise.all(promises); - // 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"); + const inbox = await readInbox("test-team", "receiver", false, false); + expect(inbox.length).toBe(numMessages); - // Check sender's inbox (should be empty) - const inboxSender = await readInbox("test-team", "sender", false, false); - expect(inboxSender.length).toBe(0); - }); + // Verify all messages are present + const texts = inbox.map((m) => m.text).sort(); + for (let i = 0; i < numMessages; i++) { + expect(texts).toContain(`msg-${i}`); + } + }); + + it("should mark messages as read", async () => { + await sendPlainMessage("test-team", "sender", "receiver", "msg1", "summary1"); + await sendPlainMessage("test-team", "sender", "receiver", "msg2", "summary2"); + + // Read only unread messages + const unread = await readInbox("test-team", "receiver", true, true); + expect(unread.length).toBe(2); + + // Now all should be read + const all = await readInbox("test-team", "receiver", false, false); + expect(all.length).toBe(2); + expect(all.every((m) => m.read)).toBe(true); + }); + + it("should broadcast message to all members except the sender", async () => { + // Setup team config + const config = { + name: "test-team", + members: [{ name: "sender" }, { name: "member1" }, { name: "member2" }], + }; + const configFilePath = path.join(testDir, "config.json"); + fs.writeFileSync(configFilePath, JSON.stringify(config)); + + await broadcastMessage("test-team", "sender", "broadcast text", "summary"); + + // Check member1's inbox + const inbox1 = await readInbox("test-team", "member1", false, false); + expect(inbox1.length).toBe(1); + expect(inbox1[0].text).toBe("broadcast text"); + expect(inbox1[0].from).toBe("sender"); + + // Check member2's inbox + const inbox2 = await readInbox("test-team", "member2", false, false); + expect(inbox2.length).toBe(1); + expect(inbox2[0].text).toBe("broadcast text"); + expect(inbox2[0].from).toBe("sender"); + + // Check sender's inbox (should be empty) + const inboxSender = await readInbox("test-team", "sender", false, false); + expect(inboxSender.length).toBe(0); + }); }); diff --git a/packages/pi-teams/src/utils/messaging.ts b/packages/pi-teams/src/utils/messaging.ts index 04aad2fc..ab9a76dd 100644 --- a/packages/pi-teams/src/utils/messaging.ts +++ b/packages/pi-teams/src/utils/messaging.ts @@ -1,76 +1,76 @@ import fs from "node:fs"; import path from "node:path"; -import { InboxMessage } from "./models"; import { withLock } from "./lock"; +import type { InboxMessage } from "./models"; import { inboxPath } from "./paths"; import { readConfig } from "./teams"; export function nowIso(): string { - return new Date().toISOString(); + return new Date().toISOString(); } export async function appendMessage(teamName: string, agentName: string, message: InboxMessage) { - const p = inboxPath(teamName, agentName); - const dir = path.dirname(p); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const p = inboxPath(teamName, agentName); + const dir = path.dirname(p); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - await withLock(p, async () => { - let msgs: InboxMessage[] = []; - if (fs.existsSync(p)) { - msgs = JSON.parse(fs.readFileSync(p, "utf-8")); - } - msgs.push(message); - fs.writeFileSync(p, JSON.stringify(msgs, null, 2)); - }); + await withLock(p, async () => { + let msgs: InboxMessage[] = []; + if (fs.existsSync(p)) { + msgs = JSON.parse(fs.readFileSync(p, "utf-8")); + } + msgs.push(message); + fs.writeFileSync(p, JSON.stringify(msgs, null, 2)); + }); } export async function readInbox( - teamName: string, - agentName: string, - unreadOnly = false, - markAsRead = true + teamName: string, + agentName: string, + unreadOnly = false, + markAsRead = true, ): Promise { - const p = inboxPath(teamName, agentName); - if (!fs.existsSync(p)) return []; + const p = inboxPath(teamName, agentName); + if (!fs.existsSync(p)) return []; - return await withLock(p, async () => { - const allMsgs: InboxMessage[] = JSON.parse(fs.readFileSync(p, "utf-8")); - let result = allMsgs; + return await withLock(p, async () => { + const allMsgs: InboxMessage[] = JSON.parse(fs.readFileSync(p, "utf-8")); + let result = allMsgs; - if (unreadOnly) { - result = allMsgs.filter(m => !m.read); - } + if (unreadOnly) { + result = allMsgs.filter((m) => !m.read); + } - if (markAsRead && result.length > 0) { - for (const m of allMsgs) { - if (result.includes(m)) { - m.read = true; - } - } - fs.writeFileSync(p, JSON.stringify(allMsgs, null, 2)); - } + if (markAsRead && result.length > 0) { + for (const m of allMsgs) { + if (result.includes(m)) { + m.read = true; + } + } + fs.writeFileSync(p, JSON.stringify(allMsgs, null, 2)); + } - return result; - }); + return result; + }); } export async function sendPlainMessage( - teamName: string, - fromName: string, - toName: string, - text: string, - summary: string, - color?: string + teamName: string, + fromName: string, + toName: string, + text: string, + summary: string, + color?: string, ) { - const msg: InboxMessage = { - from: fromName, - text, - timestamp: nowIso(), - read: false, - summary, - color, - }; - await appendMessage(teamName, toName, msg); + const msg: InboxMessage = { + from: fromName, + text, + timestamp: nowIso(), + read: false, + summary, + color, + }; + await appendMessage(teamName, toName, msg); } /** @@ -82,27 +82,27 @@ export async function sendPlainMessage( * @param color An optional color for the message */ export async function broadcastMessage( - teamName: string, - fromName: string, - text: string, - summary: string, - color?: string + teamName: string, + fromName: string, + text: string, + summary: 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 - const deliveryPromises = config.members - .filter((member) => member.name !== fromName) - .map((member) => sendPlainMessage(teamName, fromName, member.name, text, summary, color)); + // Create an array of delivery promises for all members except the sender + const deliveryPromises = config.members + .filter((member) => member.name !== fromName) + .map((member) => sendPlainMessage(teamName, fromName, member.name, text, summary, color)); - // Execute deliveries in parallel and wait for all to settle - const results = await Promise.allSettled(deliveryPromises); + // Execute deliveries in parallel and wait for all to settle + const results = await Promise.allSettled(deliveryPromises); - // Log failures for diagnostics - const failures = results.filter((r): r is PromiseRejectedResult => r.status === "rejected"); - if (failures.length > 0) { - console.error(`Broadcast partially failed: ${failures.length} messages could not be delivered.`); - // Optionally log individual errors - failures.forEach((f) => console.error(`- Delivery error:`, f.reason)); - } + // Log failures for diagnostics + const failures = results.filter((r): r is PromiseRejectedResult => r.status === "rejected"); + if (failures.length > 0) { + console.error(`Broadcast partially failed: ${failures.length} messages could not be delivered.`); + // Optionally log individual errors + failures.forEach((f) => console.error(`- Delivery error:`, f.reason)); + } } diff --git a/packages/pi-teams/src/utils/models.ts b/packages/pi-teams/src/utils/models.ts index 2ca9dd93..0791c70a 100644 --- a/packages/pi-teams/src/utils/models.ts +++ b/packages/pi-teams/src/utils/models.ts @@ -1,51 +1,51 @@ export interface Member { - agentId: string; - name: string; - agentType: string; - model?: string; - joinedAt: number; - tmuxPaneId: string; - windowId?: string; - cwd: string; - subscriptions: any[]; - prompt?: string; - color?: string; - thinking?: "off" | "minimal" | "low" | "medium" | "high"; - planModeRequired?: boolean; - backendType?: string; - isActive?: boolean; + agentId: string; + name: string; + agentType: string; + model?: string; + joinedAt: number; + tmuxPaneId: string; + windowId?: string; + cwd: string; + subscriptions: any[]; + prompt?: string; + color?: string; + thinking?: "off" | "minimal" | "low" | "medium" | "high"; + planModeRequired?: boolean; + backendType?: string; + isActive?: boolean; } export interface TeamConfig { - name: string; - description: string; - createdAt: number; - leadAgentId: string; - leadSessionId: string; - members: Member[]; - defaultModel?: string; - separateWindows?: boolean; + name: string; + description: string; + createdAt: number; + leadAgentId: string; + leadSessionId: string; + members: Member[]; + defaultModel?: string; + separateWindows?: boolean; } export interface TaskFile { - id: string; - subject: string; - description: string; - activeForm?: string; - status: "pending" | "planning" | "in_progress" | "completed" | "deleted"; - plan?: string; - planFeedback?: string; - blocks: string[]; - blockedBy: string[]; - owner?: string; - metadata?: Record; + id: string; + subject: string; + description: string; + activeForm?: string; + status: "pending" | "planning" | "in_progress" | "completed" | "deleted"; + plan?: string; + planFeedback?: string; + blocks: string[]; + blockedBy: string[]; + owner?: string; + metadata?: Record; } export interface InboxMessage { - from: string; - text: string; - timestamp: string; - read: boolean; - summary?: string; - color?: string; + from: string; + text: string; + timestamp: string; + read: boolean; + summary?: string; + color?: string; } diff --git a/packages/pi-teams/src/utils/paths.ts b/packages/pi-teams/src/utils/paths.ts index e117c5c8..75ef386d 100644 --- a/packages/pi-teams/src/utils/paths.ts +++ b/packages/pi-teams/src/utils/paths.ts @@ -1,37 +1,37 @@ +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import fs from "node:fs"; export const PI_DIR = path.join(os.homedir(), ".pi"); export const TEAMS_DIR = path.join(PI_DIR, "teams"); export const TASKS_DIR = path.join(PI_DIR, "tasks"); export function ensureDirs() { - if (!fs.existsSync(PI_DIR)) fs.mkdirSync(PI_DIR); - if (!fs.existsSync(TEAMS_DIR)) fs.mkdirSync(TEAMS_DIR); - if (!fs.existsSync(TASKS_DIR)) fs.mkdirSync(TASKS_DIR); + if (!fs.existsSync(PI_DIR)) fs.mkdirSync(PI_DIR); + if (!fs.existsSync(TEAMS_DIR)) fs.mkdirSync(TEAMS_DIR); + if (!fs.existsSync(TASKS_DIR)) fs.mkdirSync(TASKS_DIR); } export function sanitizeName(name: string): string { - // Allow only alphanumeric characters, hyphens, and underscores. - if (/[^a-zA-Z0-9_-]/.test(name)) { - throw new Error(`Invalid name: "${name}". Only alphanumeric characters, hyphens, and underscores are allowed.`); - } - return name; + // Allow only alphanumeric characters, hyphens, and underscores. + if (/[^a-zA-Z0-9_-]/.test(name)) { + throw new Error(`Invalid name: "${name}". Only alphanumeric characters, hyphens, and underscores are allowed.`); + } + return name; } export function teamDir(teamName: string) { - return path.join(TEAMS_DIR, sanitizeName(teamName)); + return path.join(TEAMS_DIR, sanitizeName(teamName)); } 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) { - return path.join(teamDir(teamName), "inboxes", `${sanitizeName(agentName)}.json`); + return path.join(teamDir(teamName), "inboxes", `${sanitizeName(agentName)}.json`); } export function configPath(teamName: string) { - return path.join(teamDir(teamName), "config.json"); + return path.join(teamDir(teamName), "config.json"); } diff --git a/packages/pi-teams/src/utils/security.test.ts b/packages/pi-teams/src/utils/security.test.ts index 3f46d978..ba3b3b9d 100644 --- a/packages/pi-teams/src/utils/security.test.ts +++ b/packages/pi-teams/src/utils/security.test.ts @@ -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 { 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)", () => { - it("should throw an error for path traversal via teamName", () => { - const maliciousTeamName = "../../etc"; - expect(() => teamDir(maliciousTeamName)).toThrow(); - }); + it("should throw an error for path traversal via teamName", () => { + const maliciousTeamName = "../../etc"; + expect(() => teamDir(maliciousTeamName)).toThrow(); + }); - it("should throw an error for path traversal via agentName", () => { - const teamName = "audit-team"; - const maliciousAgentName = "../../../.ssh/id_rsa"; - expect(() => inboxPath(teamName, maliciousAgentName)).toThrow(); - }); + it("should throw an error for path traversal via agentName", () => { + const teamName = "audit-team"; + const maliciousAgentName = "../../../.ssh/id_rsa"; + expect(() => inboxPath(teamName, maliciousAgentName)).toThrow(); + }); - it("should throw an error for path traversal via taskId", () => { - const teamName = "audit-team"; - const maliciousTaskId = "../../../etc/passwd"; - // We need to import readTask/updateTask or just sanitizeName directly if we want to test the logic - // But since we already tested sanitizeName via other paths, this is just for completeness. - expect(() => sanitizeName(maliciousTaskId)).toThrow(); - }); + it("should throw an error for path traversal via taskId", () => { + const teamName = "audit-team"; + const maliciousTaskId = "../../../etc/passwd"; + // We need to import readTask/updateTask or just sanitizeName directly if we want to test the logic + // But since we already tested sanitizeName via other paths, this is just for completeness. + expect(() => sanitizeName(maliciousTaskId)).toThrow(); + }); }); describe("Security Audit - Command Injection (Fixed)", () => { - it("should not be vulnerable to command injection in spawn_teammate (via parameters)", () => { - const maliciousCwd = "; rm -rf / ;"; - const name = "attacker"; - const team_name = "audit-team"; - const piBinary = "pi"; - const cmd = `PI_TEAM_NAME=${team_name} PI_AGENT_NAME=${name} ${piBinary}`; - - // Simulating what happens in spawn_teammate (extensions/index.ts) - const itermCmd = `cd '${maliciousCwd}' && ${cmd}`; - - // The command becomes: cd '; rm -rf / ;' && PI_TEAM_NAME=audit-team PI_AGENT_NAME=attacker pi - expect(itermCmd).toContain("cd '; rm -rf / ;' &&"); - expect(itermCmd).not.toContain("cd ; rm -rf / ; &&"); - }); + it("should not be vulnerable to command injection in spawn_teammate (via parameters)", () => { + const maliciousCwd = "; rm -rf / ;"; + const name = "attacker"; + const team_name = "audit-team"; + const piBinary = "pi"; + const cmd = `PI_TEAM_NAME=${team_name} PI_AGENT_NAME=${name} ${piBinary}`; + + // Simulating what happens in spawn_teammate (extensions/index.ts) + const itermCmd = `cd '${maliciousCwd}' && ${cmd}`; + + // The command becomes: cd '; rm -rf / ;' && PI_TEAM_NAME=audit-team PI_AGENT_NAME=attacker pi + expect(itermCmd).toContain("cd '; rm -rf / ;' &&"); + expect(itermCmd).not.toContain("cd ; rm -rf / ; &&"); + }); }); diff --git a/packages/pi-teams/src/utils/tasks.race.test.ts b/packages/pi-teams/src/utils/tasks.race.test.ts index c2e06379..ade9138c 100644 --- a/packages/pi-teams/src/utils/tasks.race.test.ts +++ b/packages/pi-teams/src/utils/tasks.race.test.ts @@ -1,44 +1,44 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import fs from "node:fs"; -import path from "node:path"; 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 { createTask, listTasks } from "./tasks"; const testDir = path.join(os.tmpdir(), "pi-tasks-race-test-" + Date.now()); describe("Tasks Race Condition Bug", () => { - beforeEach(() => { - if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true }); - fs.mkdirSync(testDir, { recursive: true }); - - vi.spyOn(paths, "taskDir").mockReturnValue(testDir); - vi.spyOn(paths, "configPath").mockReturnValue(path.join(testDir, "config.json")); - fs.writeFileSync(path.join(testDir, "config.json"), JSON.stringify({ name: "test-team" })); - }); + beforeEach(() => { + if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true }); + fs.mkdirSync(testDir, { recursive: true }); - afterEach(() => { - vi.restoreAllMocks(); - if (fs.existsSync(testDir)) fs.rmSync(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" })); + }); - it("should potentially fail to create unique IDs under high concurrency (Demonstrating Bug 1)", async () => { - const numTasks = 20; - const promises = []; - - for (let i = 0; i < numTasks; i++) { - promises.push(createTask("test-team", `Task ${i}`, `Desc ${i}`)); - } - - const results = await Promise.all(promises); - const ids = results.map(r => r.id); - const uniqueIds = new Set(ids); - - // If Bug 1 exists (getTaskId outside the lock but actually it is inside the lock in createTask), - // this test might still pass because createTask locks the directory. - // WAIT: I noticed createTask uses withLock(lockPath, ...) where lockPath = dir. - // Let's re-verify createTask in src/utils/tasks.ts - - expect(uniqueIds.size).toBe(numTasks); - }); + afterEach(() => { + vi.restoreAllMocks(); + if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true }); + }); + + it("should potentially fail to create unique IDs under high concurrency (Demonstrating Bug 1)", async () => { + const numTasks = 20; + const promises = []; + + for (let i = 0; i < numTasks; i++) { + promises.push(createTask("test-team", `Task ${i}`, `Desc ${i}`)); + } + + const results = await Promise.all(promises); + const ids = results.map((r) => r.id); + const uniqueIds = new Set(ids); + + // If Bug 1 exists (getTaskId outside the lock but actually it is inside the lock in createTask), + // this test might still pass because createTask locks the directory. + // WAIT: I noticed createTask uses withLock(lockPath, ...) where lockPath = dir. + // Let's re-verify createTask in src/utils/tasks.ts + + expect(uniqueIds.size).toBe(numTasks); + }); }); diff --git a/packages/pi-teams/src/utils/tasks.test.ts b/packages/pi-teams/src/utils/tasks.test.ts index 8809f47d..246ffe26 100644 --- a/packages/pi-teams/src/utils/tasks.test.ts +++ b/packages/pi-teams/src/utils/tasks.test.ts @@ -1,142 +1,151 @@ // Project: pi-teams -import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; + import fs from "node:fs"; -import path from "node:path"; 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 { createTask, evaluatePlan, listTasks, readTask, submitPlan, updateTask } from "./tasks"; import * as teams from "./teams"; // Mock the paths to use a temporary directory const testDir = path.join(os.tmpdir(), "pi-teams-test-" + Date.now()); describe("Tasks Utilities", () => { - beforeEach(() => { - if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true }); - fs.mkdirSync(testDir, { recursive: true }); - - // Override paths to use testDir - vi.spyOn(paths, "taskDir").mockReturnValue(testDir); - vi.spyOn(paths, "configPath").mockReturnValue(path.join(testDir, "config.json")); - - // Create a dummy team config - fs.writeFileSync(path.join(testDir, "config.json"), JSON.stringify({ name: "test-team" })); - }); + beforeEach(() => { + if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true }); + fs.mkdirSync(testDir, { recursive: true }); - afterEach(() => { - vi.restoreAllMocks(); - if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true }); - }); + // Override paths to use testDir + vi.spyOn(paths, "taskDir").mockReturnValue(testDir); + vi.spyOn(paths, "configPath").mockReturnValue(path.join(testDir, "config.json")); - it("should create a task successfully", async () => { - const task = await createTask("test-team", "Test Subject", "Test Description"); - expect(task.id).toBe("1"); - expect(task.subject).toBe("Test Subject"); - expect(fs.existsSync(path.join(testDir, "1.json"))).toBe(true); - }); + // Create a dummy team config + fs.writeFileSync(path.join(testDir, "config.json"), JSON.stringify({ name: "test-team" })); + }); - it("should update a task successfully", async () => { - await createTask("test-team", "Test Subject", "Test Description"); - const updated = await updateTask("test-team", "1", { status: "in_progress" }); - expect(updated.status).toBe("in_progress"); - - const taskData = JSON.parse(fs.readFileSync(path.join(testDir, "1.json"), "utf-8")); - expect(taskData.status).toBe("in_progress"); - }); + afterEach(() => { + vi.restoreAllMocks(); + if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true }); + }); - it("should submit a plan successfully", async () => { - const task = await createTask("test-team", "Test Subject", "Test Description"); - const plan = "Step 1: Do something\nStep 2: Profit"; - const updated = await submitPlan("test-team", task.id, plan); - expect(updated.status).toBe("planning"); - expect(updated.plan).toBe(plan); - - const taskData = JSON.parse(fs.readFileSync(path.join(testDir, `${task.id}.json`), "utf-8")); - expect(taskData.status).toBe("planning"); - expect(taskData.plan).toBe(plan); - }); + it("should create a task successfully", async () => { + const task = await createTask("test-team", "Test Subject", "Test Description"); + expect(task.id).toBe("1"); + expect(task.subject).toBe("Test Subject"); + expect(fs.existsSync(path.join(testDir, "1.json"))).toBe(true); + }); - it("should fail to submit an empty plan", async () => { - const task = await createTask("test-team", "Empty Test", "Should fail"); - await expect(submitPlan("test-team", task.id, "")).rejects.toThrow("Plan must not be empty"); - await expect(submitPlan("test-team", task.id, " ")).rejects.toThrow("Plan must not be empty"); - }); + it("should update a task successfully", async () => { + await createTask("test-team", "Test Subject", "Test Description"); + const updated = await updateTask("test-team", "1", { status: "in_progress" }); + expect(updated.status).toBe("in_progress"); - it("should list tasks", async () => { - await createTask("test-team", "Task 1", "Desc 1"); - await createTask("test-team", "Task 2", "Desc 2"); - const tasksList = await listTasks("test-team"); - expect(tasksList.length).toBe(2); - expect(tasksList[0].id).toBe("1"); - expect(tasksList[1].id).toBe("2"); - }); + const taskData = JSON.parse(fs.readFileSync(path.join(testDir, "1.json"), "utf-8")); + expect(taskData.status).toBe("in_progress"); + }); - it("should have consistent lock paths (Fixed BUG 2)", async () => { - // This test verifies that both updateTask and readTask now use the same lock path - // Both should now lock `${taskId}.json.lock` - - await createTask("test-team", "Bug Test", "Testing lock consistency"); - const taskId = "1"; - - const taskFile = path.join(testDir, `${taskId}.json`); - const commonLockFile = `${taskFile}.lock`; - - // 1. Holding the common lock - fs.writeFileSync(commonLockFile, "9999"); - - // 2. Try updateTask, it should fail - // Using small retries to speed up the test and avoid fake timer issues with native setTimeout - await expect(updateTask("test-team", taskId, { status: "in_progress" }, 2)).rejects.toThrow("Could not acquire lock"); + it("should submit a plan successfully", async () => { + const task = await createTask("test-team", "Test Subject", "Test Description"); + const plan = "Step 1: Do something\nStep 2: Profit"; + const updated = await submitPlan("test-team", task.id, plan); + expect(updated.status).toBe("planning"); + expect(updated.plan).toBe(plan); - // 3. Try readTask, it should fail too - await expect(readTask("test-team", taskId, 2)).rejects.toThrow("Could not acquire lock"); - - fs.unlinkSync(commonLockFile); - }); + 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 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 fail to submit an empty plan", async () => { + const task = await createTask("test-team", "Empty Test", "Should fail"); + await expect(submitPlan("test-team", task.id, "")).rejects.toThrow("Plan must not be empty"); + await expect(submitPlan("test-team", task.id, " ")).rejects.toThrow("Plan must not be empty"); + }); - it("should 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 list tasks", async () => { + await createTask("test-team", "Task 1", "Desc 1"); + await createTask("test-team", "Task 2", "Desc 2"); + const tasksList = await listTasks("test-team"); + expect(tasksList.length).toBe(2); + expect(tasksList[0].id).toBe("1"); + expect(tasksList[1].id).toBe("2"); + }); - it("should 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 have consistent lock paths (Fixed BUG 2)", async () => { + // This test verifies that both updateTask and readTask now use the same lock path + // Both should now lock `${taskId}.json.lock` - 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"); - }); + await createTask("test-team", "Bug Test", "Testing lock consistency"); + const taskId = "1"; - 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"); - }); + const taskFile = path.join(testDir, `${taskId}.json`); + const commonLockFile = `${taskFile}.lock`; - 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"/); - }); + // 1. Holding the common lock + fs.writeFileSync(commonLockFile, "9999"); + + // 2. Try updateTask, it should fail + // Using small retries to speed up the test and avoid fake timer issues with native setTimeout + await expect(updateTask("test-team", taskId, { status: "in_progress" }, 2)).rejects.toThrow( + "Could not acquire lock", + ); + + // 3. Try readTask, it should fail too + await expect(readTask("test-team", taskId, 2)).rejects.toThrow("Could not acquire lock"); + + fs.unlinkSync(commonLockFile); + }); + + it("should approve a plan successfully", async () => { + const task = await createTask("test-team", "Plan Test", "Should be approved"); + await submitPlan("test-team", task.id, "Wait for it..."); + + const approved = await evaluatePlan("test-team", task.id, "approve"); + expect(approved.status).toBe("in_progress"); + expect(approved.planFeedback).toBe(""); + }); + + it("should reject a plan with feedback", async () => { + const task = await createTask("test-team", "Plan Test", "Should be rejected"); + await submitPlan("test-team", task.id, "Wait for it..."); + + const feedback = "Not good enough!"; + const rejected = await evaluatePlan("test-team", task.id, "reject", feedback); + expect(rejected.status).toBe("planning"); + expect(rejected.planFeedback).toBe(feedback); + }); + + it("should fail to evaluate a task not in 'planning' status", async () => { + const task = await createTask("test-team", "Status Test", "Invalid status for eval"); + // status is "pending" + await expect(evaluatePlan("test-team", task.id, "approve")).rejects.toThrow("must be in 'planning' status"); + }); + + it("should fail to evaluate a task without a plan", async () => { + const task = await createTask("test-team", "Plan Missing Test", "No plan submitted"); + await updateTask("test-team", task.id, { status: "planning" }); // bypass submitPlan to have no plan + await expect(evaluatePlan("test-team", task.id, "approve")).rejects.toThrow("no plan has been submitted"); + }); + + it("should fail to reject a plan without feedback", async () => { + const task = await createTask("test-team", "Feedback Test", "Should require feedback"); + await submitPlan("test-team", task.id, "My plan"); + await expect(evaluatePlan("test-team", task.id, "reject")).rejects.toThrow( + "Feedback is required when rejecting a plan", + ); + await expect(evaluatePlan("test-team", task.id, "reject", " ")).rejects.toThrow( + "Feedback is required when rejecting a plan", + ); + }); + + it("should sanitize task IDs in all file operations", async () => { + const dirtyId = "../evil-id"; + // sanitizeName should throw on this dirtyId + await expect(readTask("test-team", dirtyId)).rejects.toThrow(/Invalid name: "..\/evil-id"/); + await expect(updateTask("test-team", dirtyId, { status: "in_progress" })).rejects.toThrow( + /Invalid name: "..\/evil-id"/, + ); + await expect(evaluatePlan("test-team", dirtyId, "approve")).rejects.toThrow(/Invalid name: "..\/evil-id"/); + }); }); diff --git a/packages/pi-teams/src/utils/tasks.ts b/packages/pi-teams/src/utils/tasks.ts index 122eed85..6ef5ccc6 100644 --- a/packages/pi-teams/src/utils/tasks.ts +++ b/packages/pi-teams/src/utils/tasks.ts @@ -1,81 +1,85 @@ // Project: pi-teams import fs from "node:fs"; 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 { withLock } from "./lock"; +import type { TaskFile } from "./models"; +import { sanitizeName, taskDir } from "./paths"; +import { teamExists } from "./teams"; export function getTaskId(teamName: string): string { - const dir = taskDir(teamName); - const files = fs.readdirSync(dir).filter(f => f.endsWith(".json")); - const ids = files.map(f => parseInt(path.parse(f).name, 10)).filter(id => !isNaN(id)); - return ids.length > 0 ? (Math.max(...ids) + 1).toString() : "1"; + const dir = taskDir(teamName); + const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json")); + const ids = files.map((f) => parseInt(path.parse(f).name, 10)).filter((id) => !isNaN(id)); + return ids.length > 0 ? (Math.max(...ids) + 1).toString() : "1"; } function getTaskPath(teamName: string, taskId: string): string { - const dir = taskDir(teamName); - const safeTaskId = sanitizeName(taskId); - return path.join(dir, `${safeTaskId}.json`); + const dir = taskDir(teamName); + const safeTaskId = sanitizeName(taskId); + return path.join(dir, `${safeTaskId}.json`); } export async function createTask( - teamName: string, - subject: string, - description: string, - activeForm = "", - metadata?: Record + teamName: string, + subject: string, + description: string, + activeForm = "", + metadata?: Record, ): Promise { - 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 (!subject || !subject.trim()) throw new Error("Task subject must not be empty"); + if (!teamExists(teamName)) throw new Error(`Team ${teamName} does not exist`); - const dir = taskDir(teamName); - const lockPath = dir; + const dir = taskDir(teamName); + const lockPath = dir; - return await withLock(lockPath, async () => { - const id = getTaskId(teamName); - const task: TaskFile = { - id, - subject, - description, - activeForm, - status: "pending", - blocks: [], - blockedBy: [], - metadata, - }; - fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(task, null, 2)); - return task; - }); + return await withLock(lockPath, async () => { + const id = getTaskId(teamName); + const task: TaskFile = { + id, + subject, + description, + activeForm, + status: "pending", + blocks: [], + blockedBy: [], + metadata, + }; + fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(task, null, 2)); + return task; + }); } export async function updateTask( - teamName: string, - taskId: string, - updates: Partial, - retries?: number + teamName: string, + taskId: string, + updates: Partial, + retries?: number, ): Promise { - const p = getTaskPath(teamName, taskId); + const p = getTaskPath(teamName, taskId); - return await withLock(p, async () => { - if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`); - const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8")); - const updated = { ...task, ...updates }; + return await withLock( + p, + async () => { + if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`); + const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8")); + const updated = { ...task, ...updates }; - if (updates.status === "deleted") { - fs.unlinkSync(p); - return updated; - } + if (updates.status === "deleted") { + fs.unlinkSync(p); + return updated; + } - fs.writeFileSync(p, JSON.stringify(updated, null, 2)); + fs.writeFileSync(p, JSON.stringify(updated, null, 2)); - if (updates.status === "completed") { - await runHook(teamName, "task_completed", updated); - } + if (updates.status === "completed") { + await runHook(teamName, "task_completed", updated); + } - return updated; - }, retries); + return updated; + }, + retries, + ); } /** @@ -86,8 +90,8 @@ export async function updateTask( * @returns The updated task */ export async function submitPlan(teamName: string, taskId: string, plan: string): Promise { - if (!plan || !plan.trim()) throw new Error("Plan must not be empty"); - return await updateTask(teamName, taskId, { status: "planning", plan }); + if (!plan || !plan.trim()) throw new Error("Plan must not be empty"); + 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 */ export async function evaluatePlan( - teamName: string, - taskId: string, - action: "approve" | "reject", - feedback?: string, - retries?: number + teamName: string, + taskId: string, + action: "approve" | "reject", + feedback?: string, + retries?: number, ): Promise { - const p = getTaskPath(teamName, taskId); + const p = getTaskPath(teamName, taskId); - return await withLock(p, async () => { - if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`); - const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8")); + return await withLock( + p, + async () => { + if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`); + const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8")); - // 1. Validate state: Only "planning" tasks can be evaluated - if (task.status !== "planning") { - throw new Error( - `Cannot evaluate plan for task ${taskId} because its status is '${task.status}'. ` + - `Tasks must be in 'planning' status to be evaluated.` - ); - } + // 1. Validate state: Only "planning" tasks can be evaluated + if (task.status !== "planning") { + throw new Error( + `Cannot evaluate plan for task ${taskId} because its status is '${task.status}'. ` + + `Tasks must be in 'planning' status to be evaluated.`, + ); + } - // 2. Validate plan presence - if (!task.plan || !task.plan.trim()) { - throw new Error(`Cannot evaluate plan for task ${taskId} because no plan has been submitted.`); - } + // 2. Validate plan presence + if (!task.plan || !task.plan.trim()) { + throw new Error(`Cannot evaluate plan for task ${taskId} because no plan has been submitted.`); + } - // 3. Require feedback for rejections - if (action === "reject" && (!feedback || !feedback.trim())) { - throw new Error("Feedback is required when rejecting a plan."); - } + // 3. Require feedback for rejections + if (action === "reject" && (!feedback || !feedback.trim())) { + throw new Error("Feedback is required when rejecting a plan."); + } - // 4. Perform update - const updates: Partial = action === "approve" - ? { status: "in_progress", planFeedback: "" } - : { status: "planning", planFeedback: feedback }; + // 4. Perform update + const updates: Partial = + action === "approve" + ? { status: "in_progress", planFeedback: "" } + : { status: "planning", planFeedback: feedback }; - const updated = { ...task, ...updates }; - fs.writeFileSync(p, JSON.stringify(updated, null, 2)); - return updated; - }, retries); + const updated = { ...task, ...updates }; + fs.writeFileSync(p, JSON.stringify(updated, null, 2)); + return updated; + }, + retries, + ); } export async function readTask(teamName: string, taskId: string, retries?: number): Promise { - const p = getTaskPath(teamName, taskId); - if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`); - return await withLock(p, async () => { - return JSON.parse(fs.readFileSync(p, "utf-8")); - }, retries); + const p = getTaskPath(teamName, taskId); + if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`); + return await withLock( + p, + async () => { + return JSON.parse(fs.readFileSync(p, "utf-8")); + }, + retries, + ); } export async function listTasks(teamName: string): Promise { - const dir = taskDir(teamName); - return await withLock(dir, async () => { - const files = fs.readdirSync(dir).filter(f => f.endsWith(".json")); - const tasks: TaskFile[] = files - .map(f => { - const id = parseInt(path.parse(f).name, 10); - if (isNaN(id)) return null; - return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8")); - }) - .filter(t => t !== null); - return tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10)); - }); + const dir = taskDir(teamName); + return await withLock(dir, async () => { + const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json")); + const tasks: TaskFile[] = files + .map((f) => { + const id = parseInt(path.parse(f).name, 10); + if (isNaN(id)) return null; + return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8")); + }) + .filter((t) => t !== null); + return tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10)); + }); } export async function resetOwnerTasks(teamName: string, agentName: string) { - const dir = taskDir(teamName); - const lockPath = dir; + const dir = taskDir(teamName); + const lockPath = dir; - await withLock(lockPath, async () => { - const files = fs.readdirSync(dir).filter(f => f.endsWith(".json")); - for (const f of files) { - const p = path.join(dir, f); - const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8")); - if (task.owner === agentName) { - task.owner = undefined; - if (task.status !== "completed") { - task.status = "pending"; - } - fs.writeFileSync(p, JSON.stringify(task, null, 2)); - } - } - }); + await withLock(lockPath, async () => { + const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json")); + for (const f of files) { + const p = path.join(dir, f); + const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8")); + if (task.owner === agentName) { + task.owner = undefined; + if (task.status !== "completed") { + task.status = "pending"; + } + fs.writeFileSync(p, JSON.stringify(task, null, 2)); + } + } + }); } diff --git a/packages/pi-teams/src/utils/teams.ts b/packages/pi-teams/src/utils/teams.ts index fa54d484..500df6a2 100644 --- a/packages/pi-teams/src/utils/teams.ts +++ b/packages/pi-teams/src/utils/teams.ts @@ -1,90 +1,90 @@ import fs from "node:fs"; import path from "node:path"; -import { TeamConfig, Member } from "./models"; -import { configPath, teamDir, taskDir } from "./paths"; import { withLock } from "./lock"; +import type { Member, TeamConfig } from "./models"; +import { configPath, taskDir, teamDir } from "./paths"; export function teamExists(teamName: string) { - return fs.existsSync(configPath(teamName)); + return fs.existsSync(configPath(teamName)); } export function createTeam( - name: string, - sessionId: string, - leadAgentId: string, - description = "", - defaultModel?: string, - separateWindows?: boolean + name: string, + sessionId: string, + leadAgentId: string, + description = "", + defaultModel?: string, + separateWindows?: boolean, ): TeamConfig { - const dir = teamDir(name); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const dir = teamDir(name); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - const tasksDir = taskDir(name); - if (!fs.existsSync(tasksDir)) fs.mkdirSync(tasksDir, { recursive: true }); + const tasksDir = taskDir(name); + if (!fs.existsSync(tasksDir)) fs.mkdirSync(tasksDir, { recursive: true }); - const leadMember: Member = { - agentId: leadAgentId, - name: "team-lead", - agentType: "lead", - joinedAt: Date.now(), - tmuxPaneId: process.env.TMUX_PANE || "", - cwd: process.cwd(), - subscriptions: [], - }; + const leadMember: Member = { + agentId: leadAgentId, + name: "team-lead", + agentType: "lead", + joinedAt: Date.now(), + tmuxPaneId: process.env.TMUX_PANE || "", + cwd: process.cwd(), + subscriptions: [], + }; - const config: TeamConfig = { - name, - description, - createdAt: Date.now(), - leadAgentId, - leadSessionId: sessionId, - members: [leadMember], - defaultModel, - separateWindows, - }; + const config: TeamConfig = { + name, + description, + createdAt: Date.now(), + leadAgentId, + leadSessionId: sessionId, + members: [leadMember], + defaultModel, + separateWindows, + }; - fs.writeFileSync(configPath(name), JSON.stringify(config, null, 2)); - return config; + fs.writeFileSync(configPath(name), JSON.stringify(config, null, 2)); + return config; } 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 { - const p = configPath(teamName); - if (!fs.existsSync(p)) throw new Error(`Team ${teamName} not found`); - return await withLock(p, async () => { - return readConfigRaw(p); - }); + const p = configPath(teamName); + if (!fs.existsSync(p)) throw new Error(`Team ${teamName} not found`); + return await withLock(p, async () => { + return readConfigRaw(p); + }); } export async function addMember(teamName: string, member: Member) { - const p = configPath(teamName); - await withLock(p, async () => { - const config = readConfigRaw(p); - config.members.push(member); - fs.writeFileSync(p, JSON.stringify(config, null, 2)); - }); + const p = configPath(teamName); + await withLock(p, async () => { + const config = readConfigRaw(p); + config.members.push(member); + fs.writeFileSync(p, JSON.stringify(config, null, 2)); + }); } export async function removeMember(teamName: string, agentName: string) { - const p = configPath(teamName); - await withLock(p, async () => { - const config = readConfigRaw(p); - config.members = config.members.filter(m => m.name !== agentName); - fs.writeFileSync(p, JSON.stringify(config, null, 2)); - }); + const p = configPath(teamName); + await withLock(p, async () => { + const config = readConfigRaw(p); + config.members = config.members.filter((m) => m.name !== agentName); + fs.writeFileSync(p, JSON.stringify(config, null, 2)); + }); } export async function updateMember(teamName: string, agentName: string, updates: Partial) { - const p = configPath(teamName); - await withLock(p, async () => { - const config = readConfigRaw(p); - const m = config.members.find(m => m.name === agentName); - if (m) { - Object.assign(m, updates); - fs.writeFileSync(p, JSON.stringify(config, null, 2)); - } - }); + const p = configPath(teamName); + await withLock(p, async () => { + const config = readConfigRaw(p); + const m = config.members.find((m) => m.name === agentName); + if (m) { + Object.assign(m, updates); + fs.writeFileSync(p, JSON.stringify(config, null, 2)); + } + }); } diff --git a/packages/pi-teams/src/utils/terminal-adapter.ts b/packages/pi-teams/src/utils/terminal-adapter.ts index 8d4064f6..557fcbe8 100644 --- a/packages/pi-teams/src/utils/terminal-adapter.ts +++ b/packages/pi-teams/src/utils/terminal-adapter.ts @@ -1,6 +1,6 @@ /** * Terminal Adapter Interface - * + * * Abstracts terminal multiplexer operations (tmux, iTerm2, Zellij) * 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 */ export interface SpawnOptions { - /** Name/identifier for the pane/window */ - name: string; - /** Working directory for the new pane/window */ - cwd: string; - /** Command to execute in the pane/window */ - command: string; - /** Environment variables to set (key-value pairs) */ - env: Record; - /** Team name for window title formatting (e.g., "team: agent") */ - teamName?: string; + /** Name/identifier for the pane/window */ + name: string; + /** Working directory for the new pane/window */ + cwd: string; + /** Command to execute in the pane/window */ + command: string; + /** Environment variables to set (key-value pairs) */ + env: Record; + /** Team name for window title formatting (e.g., "team: agent") */ + teamName?: string; } /** * Terminal Adapter Interface - * + * * Implementations provide terminal-specific logic for pane management. */ export interface TerminalAdapter { - /** Unique name identifier for this terminal type */ - readonly name: string; + /** Unique name identifier for this terminal type */ + readonly name: string; - /** - * Detect if this terminal is currently available/active. - * Should check for terminal-specific environment variables or processes. - * - * @returns true if this terminal should be used - */ - detect(): boolean; + /** + * Detect if this terminal is currently available/active. + * Should check for terminal-specific environment variables or processes. + * + * @returns true if this terminal should be used + */ + detect(): boolean; - /** - * Spawn a new terminal pane with the given options. - * - * @param options - Spawn configuration - * @returns Pane ID that can be used for subsequent operations - * @throws Error if spawn fails - */ - spawn(options: SpawnOptions): string; + /** + * Spawn a new terminal pane with the given options. + * + * @param options - Spawn configuration + * @returns Pane ID that can be used for subsequent operations + * @throws Error if spawn fails + */ + spawn(options: SpawnOptions): string; - /** - * Kill/terminate a terminal pane. - * Should be idempotent - no error if pane doesn't exist. - * - * @param paneId - The pane ID returned from spawn() - */ - kill(paneId: string): void; + /** + * Kill/terminate a terminal pane. + * Should be idempotent - no error if pane doesn't exist. + * + * @param paneId - The pane ID returned from spawn() + */ + kill(paneId: string): void; - /** - * Check if a terminal pane is still alive/active. - * - * @param paneId - The pane ID returned from spawn() - * @returns true if pane exists and is active - */ - isAlive(paneId: string): boolean; + /** + * Check if a terminal pane is still alive/active. + * + * @param paneId - The pane ID returned from spawn() + * @returns true if pane exists and is active + */ + isAlive(paneId: string): boolean; - /** - * Set the title of the current terminal pane/window. - * Used for identifying panes in the terminal UI. - * - * @param title - The title to set - */ - setTitle(title: string): void; + /** + * Set the title of the current terminal pane/window. + * Used for identifying panes in the terminal UI. + * + * @param title - The title to set + */ + setTitle(title: string): void; - /** - * Check if this terminal supports spawning separate OS windows. - * Terminals like tmux and Zellij only support panes/tabs within a session. - * - * @returns true if spawnWindow() is supported - */ - supportsWindows(): boolean; + /** + * Check if this terminal supports spawning separate OS windows. + * Terminals like tmux and Zellij only support panes/tabs within a session. + * + * @returns true if spawnWindow() is supported + */ + supportsWindows(): boolean; - /** - * Spawn a new separate OS window with the given options. - * Only available if supportsWindows() returns true. - * - * @param options - Spawn configuration - * @returns Window ID that can be used for subsequent operations - * @throws Error if spawn fails or not supported - */ - spawnWindow(options: SpawnOptions): string; + /** + * Spawn a new separate OS window with the given options. + * Only available if supportsWindows() returns true. + * + * @param options - Spawn configuration + * @returns Window ID that can be used for subsequent operations + * @throws Error if spawn fails or not supported + */ + spawnWindow(options: SpawnOptions): string; - /** - * Set the title of a specific window. - * Used for identifying windows in the OS window manager. - * - * @param windowId - The window ID returned from spawnWindow() - * @param title - The title to set - */ - setWindowTitle(windowId: string, title: string): void; + /** + * Set the title of a specific window. + * Used for identifying windows in the OS window manager. + * + * @param windowId - The window ID returned from spawnWindow() + * @param title - The title to set + */ + setWindowTitle(windowId: string, title: string): void; - /** - * Kill/terminate a window. - * Should be idempotent - no error if window doesn't exist. - * - * @param windowId - The window ID returned from spawnWindow() - */ - killWindow(windowId: string): void; + /** + * Kill/terminate a window. + * Should be idempotent - no error if window doesn't exist. + * + * @param windowId - The window ID returned from spawnWindow() + */ + killWindow(windowId: string): void; - /** - * Check if a window is still alive/active. - * - * @param windowId - The window ID returned from spawnWindow() - * @returns true if window exists and is active - */ - isWindowAlive(windowId: string): boolean; + /** + * Check if a window is still alive/active. + * + * @param windowId - The window ID returned from spawnWindow() + * @returns true if window exists and is active + */ + isWindowAlive(windowId: string): boolean; } /** * Base helper for adapters to execute commands synchronously. */ -export function execCommand(command: string, args: string[]): { stdout: string; stderr: string; status: number | null } { - const result = spawnSync(command, args, { encoding: "utf-8" }); - return { - stdout: result.stdout?.toString() ?? "", - stderr: result.stderr?.toString() ?? "", - status: result.status, - }; +export function execCommand( + command: string, + args: string[], +): { stdout: string; stderr: string; status: number | null } { + const result = spawnSync(command, args, { encoding: "utf-8" }); + return { + stdout: result.stdout?.toString() ?? "", + stderr: result.stderr?.toString() ?? "", + status: result.status, + }; }