mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 07:04:45 +00:00
Merge pull request #3 from getcompanion-ai/fix/daemon-install-runtime
fix(runtime): keep daemon alive and localize package installs
This commit is contained in:
commit
45cb0d0479
38 changed files with 2051 additions and 1939 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>,
|
|||
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
function buildMetadata(
|
||||
event: { channel?: string; user?: string; ts?: string; thread_ts?: string; channel_type?: string },
|
||||
extra?: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
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<void> }) => {
|
||||
try {
|
||||
await ack();
|
||||
socketClient.on(
|
||||
"app_mention",
|
||||
async ({ event, ack }: { event: SlackMentionEvent; ack: () => Promise<void> }) => {
|
||||
try {
|
||||
await ack();
|
||||
|
||||
if (!isAllowed(event.channel)) return;
|
||||
if (!isAllowed(event.channel)) return;
|
||||
|
||||
const sender = event.thread_ts
|
||||
? `${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<void> }) => {
|
||||
try {
|
||||
if (body.command !== slashCommand) {
|
||||
await ack();
|
||||
return;
|
||||
socketClient.on(
|
||||
"slash_commands",
|
||||
async ({ body, ack }: { body: SlackCommandPayload; ack: (response?: any) => Promise<void> }) => {
|
||||
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<void> }) => {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<BridgeConfig> = {
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<RunResult> {
|
|||
});
|
||||
} 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<RunResult> {
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <UUID>"
|
||||
const splitResult = execCommand("cmux", ["new-split", "right", "--command", fullCommand]);
|
||||
|
||||
if (splitResult.status !== 0) {
|
||||
throw new Error(`cmux new-split failed with status ${splitResult.status}: ${splitResult.stderr}`);
|
||||
}
|
||||
// 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 <UUID>"
|
||||
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 <UUID>"
|
||||
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 <UUID>"
|
||||
const result = execCommand("cmux", ["new-window"]);
|
||||
|
||||
throw new Error(`cmux new-window returned unexpected output: ${output}`);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`cmux new-window failed with status ${result.status}: ${result.stderr}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.spyOn>;
|
||||
let adapter: WezTermAdapter;
|
||||
let mockExecCommand: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new WezTermAdapter();
|
||||
mockExecCommand = vi.spyOn(terminalAdapter, "execCommand");
|
||||
delete process.env.WEZTERM_PANE;
|
||||
delete process.env.TMUX;
|
||||
delete process.env.ZELLIJ;
|
||||
process.env.WEZTERM_PANE = "0";
|
||||
});
|
||||
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"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
const hookPath = path.join(process.cwd(), ".pi", "team-hooks", `${hookName}.sh`);
|
||||
const hookPath = path.join(process.cwd(), ".pi", "team-hooks", `${hookName}.sh`);
|
||||
|
||||
if (!fs.existsSync(hookPath)) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<T>(lockPath: string, fn: () => Promise<T>, retries: number = 50): Promise<T> {
|
||||
const lockFile = `${lockPath}.lock`;
|
||||
|
||||
while (retries > 0) {
|
||||
try {
|
||||
// Check if lock exists and is stale
|
||||
if (fs.existsSync(lockFile)) {
|
||||
const stats = fs.statSync(lockFile);
|
||||
const age = Date.now() - stats.mtimeMs;
|
||||
if (age > STALE_LOCK_TIMEOUT) {
|
||||
// Attempt to remove stale lock
|
||||
try {
|
||||
fs.unlinkSync(lockFile);
|
||||
} catch (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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<InboxMessage[]> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, any>;
|
||||
id: string;
|
||||
subject: string;
|
||||
description: string;
|
||||
activeForm?: string;
|
||||
status: "pending" | "planning" | "in_progress" | "completed" | "deleted";
|
||||
plan?: string;
|
||||
planFeedback?: string;
|
||||
blocks: string[];
|
||||
blockedBy: string[];
|
||||
owner?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface InboxMessage {
|
||||
from: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
read: boolean;
|
||||
summary?: string;
|
||||
color?: string;
|
||||
from: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
read: boolean;
|
||||
summary?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 / ; &&");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, any>
|
||||
teamName: string,
|
||||
subject: string,
|
||||
description: string,
|
||||
activeForm = "",
|
||||
metadata?: Record<string, any>,
|
||||
): Promise<TaskFile> {
|
||||
if (!subject || !subject.trim()) throw new Error("Task subject must not be empty");
|
||||
if (!teamExists(teamName)) throw new Error(`Team ${teamName} does not exist`);
|
||||
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<TaskFile>,
|
||||
retries?: number
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
updates: Partial<TaskFile>,
|
||||
retries?: number,
|
||||
): Promise<TaskFile> {
|
||||
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<TaskFile> {
|
||||
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<TaskFile> {
|
||||
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<TaskFile> = action === "approve"
|
||||
? { status: "in_progress", planFeedback: "" }
|
||||
: { status: "planning", planFeedback: feedback };
|
||||
// 4. Perform update
|
||||
const updates: Partial<TaskFile> =
|
||||
action === "approve"
|
||||
? { status: "in_progress", planFeedback: "" }
|
||||
: { status: "planning", planFeedback: feedback };
|
||||
|
||||
const updated = { ...task, ...updates };
|
||||
fs.writeFileSync(p, JSON.stringify(updated, null, 2));
|
||||
return updated;
|
||||
}, retries);
|
||||
const updated = { ...task, ...updates };
|
||||
fs.writeFileSync(p, JSON.stringify(updated, null, 2));
|
||||
return updated;
|
||||
},
|
||||
retries,
|
||||
);
|
||||
}
|
||||
|
||||
export async function readTask(teamName: string, taskId: string, retries?: number): Promise<TaskFile> {
|
||||
const p = getTaskPath(teamName, taskId);
|
||||
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
|
||||
return await withLock(p, async () => {
|
||||
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
}, retries);
|
||||
const p = getTaskPath(teamName, taskId);
|
||||
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
|
||||
return await withLock(
|
||||
p,
|
||||
async () => {
|
||||
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
},
|
||||
retries,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listTasks(teamName: string): Promise<TaskFile[]> {
|
||||
const dir = taskDir(teamName);
|
||||
return await withLock(dir, async () => {
|
||||
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json"));
|
||||
const tasks: TaskFile[] = files
|
||||
.map(f => {
|
||||
const id = parseInt(path.parse(f).name, 10);
|
||||
if (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));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TeamConfig> {
|
||||
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<Member>) {
|
||||
const p = configPath(teamName);
|
||||
await withLock(p, async () => {
|
||||
const config = readConfigRaw(p);
|
||||
const m = config.members.find(m => m.name === agentName);
|
||||
if (m) {
|
||||
Object.assign(m, updates);
|
||||
fs.writeFileSync(p, JSON.stringify(config, null, 2));
|
||||
}
|
||||
});
|
||||
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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string>;
|
||||
/** 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<string, string>;
|
||||
/** Team name for window title formatting (e.g., "team: agent") */
|
||||
teamName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal Adapter Interface
|
||||
*
|
||||
*
|
||||
* Implementations provide terminal-specific logic for pane management.
|
||||
*/
|
||||
export interface TerminalAdapter {
|
||||
/** Unique name identifier for this terminal type */
|
||||
readonly name: string;
|
||||
/** 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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue