fix(runtime): keep daemon alive and localize package installs

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Harivansh Rathi 2026-03-05 17:36:25 -08:00
parent fa208bca73
commit 3f04822f58
38 changed files with 2051 additions and 1939 deletions

View file

@ -32,7 +32,7 @@ fi
if [[ "${CO_MONO_SKIP_BUILD:-0}" != "1" ]]; then if [[ "${CO_MONO_SKIP_BUILD:-0}" != "1" ]]; then
log "Building core packages" log "Building core packages"
BUILD_FAILED=0 BUILD_FAILED=0
for pkg in packages/agent packages/ai packages/tui packages/coding-agent; do for pkg in packages/tui packages/ai packages/agent packages/coding-agent; do
if ! npm run build --workspace "$pkg"; then if ! npm run build --workspace "$pkg"; then
BUILD_FAILED=1 BUILD_FAILED=1
echo "WARN: build failed for $pkg; falling back to source launch mode." echo "WARN: build failed for $pkg; falling back to source launch mode."

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,7 @@
* Implements the TerminalAdapter interface for CMUX (cmux.dev). * Implements the TerminalAdapter interface for CMUX (cmux.dev).
*/ */
import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter"; import { execCommand, type SpawnOptions, type TerminalAdapter } from "../utils/terminal-adapter";
export class CmuxAdapter implements TerminalAdapter { export class CmuxAdapter implements TerminalAdapter {
readonly name = "cmux"; readonly name = "cmux";

View file

@ -5,8 +5,8 @@
* Uses AppleScript for all operations. * Uses AppleScript for all operations.
*/ */
import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { execCommand, type SpawnOptions, type TerminalAdapter } from "../utils/terminal-adapter";
/** /**
* Context needed for iTerm2 spawning (tracks last pane for layout) * Context needed for iTerm2 spawning (tracks last pane for layout)
@ -175,9 +175,7 @@ end tell`;
const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`; const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`;
const escapedCmd = itermCmd.replace(/"/g, '\\"'); const escapedCmd = itermCmd.replace(/"/g, '\\"');
const windowTitle = options.teamName const windowTitle = options.teamName ? `${options.teamName}: ${options.name}` : options.name;
? `${options.teamName}: ${options.name}`
: options.name;
const escapedTitle = windowTitle.replace(/"/g, '\\"'); const escapedTitle = windowTitle.replace(/"/g, '\\"');

View file

@ -5,12 +5,12 @@
* the current environment. * the current environment.
*/ */
import { TerminalAdapter } from "../utils/terminal-adapter"; import type { TerminalAdapter } from "../utils/terminal-adapter";
import { TmuxAdapter } from "./tmux-adapter";
import { Iterm2Adapter } from "./iterm2-adapter";
import { ZellijAdapter } from "./zellij-adapter";
import { WezTermAdapter } from "./wezterm-adapter";
import { CmuxAdapter } from "./cmux-adapter"; import { CmuxAdapter } from "./cmux-adapter";
import { Iterm2Adapter } from "./iterm2-adapter";
import { TmuxAdapter } from "./tmux-adapter";
import { WezTermAdapter } from "./wezterm-adapter";
import { ZellijAdapter } from "./zellij-adapter";
/** /**
* Available terminal adapters, ordered by priority * Available terminal adapters, ordered by priority
@ -68,7 +68,7 @@ export function getTerminalAdapter(): TerminalAdapter | null {
* @returns The adapter instance, or undefined if not found * @returns The adapter instance, or undefined if not found
*/ */
export function getAdapterByName(name: string): TerminalAdapter | undefined { export function getAdapterByName(name: string): TerminalAdapter | undefined {
return adapters.find(a => a.name === name); return adapters.find((a) => a.name === name);
} }
/** /**

View file

@ -5,7 +5,7 @@
*/ */
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter"; import { execCommand, type SpawnOptions, type TerminalAdapter } from "../utils/terminal-adapter";
export class TmuxAdapter implements TerminalAdapter { export class TmuxAdapter implements TerminalAdapter {
readonly name = "tmux"; readonly name = "tmux";
@ -22,11 +22,17 @@ export class TmuxAdapter implements TerminalAdapter {
const tmuxArgs = [ const tmuxArgs = [
"split-window", "split-window",
"-h", "-dP", "-h",
"-F", "#{pane_id}", "-dP",
"-c", options.cwd, "-F",
"env", ...envArgs, "#{pane_id}",
"sh", "-c", options.command "-c",
options.cwd,
"env",
...envArgs,
"sh",
"-c",
options.command,
]; ];
const result = execCommand("tmux", tmuxArgs); const result = execCommand("tmux", tmuxArgs);

View file

@ -2,9 +2,9 @@
* WezTerm Adapter Tests * WezTerm Adapter Tests
*/ */
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WezTermAdapter } from "./wezterm-adapter";
import * as terminalAdapter from "../utils/terminal-adapter"; import * as terminalAdapter from "../utils/terminal-adapter";
import { WezTermAdapter } from "./wezterm-adapter";
describe("WezTermAdapter", () => { describe("WezTermAdapter", () => {
let adapter: WezTermAdapter; let adapter: WezTermAdapter;
@ -44,7 +44,7 @@ describe("WezTermAdapter", () => {
return { return {
stdout: JSON.stringify([{ pane_id: 0, tab_id: 0 }]), stdout: JSON.stringify([{ pane_id: 0, tab_id: 0 }]),
stderr: "", stderr: "",
status: 0 status: 0,
}; };
} }
if (args.includes("split-pane")) { if (args.includes("split-pane")) {
@ -63,7 +63,7 @@ describe("WezTermAdapter", () => {
expect(result).toBe("wezterm_1"); expect(result).toBe("wezterm_1");
expect(mockExecCommand).toHaveBeenCalledWith( expect(mockExecCommand).toHaveBeenCalledWith(
expect.stringContaining("wezterm"), expect.stringContaining("wezterm"),
expect.arrayContaining(["cli", "split-pane", "--right", "--percent", "50"]) expect.arrayContaining(["cli", "split-pane", "--right", "--percent", "50"]),
); );
}); });
@ -72,9 +72,12 @@ describe("WezTermAdapter", () => {
mockExecCommand.mockImplementation((bin, args) => { mockExecCommand.mockImplementation((bin, args) => {
if (args.includes("list")) { if (args.includes("list")) {
return { return {
stdout: JSON.stringify([{ pane_id: 0, tab_id: 0 }, { pane_id: 1, tab_id: 0 }]), stdout: JSON.stringify([
{ pane_id: 0, tab_id: 0 },
{ pane_id: 1, tab_id: 0 },
]),
stderr: "", stderr: "",
status: 0 status: 0,
}; };
} }
if (args.includes("split-pane")) { if (args.includes("split-pane")) {
@ -94,7 +97,7 @@ describe("WezTermAdapter", () => {
// 1 sidebar pane already exists, so percent should be floor(100/(1+1)) = 50% // 1 sidebar pane already exists, so percent should be floor(100/(1+1)) = 50%
expect(mockExecCommand).toHaveBeenCalledWith( expect(mockExecCommand).toHaveBeenCalledWith(
expect.stringContaining("wezterm"), expect.stringContaining("wezterm"),
expect.arrayContaining(["cli", "split-pane", "--bottom", "--pane-id", "1", "--percent", "50"]) expect.arrayContaining(["cli", "split-pane", "--bottom", "--pane-id", "1", "--percent", "50"]),
); );
}); });
}); });

View file

@ -5,7 +5,7 @@
* Uses wezterm cli split-pane for pane management. * Uses wezterm cli split-pane for pane management.
*/ */
import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter"; import { execCommand, type SpawnOptions, type TerminalAdapter } from "../utils/terminal-adapter";
export class WezTermAdapter implements TerminalAdapter { export class WezTermAdapter implements TerminalAdapter {
readonly name = "WezTerm"; readonly name = "WezTerm";
@ -91,17 +91,26 @@ export class WezTermAdapter implements TerminalAdapter {
if (isFirstPane) { if (isFirstPane) {
weztermArgs = [ weztermArgs = [
"cli", "split-pane", "--right", "--percent", "50", "cli",
"--cwd", options.cwd, "--", "env", ...envArgs, "sh", "-c", options.command "split-pane",
"--right",
"--percent",
"50",
"--cwd",
options.cwd,
"--",
"env",
...envArgs,
"sh",
"-c",
options.command,
]; ];
} else { } else {
// Subsequent teammates stack in the sidebar on the right. // Subsequent teammates stack in the sidebar on the right.
// currentPaneId (id 0) is the main pane on the left. // currentPaneId (id 0) is the main pane on the left.
// All other panes are in the sidebar. // All other panes are in the sidebar.
const currentPaneId = parseInt(process.env.WEZTERM_PANE || "0", 10); const currentPaneId = parseInt(process.env.WEZTERM_PANE || "0", 10);
const sidebarPanes = panes 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)
.filter(p => p.pane_id !== currentPaneId)
.sort((a, b) => b.cursor_y - a.cursor_y); // Sort by vertical position (bottom-most first)
// To add a new pane to the bottom of the sidebar stack: // To add a new pane to the bottom of the sidebar stack:
// We always split the BOTTOM-MOST pane (sidebarPanes[0]) // We always split the BOTTOM-MOST pane (sidebarPanes[0])
@ -110,9 +119,21 @@ export class WezTermAdapter implements TerminalAdapter {
const targetPane = sidebarPanes[0]; const targetPane = sidebarPanes[0];
weztermArgs = [ weztermArgs = [
"cli", "split-pane", "--bottom", "--pane-id", targetPane.pane_id.toString(), "cli",
"--percent", "50", "split-pane",
"--cwd", options.cwd, "--", "env", ...envArgs, "sh", "-c", options.command "--bottom",
"--pane-id",
targetPane.pane_id.toString(),
"--percent",
"50",
"--cwd",
options.cwd,
"--",
"env",
...envArgs,
"sh",
"-c",
options.command,
]; ];
} }
@ -153,7 +174,7 @@ export class WezTermAdapter implements TerminalAdapter {
const weztermId = parseInt(paneId.replace("wezterm_", ""), 10); const weztermId = parseInt(paneId.replace("wezterm_", ""), 10);
const panes = this.getPanes(); const panes = this.getPanes();
return panes.some(p => p.pane_id === weztermId); return panes.some((p) => p.pane_id === weztermId);
} }
setTitle(title: string): void { setTitle(title: string): void {
@ -186,15 +207,21 @@ export class WezTermAdapter implements TerminalAdapter {
.map(([k, v]) => `${k}=${v}`); .map(([k, v]) => `${k}=${v}`);
// Format window title as "teamName: agentName" if teamName is provided // Format window title as "teamName: agentName" if teamName is provided
const windowTitle = options.teamName const windowTitle = options.teamName ? `${options.teamName}: ${options.name}` : options.name;
? `${options.teamName}: ${options.name}`
: options.name;
// Spawn a new window // Spawn a new window
const spawnArgs = [ const spawnArgs = [
"cli", "spawn", "--new-window", "cli",
"--cwd", options.cwd, "spawn",
"--", "env", ...envArgs, "sh", "-c", options.command "--new-window",
"--cwd",
options.cwd,
"--",
"env",
...envArgs,
"sh",
"-c",
options.command,
]; ];
const result = execCommand(weztermBin, spawnArgs); const result = execCommand(weztermBin, spawnArgs);

View file

@ -5,7 +5,7 @@
* Note: Zellij uses --close-on-exit, so explicit kill is not needed. * Note: Zellij uses --close-on-exit, so explicit kill is not needed.
*/ */
import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter"; import { execCommand, type SpawnOptions, type TerminalAdapter } from "../utils/terminal-adapter";
export class ZellijAdapter implements TerminalAdapter { export class ZellijAdapter implements TerminalAdapter {
readonly name = "zellij"; readonly name = "zellij";
@ -18,15 +18,19 @@ export class ZellijAdapter implements TerminalAdapter {
spawn(options: SpawnOptions): string { spawn(options: SpawnOptions): string {
const zellijArgs = [ const zellijArgs = [
"run", "run",
"--name", options.name, "--name",
"--cwd", options.cwd, options.name,
"--cwd",
options.cwd,
"--close-on-exit", "--close-on-exit",
"--", "--",
"env", "env",
...Object.entries(options.env) ...Object.entries(options.env)
.filter(([k]) => k.startsWith("PI_")) .filter(([k]) => k.startsWith("PI_"))
.map(([k, v]) => `${k}=${v}`), .map(([k, v]) => `${k}=${v}`),
"sh", "-c", options.command "sh",
"-c",
options.command,
]; ];
const result = execCommand("zellij", zellijArgs); const result = execCommand("zellij", zellijArgs);

View file

@ -1,7 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { runHook } from "./hooks"; import { runHook } from "./hooks";
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
describe("runHook", () => { describe("runHook", () => {
const hooksDir = path.join(process.cwd(), ".pi", "team-hooks"); const hooksDir = path.join(process.cwd(), ".pi", "team-hooks");
@ -15,7 +15,7 @@ describe("runHook", () => {
afterAll(() => { afterAll(() => {
// Optional: Clean up created scripts // Optional: Clean up created scripts
const files = ["success_hook.sh", "fail_hook.sh"]; const files = ["success_hook.sh", "fail_hook.sh"];
files.forEach(f => { files.forEach((f) => {
const p = path.join(hooksDir, f); const p = path.join(hooksDir, f);
if (fs.existsSync(p)) fs.unlinkSync(p); if (fs.existsSync(p)) fs.unlinkSync(p);
}); });

View file

@ -1,7 +1,7 @@
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);

View file

@ -1,7 +1,7 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path";
import os from "node:os"; import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withLock } from "./lock"; import { withLock } from "./lock";
describe("withLock race conditions", () => { describe("withLock race conditions", () => {
@ -27,7 +27,7 @@ describe("withLock race conditions", () => {
await withLock(lockPath, async () => { await withLock(lockPath, async () => {
const current = counter; const current = counter;
// Add a small delay to increase the chance of race conditions if locking fails // Add a small delay to increase the chance of race conditions if locking fails
await new Promise(resolve => setTimeout(resolve, Math.random() * 10)); await new Promise((resolve) => setTimeout(resolve, Math.random() * 10));
counter = current + 1; counter = current + 1;
}); });
} }

View file

@ -1,8 +1,9 @@
// Project: pi-teams // Project: pi-teams
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path";
import os from "node:os"; import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withLock } from "./lock"; import { withLock } from "./lock";
describe("withLock", () => { describe("withLock", () => {

View file

@ -28,7 +28,7 @@ export async function withLock<T>(lockPath: string, fn: () => Promise<T>, retrie
break; break;
} catch (e) { } catch (e) {
retries--; retries--;
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
} }
} }

View file

@ -1,8 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path";
import os from "node:os"; import os from "node:os";
import { appendMessage, readInbox, sendPlainMessage, broadcastMessage } from "./messaging"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { appendMessage, broadcastMessage, readInbox, sendPlainMessage } from "./messaging";
import * as paths from "./paths"; import * as paths from "./paths";
// Mock the paths to use a temporary directory // Mock the paths to use a temporary directory
@ -50,7 +50,7 @@ describe("Messaging Utilities", () => {
expect(inbox.length).toBe(numMessages); expect(inbox.length).toBe(numMessages);
// Verify all messages are present // Verify all messages are present
const texts = inbox.map(m => m.text).sort(); const texts = inbox.map((m) => m.text).sort();
for (let i = 0; i < numMessages; i++) { for (let i = 0; i < numMessages; i++) {
expect(texts).toContain(`msg-${i}`); expect(texts).toContain(`msg-${i}`);
} }
@ -67,18 +67,14 @@ describe("Messaging Utilities", () => {
// Now all should be read // Now all should be read
const all = await readInbox("test-team", "receiver", false, false); const all = await readInbox("test-team", "receiver", false, false);
expect(all.length).toBe(2); expect(all.length).toBe(2);
expect(all.every(m => m.read)).toBe(true); expect(all.every((m) => m.read)).toBe(true);
}); });
it("should broadcast message to all members except the sender", async () => { it("should broadcast message to all members except the sender", async () => {
// Setup team config // Setup team config
const config = { const config = {
name: "test-team", name: "test-team",
members: [ members: [{ name: "sender" }, { name: "member1" }, { name: "member2" }],
{ name: "sender" },
{ name: "member1" },
{ name: "member2" }
]
}; };
const configFilePath = path.join(testDir, "config.json"); const configFilePath = path.join(testDir, "config.json");
fs.writeFileSync(configFilePath, JSON.stringify(config)); fs.writeFileSync(configFilePath, JSON.stringify(config));

View file

@ -1,7 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { InboxMessage } from "./models";
import { withLock } from "./lock"; import { withLock } from "./lock";
import type { InboxMessage } from "./models";
import { inboxPath } from "./paths"; import { inboxPath } from "./paths";
import { readConfig } from "./teams"; import { readConfig } from "./teams";
@ -28,7 +28,7 @@ export async function readInbox(
teamName: string, teamName: string,
agentName: string, agentName: string,
unreadOnly = false, unreadOnly = false,
markAsRead = true markAsRead = true,
): Promise<InboxMessage[]> { ): Promise<InboxMessage[]> {
const p = inboxPath(teamName, agentName); const p = inboxPath(teamName, agentName);
if (!fs.existsSync(p)) return []; if (!fs.existsSync(p)) return [];
@ -38,7 +38,7 @@ export async function readInbox(
let result = allMsgs; let result = allMsgs;
if (unreadOnly) { if (unreadOnly) {
result = allMsgs.filter(m => !m.read); result = allMsgs.filter((m) => !m.read);
} }
if (markAsRead && result.length > 0) { if (markAsRead && result.length > 0) {
@ -60,7 +60,7 @@ export async function sendPlainMessage(
toName: string, toName: string,
text: string, text: string,
summary: string, summary: string,
color?: string color?: string,
) { ) {
const msg: InboxMessage = { const msg: InboxMessage = {
from: fromName, from: fromName,
@ -86,7 +86,7 @@ export async function broadcastMessage(
fromName: string, fromName: string,
text: string, text: string,
summary: string, summary: string,
color?: string color?: string,
) { ) {
const config = await readConfig(teamName); const config = await readConfig(teamName);

View file

@ -1,6 +1,6 @@
import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import fs from "node:fs";
export const PI_DIR = path.join(os.homedir(), ".pi"); export const PI_DIR = path.join(os.homedir(), ".pi");
export const TEAMS_DIR = path.join(PI_DIR, "teams"); export const TEAMS_DIR = path.join(PI_DIR, "teams");

View file

@ -1,8 +1,8 @@
import { describe, it, expect } from "vitest";
import path from "node:path";
import os from "node:os";
import fs from "node:fs"; import fs from "node:fs";
import { teamDir, inboxPath, sanitizeName } from "./paths"; import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { inboxPath, sanitizeName, teamDir } from "./paths";
describe("Security Audit - Path Traversal (Prevention Check)", () => { describe("Security Audit - Path Traversal (Prevention Check)", () => {
it("should throw an error for path traversal via teamName", () => { it("should throw an error for path traversal via teamName", () => {

View file

@ -1,9 +1,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path";
import os from "node:os"; import os from "node:os";
import { createTask, listTasks } from "./tasks"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as paths from "./paths"; import * as paths from "./paths";
import { createTask, listTasks } from "./tasks";
const testDir = path.join(os.tmpdir(), "pi-tasks-race-test-" + Date.now()); const testDir = path.join(os.tmpdir(), "pi-tasks-race-test-" + Date.now());
@ -31,7 +31,7 @@ describe("Tasks Race Condition Bug", () => {
} }
const results = await Promise.all(promises); const results = await Promise.all(promises);
const ids = results.map(r => r.id); const ids = results.map((r) => r.id);
const uniqueIds = new Set(ids); const uniqueIds = new Set(ids);
// If Bug 1 exists (getTaskId outside the lock but actually it is inside the lock in createTask), // If Bug 1 exists (getTaskId outside the lock but actually it is inside the lock in createTask),

View file

@ -1,10 +1,11 @@
// Project: pi-teams // Project: pi-teams
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path";
import os from "node:os"; import os from "node:os";
import { createTask, updateTask, readTask, listTasks, submitPlan, evaluatePlan } from "./tasks"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as paths from "./paths"; import * as paths from "./paths";
import { createTask, evaluatePlan, listTasks, readTask, submitPlan, updateTask } from "./tasks";
import * as teams from "./teams"; import * as teams from "./teams";
// Mock the paths to use a temporary directory // Mock the paths to use a temporary directory
@ -86,7 +87,9 @@ describe("Tasks Utilities", () => {
// 2. Try updateTask, it should fail // 2. Try updateTask, it should fail
// Using small retries to speed up the test and avoid fake timer issues with native setTimeout // 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"); await expect(updateTask("test-team", taskId, { status: "in_progress" }, 2)).rejects.toThrow(
"Could not acquire lock",
);
// 3. Try readTask, it should fail too // 3. Try readTask, it should fail too
await expect(readTask("test-team", taskId, 2)).rejects.toThrow("Could not acquire lock"); await expect(readTask("test-team", taskId, 2)).rejects.toThrow("Could not acquire lock");
@ -128,15 +131,21 @@ describe("Tasks Utilities", () => {
it("should fail to reject a plan without feedback", async () => { it("should fail to reject a plan without feedback", async () => {
const task = await createTask("test-team", "Feedback Test", "Should require feedback"); const task = await createTask("test-team", "Feedback Test", "Should require feedback");
await submitPlan("test-team", task.id, "My plan"); 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(
await expect(evaluatePlan("test-team", task.id, "reject", " ")).rejects.toThrow("Feedback is required when rejecting a plan"); "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 () => { it("should sanitize task IDs in all file operations", async () => {
const dirtyId = "../evil-id"; const dirtyId = "../evil-id";
// sanitizeName should throw on this dirtyId // sanitizeName should throw on this dirtyId
await expect(readTask("test-team", dirtyId)).rejects.toThrow(/Invalid name: "..\/evil-id"/); 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(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"/); await expect(evaluatePlan("test-team", dirtyId, "approve")).rejects.toThrow(/Invalid name: "..\/evil-id"/);
}); });
}); });

View file

@ -1,16 +1,16 @@
// Project: pi-teams // Project: pi-teams
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { TaskFile } from "./models";
import { taskDir, sanitizeName } from "./paths";
import { teamExists } from "./teams";
import { withLock } from "./lock";
import { runHook } from "./hooks"; import { runHook } from "./hooks";
import { withLock } from "./lock";
import type { TaskFile } from "./models";
import { sanitizeName, taskDir } from "./paths";
import { teamExists } from "./teams";
export function getTaskId(teamName: string): string { export function getTaskId(teamName: string): string {
const dir = taskDir(teamName); const dir = taskDir(teamName);
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json")); const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
const ids = files.map(f => parseInt(path.parse(f).name, 10)).filter(id => !isNaN(id)); const ids = files.map((f) => parseInt(path.parse(f).name, 10)).filter((id) => !isNaN(id));
return ids.length > 0 ? (Math.max(...ids) + 1).toString() : "1"; return ids.length > 0 ? (Math.max(...ids) + 1).toString() : "1";
} }
@ -25,7 +25,7 @@ export async function createTask(
subject: string, subject: string,
description: string, description: string,
activeForm = "", activeForm = "",
metadata?: Record<string, any> metadata?: Record<string, any>,
): Promise<TaskFile> { ): Promise<TaskFile> {
if (!subject || !subject.trim()) throw new Error("Task subject must not be empty"); if (!subject || !subject.trim()) throw new Error("Task subject must not be empty");
if (!teamExists(teamName)) throw new Error(`Team ${teamName} does not exist`); if (!teamExists(teamName)) throw new Error(`Team ${teamName} does not exist`);
@ -54,11 +54,13 @@ export async function updateTask(
teamName: string, teamName: string,
taskId: string, taskId: string,
updates: Partial<TaskFile>, updates: Partial<TaskFile>,
retries?: number retries?: number,
): Promise<TaskFile> { ): Promise<TaskFile> {
const p = getTaskPath(teamName, taskId); const p = getTaskPath(teamName, taskId);
return await withLock(p, async () => { return await withLock(
p,
async () => {
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`); if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8")); const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
const updated = { ...task, ...updates }; const updated = { ...task, ...updates };
@ -75,7 +77,9 @@ export async function updateTask(
} }
return updated; return updated;
}, retries); },
retries,
);
} }
/** /**
@ -104,11 +108,13 @@ export async function evaluatePlan(
taskId: string, taskId: string,
action: "approve" | "reject", action: "approve" | "reject",
feedback?: string, feedback?: string,
retries?: number retries?: number,
): Promise<TaskFile> { ): Promise<TaskFile> {
const p = getTaskPath(teamName, taskId); const p = getTaskPath(teamName, taskId);
return await withLock(p, async () => { return await withLock(
p,
async () => {
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`); if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8")); const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
@ -116,7 +122,7 @@ export async function evaluatePlan(
if (task.status !== "planning") { if (task.status !== "planning") {
throw new Error( throw new Error(
`Cannot evaluate plan for task ${taskId} because its status is '${task.status}'. ` + `Cannot evaluate plan for task ${taskId} because its status is '${task.status}'. ` +
`Tasks must be in 'planning' status to be evaluated.` `Tasks must be in 'planning' status to be evaluated.`,
); );
} }
@ -131,35 +137,42 @@ export async function evaluatePlan(
} }
// 4. Perform update // 4. Perform update
const updates: Partial<TaskFile> = action === "approve" const updates: Partial<TaskFile> =
action === "approve"
? { status: "in_progress", planFeedback: "" } ? { status: "in_progress", planFeedback: "" }
: { status: "planning", planFeedback: feedback }; : { status: "planning", planFeedback: feedback };
const updated = { ...task, ...updates }; const updated = { ...task, ...updates };
fs.writeFileSync(p, JSON.stringify(updated, null, 2)); fs.writeFileSync(p, JSON.stringify(updated, null, 2));
return updated; return updated;
}, retries); },
retries,
);
} }
export async function readTask(teamName: string, taskId: string, retries?: number): Promise<TaskFile> { export async function readTask(teamName: string, taskId: string, retries?: number): Promise<TaskFile> {
const p = getTaskPath(teamName, taskId); const p = getTaskPath(teamName, taskId);
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`); if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
return await withLock(p, async () => { return await withLock(
p,
async () => {
return JSON.parse(fs.readFileSync(p, "utf-8")); return JSON.parse(fs.readFileSync(p, "utf-8"));
}, retries); },
retries,
);
} }
export async function listTasks(teamName: string): Promise<TaskFile[]> { export async function listTasks(teamName: string): Promise<TaskFile[]> {
const dir = taskDir(teamName); const dir = taskDir(teamName);
return await withLock(dir, async () => { return await withLock(dir, async () => {
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json")); const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
const tasks: TaskFile[] = files const tasks: TaskFile[] = files
.map(f => { .map((f) => {
const id = parseInt(path.parse(f).name, 10); const id = parseInt(path.parse(f).name, 10);
if (isNaN(id)) return null; if (isNaN(id)) return null;
return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8")); return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
}) })
.filter(t => t !== null); .filter((t) => t !== null);
return tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10)); return tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
}); });
} }
@ -169,7 +182,7 @@ export async function resetOwnerTasks(teamName: string, agentName: string) {
const lockPath = dir; const lockPath = dir;
await withLock(lockPath, async () => { await withLock(lockPath, async () => {
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json")); const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
for (const f of files) { for (const f of files) {
const p = path.join(dir, f); const p = path.join(dir, f);
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8")); const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));

View file

@ -1,8 +1,8 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { TeamConfig, Member } from "./models";
import { configPath, teamDir, taskDir } from "./paths";
import { withLock } from "./lock"; import { withLock } from "./lock";
import type { Member, TeamConfig } from "./models";
import { configPath, taskDir, teamDir } from "./paths";
export function teamExists(teamName: string) { export function teamExists(teamName: string) {
return fs.existsSync(configPath(teamName)); return fs.existsSync(configPath(teamName));
@ -14,7 +14,7 @@ export function createTeam(
leadAgentId: string, leadAgentId: string,
description = "", description = "",
defaultModel?: string, defaultModel?: string,
separateWindows?: boolean separateWindows?: boolean,
): TeamConfig { ): TeamConfig {
const dir = teamDir(name); const dir = teamDir(name);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
@ -72,7 +72,7 @@ export async function removeMember(teamName: string, agentName: string) {
const p = configPath(teamName); const p = configPath(teamName);
await withLock(p, async () => { await withLock(p, async () => {
const config = readConfigRaw(p); const config = readConfigRaw(p);
config.members = config.members.filter(m => m.name !== agentName); config.members = config.members.filter((m) => m.name !== agentName);
fs.writeFileSync(p, JSON.stringify(config, null, 2)); fs.writeFileSync(p, JSON.stringify(config, null, 2));
}); });
} }
@ -81,7 +81,7 @@ export async function updateMember(teamName: string, agentName: string, updates:
const p = configPath(teamName); const p = configPath(teamName);
await withLock(p, async () => { await withLock(p, async () => {
const config = readConfigRaw(p); const config = readConfigRaw(p);
const m = config.members.find(m => m.name === agentName); const m = config.members.find((m) => m.name === agentName);
if (m) { if (m) {
Object.assign(m, updates); Object.assign(m, updates);
fs.writeFileSync(p, JSON.stringify(config, null, 2)); fs.writeFileSync(p, JSON.stringify(config, null, 2));

View file

@ -120,7 +120,10 @@ export interface TerminalAdapter {
/** /**
* Base helper for adapters to execute commands synchronously. * Base helper for adapters to execute commands synchronously.
*/ */
export function execCommand(command: string, args: string[]): { stdout: string; stderr: string; status: number | null } { export function execCommand(
command: string,
args: string[],
): { stdout: string; stderr: string; status: number | null } {
const result = spawnSync(command, args, { encoding: "utf-8" }); const result = spawnSync(command, args, { encoding: "utf-8" });
return { return {
stdout: result.stdout?.toString() ?? "", stdout: result.stdout?.toString() ?? "",