feat: add Tauri desktop app support and terminal management

This commit is contained in:
Mohamed Boudra
2026-01-20 10:09:35 +07:00
parent 12f617dd10
commit d9c487dd54
125 changed files with 8906 additions and 1049 deletions
+183 -23
View File
@@ -24,7 +24,12 @@ import type {
ListCommandsResponse,
ListConversationsResponseMessage,
ListProviderModelsResponseMessage,
SendAgentAudio,
ListTerminalsResponse,
CreateTerminalResponse,
SubscribeTerminalResponse,
TerminalOutput,
KillTerminalResponse,
TerminalInput,
SendAgentMessage,
SessionInboundMessage,
SessionOutboundMessage,
@@ -137,7 +142,11 @@ export type DaemonClientV2Config = {
export type SendMessageOptions = Pick<SendAgentMessage, "messageId" | "images">;
export type SendAgentAudioOptions = Omit<SendAgentAudio, "type">;
export type TranscribeAudioOptions = {
audio: string; // base64 encoded
format: string;
timeout?: number;
};
type AgentConfigOverrides = Partial<Omit<AgentSessionConfig, "provider" | "cwd">>;
@@ -164,6 +173,11 @@ type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
type ListCommandsPayload = ListCommandsResponse["payload"];
type TranscriptionResultPayload = TranscriptionResultMessage["payload"];
type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"];
type ListTerminalsPayload = ListTerminalsResponse["payload"];
type CreateTerminalPayload = CreateTerminalResponse["payload"];
type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
type TerminalOutputPayload = TerminalOutput["payload"];
type KillTerminalPayload = KillTerminalResponse["payload"];
type AgentCreateFailedStatusPayload = z.infer<
typeof AgentCreateFailedStatusPayloadSchema
@@ -811,28 +825,13 @@ export class DaemonClientV2 {
await this.sendAgentMessage(agentId, text, options);
}
async sendAgentAudio(options: SendAgentAudioOptions): Promise<void> {
const requestId = options.requestId ?? this.createRequestId();
const message = SessionInboundMessageSchema.parse({
type: "send_agent_audio",
...(options.agentId ? { agentId: options.agentId } : {}),
audio: options.audio,
format: options.format,
isLast: options.isLast,
requestId,
...(options.mode ? { mode: options.mode } : {}),
});
this.sendSessionMessage(message);
}
async waitForTranscriptionResult(
requestId: string,
timeout = 120000
async transcribeAudio(
options: TranscribeAudioOptions
): Promise<TranscriptionResultPayload> {
if (!requestId) {
throw new Error("requestId is required");
}
return this.waitFor(
const requestId = this.createRequestId();
const timeout = options.timeout ?? 120000;
const responsePromise = this.waitFor(
(msg) => {
if (msg.type !== "transcription_result") {
return null;
@@ -845,6 +844,16 @@ export class DaemonClientV2 {
timeout,
{ skipQueue: true }
);
const message = SessionInboundMessageSchema.parse({
type: "transcribe_audio_request",
audio: options.audio,
format: options.format,
requestId,
});
this.sendSessionMessage(message);
return responsePromise;
}
async cancelAgent(agentId: string): Promise<void> {
@@ -1399,6 +1408,157 @@ export class DaemonClientV2 {
);
}
// ============================================================================
// Terminals
// ============================================================================
async listTerminals(
cwd: string,
requestId?: string
): Promise<ListTerminalsPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "list_terminals_request",
cwd,
requestId: resolvedRequestId,
});
const response = this.waitFor(
(msg) => {
if (msg.type !== "list_terminals_response") {
return null;
}
if (msg.payload.requestId !== resolvedRequestId) {
return null;
}
return msg.payload;
},
10000,
{ skipQueue: true }
);
this.sendSessionMessage(message);
return response;
}
async createTerminal(
cwd: string,
name?: string,
requestId?: string
): Promise<CreateTerminalPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "create_terminal_request",
cwd,
name,
requestId: resolvedRequestId,
});
const response = this.waitFor(
(msg) => {
if (msg.type !== "create_terminal_response") {
return null;
}
if (msg.payload.requestId !== resolvedRequestId) {
return null;
}
return msg.payload;
},
10000,
{ skipQueue: true }
);
this.sendSessionMessage(message);
return response;
}
async subscribeTerminal(
terminalId: string,
requestId?: string
): Promise<SubscribeTerminalPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "subscribe_terminal_request",
terminalId,
requestId: resolvedRequestId,
});
const response = this.waitFor(
(msg) => {
if (msg.type !== "subscribe_terminal_response") {
return null;
}
if (msg.payload.requestId !== resolvedRequestId) {
return null;
}
return msg.payload;
},
10000,
{ skipQueue: true }
);
this.sendSessionMessage(message);
return response;
}
unsubscribeTerminal(terminalId: string): void {
this.sendSessionMessage({
type: "unsubscribe_terminal_request",
terminalId,
});
}
sendTerminalInput(
terminalId: string,
message: TerminalInput["message"]
): void {
this.sendSessionMessage({
type: "terminal_input",
terminalId,
message,
});
}
async killTerminal(
terminalId: string,
requestId?: string
): Promise<KillTerminalPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "kill_terminal_request",
terminalId,
requestId: resolvedRequestId,
});
const response = this.waitFor(
(msg) => {
if (msg.type !== "kill_terminal_response") {
return null;
}
if (msg.payload.requestId !== resolvedRequestId) {
return null;
}
return msg.payload;
},
10000,
{ skipQueue: true }
);
this.sendSessionMessage(message);
return response;
}
async waitForTerminalOutput(
terminalId: string,
timeout = 5000
): Promise<TerminalOutputPayload> {
return this.waitFor(
(msg) => {
if (msg.type !== "terminal_output") {
return null;
}
if (msg.payload.terminalId !== terminalId) {
return null;
}
return msg.payload;
},
timeout,
{ skipQueue: true }
);
}
// ============================================================================
// Internals
// ============================================================================
@@ -91,17 +91,6 @@ describe("agent MCP end-to-end", () => {
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const agentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-"));
const port = await getAvailablePort();
const basicUsers = { test: "pass" };
const [agentMcpUser, agentMcpPassword] =
Object.entries(basicUsers)[0] ?? [];
const agentMcpAuthHeader =
agentMcpUser && agentMcpPassword
? `Basic ${Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")}`
: undefined;
const agentMcpBearerToken =
agentMcpUser && agentMcpPassword
? Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")
: undefined;
const daemonConfig: PaseoDaemonConfig = {
listen: `${port}`,
@@ -109,21 +98,12 @@ describe("agent MCP end-to-end", () => {
corsAllowedOrigins: [],
agentMcpRoute: "/mcp/agents",
agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`],
auth: {
basicUsers,
agentMcpAuthHeader,
agentMcpBearerToken,
realm: "Voice Assistant",
},
staticDir,
mcpDebug: false,
agentClients: {},
agentRegistryPath: path.join(paseoHome, "agents.json"),
agentControlMcp: {
url: `http://127.0.0.1:${port}/mcp/agents`,
...(agentMcpAuthHeader
? { headers: { Authorization: agentMcpAuthHeader } }
: {}),
},
};
@@ -140,10 +120,7 @@ describe("agent MCP end-to-end", () => {
await daemon.start();
const transport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${port}/mcp/agents`),
agentMcpAuthHeader
? { requestInit: { headers: { Authorization: agentMcpAuthHeader } } }
: undefined
new URL(`http://127.0.0.1:${port}/mcp/agents`)
);
const client = (await experimental_createMCPClient({
transport,
@@ -247,17 +224,6 @@ describe("agent MCP end-to-end", () => {
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const agentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-"));
const port = await getAvailablePort();
const basicUsers = { test: "pass" };
const [agentMcpUser, agentMcpPassword] =
Object.entries(basicUsers)[0] ?? [];
const agentMcpAuthHeader =
agentMcpUser && agentMcpPassword
? `Basic ${Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")}`
: undefined;
const agentMcpBearerToken =
agentMcpUser && agentMcpPassword
? Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")
: undefined;
const daemonConfig: PaseoDaemonConfig = {
listen: `${port}`,
@@ -265,21 +231,12 @@ describe("agent MCP end-to-end", () => {
corsAllowedOrigins: [],
agentMcpRoute: "/mcp/agents",
agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`],
auth: {
basicUsers,
agentMcpAuthHeader,
agentMcpBearerToken,
realm: "Voice Assistant",
},
staticDir,
mcpDebug: false,
agentClients: {},
agentRegistryPath: path.join(paseoHome, "agents.json"),
agentControlMcp: {
url: `http://127.0.0.1:${port}/mcp/agents`,
...(agentMcpAuthHeader
? { headers: { Authorization: agentMcpAuthHeader } }
: {}),
},
};
@@ -296,10 +253,7 @@ describe("agent MCP end-to-end", () => {
await daemon.start();
const transport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${port}/mcp/agents`),
agentMcpAuthHeader
? { requestInit: { headers: { Authorization: agentMcpAuthHeader } } }
: undefined
new URL(`http://127.0.0.1:${port}/mcp/agents`)
);
const client = (await experimental_createMCPClient({
transport,
+9 -37
View File
@@ -1,5 +1,4 @@
import express from "express";
import basicAuth from "express-basic-auth";
import { createServer as createHTTPServer } from "http";
import { createReadStream, unlinkSync, existsSync } from "fs";
import { stat } from "fs/promises";
@@ -49,6 +48,7 @@ import { initializeTitleGenerator } from "../services/agent-title-generator.js";
import { attachAgentRegistryPersistence } from "./persistence-hooks.js";
import { createAgentMcpServer } from "./agent/mcp-server.js";
import { createAllClients, shutdownProviders } from "./agent/provider-registry.js";
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
import type {
AgentClient,
AgentControlMcpConfig,
@@ -57,13 +57,6 @@ import type {
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
export type PaseoAuthConfig = {
basicUsers: Record<string, string>;
realm?: string;
agentMcpBearerToken?: string;
agentMcpAuthHeader?: string;
};
export type PaseoOpenAIConfig = {
apiKey?: string;
stt?: Partial<STTConfig> & { apiKey?: string };
@@ -76,7 +69,6 @@ export type PaseoDaemonConfig = {
corsAllowedOrigins: string[];
agentMcpRoute: string;
agentMcpAllowedHosts: string[];
auth: PaseoAuthConfig;
staticDir: string;
mcpDebug: boolean;
agentClients: Partial<Record<AgentProvider, AgentClient>>;
@@ -90,6 +82,7 @@ export interface PaseoDaemon {
config: PaseoDaemonConfig;
agentManager: AgentManager;
agentRegistry: AgentRegistry;
terminalManager: TerminalManager;
start(): Promise<void>;
stop(): Promise<void>;
}
@@ -101,13 +94,9 @@ export async function createPaseoDaemon(
const logger = rootLogger.child({ module: "bootstrap" });
const agentMcpRoute = config.agentMcpRoute;
const basicAuthUsers = config.auth.basicUsers;
const staticDir = config.staticDir;
const authRealm = config.auth.realm ?? "Voice Assistant";
const downloadTokenTtlMs = config.downloadTokenTtlMs ?? 60000;
const agentMcpBearerToken = config.auth.agentMcpBearerToken;
const downloadTokenStore = new DownloadTokenStore({ ttlMs: downloadTokenTtlMs });
const listenTarget = parseListenString(config.listen);
@@ -142,31 +131,9 @@ export async function createPaseoDaemon(
next();
});
// Serve static files from public directory (no auth required for APK downloads)
// Serve static files from public directory
app.use("/public", express.static(staticDir));
// Basic authentication (skip for /public routes)
const basicAuthMiddleware = basicAuth({
users: basicAuthUsers,
challenge: true,
realm: authRealm,
});
app.use((req, res, next) => {
if (req.path === "/api/files/download") {
return next();
}
if (agentMcpBearerToken && req.path.startsWith(agentMcpRoute)) {
const authHeader = req.header("authorization") ?? "";
if (authHeader.startsWith("Bearer ")) {
const token = authHeader.slice("Bearer ".length).trim();
if (token === agentMcpBearerToken) {
return next();
}
}
}
return basicAuthMiddleware(req, res, next);
});
// Middleware
app.use(express.json());
@@ -260,6 +227,8 @@ export async function createPaseoDaemon(
logger,
});
const terminalManager = createTerminalManager();
attachAgentRegistryPersistence(logger, agentManager, agentRegistry);
const persistedRecords = await agentRegistry.list();
logger.info(
@@ -425,7 +394,8 @@ export async function createPaseoDaemon(
agentMcpHeaders: config.agentControlMcp.headers,
},
{ allowedOrigins },
{ stt: sttService, tts: ttsService }
{ stt: sttService, tts: ttsService },
terminalManager
);
const start = async () => {
@@ -464,6 +434,7 @@ export async function createPaseoDaemon(
const stop = async () => {
await closeAllAgents(logger, agentManager);
await shutdownProviders(logger);
terminalManager.killAll();
await wsServer.close();
await new Promise<void>((resolve) => {
httpServer.close(() => resolve());
@@ -478,6 +449,7 @@ export async function createPaseoDaemon(
config,
agentManager,
agentRegistry,
terminalManager,
start,
stop,
};
@@ -49,7 +49,6 @@ describe("client activity tracking", () => {
async function createClient(): Promise<DaemonClient> {
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
authHeader: daemon.agentMcpAuthHeader,
messageQueueLimit: null,
});
await client.connect();
-21
View File
@@ -6,7 +6,6 @@ import type { TTSConfig } from "./agent/tts-openai.js";
import { loadPersistedConfig } from "./persisted-config.js";
const DEFAULT_LISTEN = "127.0.0.1:6767";
const DEFAULT_BASIC_AUTH_USERS = { mo: "bo" } as const;
const DEFAULT_AGENT_MCP_ROUTE = "/mcp/agents";
function parseOpenAIConfig(env: NodeJS.ProcessEnv) {
@@ -68,17 +67,6 @@ export function loadConfig(
const listen = env.PASEO_LISTEN ?? persisted.listen ?? DEFAULT_LISTEN;
const mcpListen = getListenForMcp(listen);
const basicUsers = DEFAULT_BASIC_AUTH_USERS;
const [agentMcpUser, agentMcpPassword] = Object.entries(basicUsers)[0] ?? [];
const agentMcpAuthHeader =
agentMcpUser && agentMcpPassword
? `Basic ${Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")}`
: undefined;
const agentMcpBearerToken =
agentMcpUser && agentMcpPassword
? Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")
: undefined;
const envCorsOrigins = env.PASEO_CORS_ORIGINS
? env.PASEO_CORS_ORIGINS.split(",").map((s) => s.trim())
: [];
@@ -89,18 +77,9 @@ export function loadConfig(
corsAllowedOrigins: [...persisted.cors.allowedOrigins, ...envCorsOrigins],
agentMcpRoute: DEFAULT_AGENT_MCP_ROUTE,
agentMcpAllowedHosts: [mcpListen, `localhost:${mcpListen.split(":")[1]}`],
auth: {
basicUsers,
agentMcpAuthHeader,
agentMcpBearerToken,
realm: "Voice Assistant",
},
mcpDebug: env.MCP_DEBUG === "1",
agentControlMcp: {
url: `http://${mcpListen}${DEFAULT_AGENT_MCP_ROUTE}`,
...(agentMcpAuthHeader
? { headers: { Authorization: agentMcpAuthHeader } }
: {}),
},
agentRegistryPath: path.join(paseoHome, "agents.json"),
staticDir: "public",
@@ -9,6 +9,7 @@ import {
createDaemonTestContext,
type DaemonTestContext,
} from "./test-utils/index.js";
import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js";
const defaultEnvPath = path.resolve(process.cwd(), ".env");
const fallbackEnvPath = path.resolve(process.cwd(), "packages", "server", ".env");
@@ -26,10 +27,6 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-client-v2-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
function waitForSignal<T>(
timeoutMs: number,
setup: (
@@ -155,9 +152,7 @@ describe("daemon client v2 E2E", () => {
});
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
...getFullAccessConfig("codex"),
cwd,
title: "Daemon Client V2",
requestId: createRequestId,
@@ -198,9 +193,7 @@ describe("daemon client v2 E2E", () => {
});
const failResult = await ctx.client.createAgentExpectFail({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
...getFullAccessConfig("codex"),
cwd: "/this/path/does/not/exist/12345",
title: "Should Fail",
requestId: failRequestId,
@@ -409,12 +402,9 @@ describe("daemon client v2 E2E", () => {
const filePath = path.join(cwd, "permission.txt");
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
...getAskModeConfig("codex"),
cwd,
title: "Permission Test",
modeId: "auto",
});
const permissionRequestPromise = waitForSignal(60000, (resolve) => {
@@ -485,9 +475,7 @@ describe("daemon client v2 E2E", () => {
async () => {
const cwd = tmpCwd();
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
...getFullAccessConfig("codex"),
cwd,
title: "Raw Events Test",
});
@@ -668,9 +656,7 @@ describe("daemon client v2 E2E", () => {
writeFileSync(downloadFile, downloadContents, "utf-8");
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
...getFullAccessConfig("codex"),
cwd,
title: "Git/File Test",
});
@@ -871,12 +857,8 @@ describe("daemon client v2 E2E", () => {
expect(tokenMessage.payload.agentId).toBe(agent.id);
expect(tokenMessage.payload.requestId).toBe(tokenRequestId);
const authHeader = ctx.daemon.agentMcpAuthHeader;
expect(authHeader).toBeTruthy();
const response = await fetch(
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`,
{ headers: { Authorization: authHeader! } }
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`
);
expect(response.status).toBe(200);
@@ -54,12 +54,8 @@ describe("daemon E2E", () => {
expect(tokenResponse.token).toBeTruthy();
expect(tokenResponse.fileName).toBe("download.txt");
const authHeader = ctx.daemon.agentMcpAuthHeader;
expect(authHeader).toBeTruthy();
const response = await fetch(
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`,
{ headers: { Authorization: authHeader! } }
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`
);
expect(response.status).toBe(200);
@@ -80,12 +76,8 @@ describe("daemon E2E", () => {
test(
"rejects invalid token",
async () => {
const authHeader = ctx.daemon.agentMcpAuthHeader;
expect(authHeader).toBeTruthy();
const response = await fetch(
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=invalid-token`,
{ headers: { Authorization: authHeader! } }
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=invalid-token`
);
expect(response.status).toBe(403);
@@ -119,12 +111,8 @@ describe("daemon E2E", () => {
await new Promise((resolve) => setTimeout(resolve, 150));
const authHeader = ctx.daemon.agentMcpAuthHeader;
expect(authHeader).toBeTruthy();
const response = await fetch(
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`,
{ headers: { Authorization: authHeader! } }
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`
);
expect(response.status).toBe(403);
@@ -0,0 +1,232 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-terminal-e2e-"));
}
describe("daemon E2E terminal", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
test(
"lists terminals for a directory (auto-creates first)",
async () => {
const cwd = tmpCwd();
const result = await ctx.client.listTerminals(cwd);
expect(result.cwd).toBe(cwd);
expect(result.terminals).toHaveLength(1);
expect(result.terminals[0].name).toBe("Terminal 1");
expect(result.terminals[0].id).toBeTruthy();
rmSync(cwd, { recursive: true, force: true });
},
30000
);
test(
"creates additional terminal with custom name",
async () => {
const cwd = tmpCwd();
// First call auto-creates Terminal 1
await ctx.client.listTerminals(cwd);
// Create a second terminal with custom name
const result = await ctx.client.createTerminal(cwd, "Dev Server");
expect(result.error).toBeNull();
expect(result.terminal).toBeTruthy();
expect(result.terminal!.name).toBe("Dev Server");
expect(result.terminal!.cwd).toBe(cwd);
// Verify list now shows two terminals
const list = await ctx.client.listTerminals(cwd);
expect(list.terminals).toHaveLength(2);
rmSync(cwd, { recursive: true, force: true });
},
30000
);
test(
"subscribes to terminal and receives state",
async () => {
const cwd = tmpCwd();
// Get terminal (auto-creates)
const list = await ctx.client.listTerminals(cwd);
const terminalId = list.terminals[0].id;
// Subscribe to terminal
const subscribeResult = await ctx.client.subscribeTerminal(terminalId);
expect(subscribeResult.error).toBeNull();
expect(subscribeResult.terminalId).toBe(terminalId);
expect(subscribeResult.state).toBeTruthy();
expect(subscribeResult.state!.rows).toBeGreaterThan(0);
expect(subscribeResult.state!.cols).toBeGreaterThan(0);
expect(subscribeResult.state!.grid).toBeTruthy();
expect(subscribeResult.state!.cursor).toBeTruthy();
// Unsubscribe
ctx.client.unsubscribeTerminal(terminalId);
rmSync(cwd, { recursive: true, force: true });
},
30000
);
test(
"sends input to terminal and receives output",
async () => {
const cwd = tmpCwd();
// Get terminal
const list = await ctx.client.listTerminals(cwd);
const terminalId = list.terminals[0].id;
// Subscribe to terminal
await ctx.client.subscribeTerminal(terminalId);
// Send input
ctx.client.sendTerminalInput(terminalId, { type: "input", data: "echo hello\r" });
// Wait for output containing "hello" - may need multiple updates
let foundHello = false;
const start = Date.now();
const timeout = 10000;
while (!foundHello && Date.now() - start < timeout) {
try {
const output = await ctx.client.waitForTerminalOutput(terminalId, 2000);
expect(output.terminalId).toBe(terminalId);
expect(output.state).toBeTruthy();
// Extract text from grid
const gridText = output.state.grid
.map((row) => row.map((cell) => cell.char).join("").trimEnd())
.filter((line) => line.length > 0)
.join("\n");
if (gridText.includes("hello")) {
foundHello = true;
}
} catch {
// Timeout waiting for output, try again
}
}
expect(foundHello).toBe(true);
// Cleanup
ctx.client.unsubscribeTerminal(terminalId);
rmSync(cwd, { recursive: true, force: true });
},
30000
);
test(
"kills terminal",
async () => {
const cwd = tmpCwd();
// Create terminal
const createResult = await ctx.client.createTerminal(cwd, "To Kill");
expect(createResult.terminal).toBeTruthy();
const terminalId = createResult.terminal!.id;
// Kill terminal
const killResult = await ctx.client.killTerminal(terminalId);
expect(killResult.success).toBe(true);
expect(killResult.terminalId).toBe(terminalId);
// Verify terminal is gone by trying to subscribe
const subscribeResult = await ctx.client.subscribeTerminal(terminalId);
expect(subscribeResult.error).toBe("Terminal not found");
rmSync(cwd, { recursive: true, force: true });
},
30000
);
test(
"returns error for relative path",
async () => {
// Try to list terminals with relative path
const list = await ctx.client.listTerminals("relative/path");
// Should return empty terminals (error case)
expect(list.terminals).toHaveLength(0);
},
30000
);
test(
"preserves color mode in terminal output (fgMode/bgMode)",
async () => {
const cwd = tmpCwd();
// Get terminal
const list = await ctx.client.listTerminals(cwd);
const terminalId = list.terminals[0].id;
// Subscribe to terminal
await ctx.client.subscribeTerminal(terminalId);
// Send printf with ANSI red color (mode 1)
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "printf '\\033[31mRED\\033[0m\\n'\r",
});
// Wait for output with colored text
let foundColoredCell = false;
const start = Date.now();
const timeout = 10000;
while (!foundColoredCell && Date.now() - start < timeout) {
try {
const output = await ctx.client.waitForTerminalOutput(terminalId, 2000);
// Look for a cell with fgMode set
for (const row of output.state.grid) {
for (const cell of row) {
if (cell.fgMode !== undefined && cell.fgMode > 0) {
foundColoredCell = true;
// Verify the mode is correct (1 = 16 ANSI colors)
expect(cell.fgMode).toBe(1);
expect(cell.fg).toBe(1); // ANSI red
break;
}
}
if (foundColoredCell) break;
}
} catch {
// Timeout waiting for output, try again
}
}
expect(foundColoredCell).toBe(true);
ctx.client.unsubscribeTerminal(terminalId);
rmSync(cwd, { recursive: true, force: true });
},
30000
);
});
+7 -1
View File
@@ -19,8 +19,14 @@ async function main() {
await daemon.start();
let shuttingDown = false;
const handleShutdown = async (signal: string) => {
logger.info(`${signal} received, shutting down gracefully...`);
if (shuttingDown) {
logger.info("Forcing exit...");
process.exit(1);
}
shuttingDown = true;
logger.info(`${signal} received, shutting down gracefully... (press Ctrl+C again to force exit)`);
const forceExit = setTimeout(() => {
logger.warn("Forcing shutdown - HTTP server didn't close in time");
+244 -101
View File
@@ -19,7 +19,14 @@ import {
type FileExplorerRequest,
type FileDownloadTokenRequest,
type GitSetupOptions,
type ListTerminalsRequest,
type CreateTerminalRequest,
type SubscribeTerminalRequest,
type UnsubscribeTerminalRequest,
type TerminalInput,
type KillTerminalRequest,
} from "./messages.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import { parseAndHighlightDiff, type ParsedDiffFile } from "./utils/diff-highlighter.js";
import { getSystemPrompt } from "./agent/system-prompt.js";
import { getAllTools } from "./agent/llm-openai.js";
@@ -252,6 +259,8 @@ export class Session {
lastActivityAt: Date;
appVisible: boolean;
} | null = null;
private readonly terminalManager: TerminalManager | null;
private terminalSubscriptions: Map<string, () => void> = new Map();
constructor(
clientId: string,
@@ -264,6 +273,7 @@ export class Session {
agentMcpConfig: AgentMcpClientConfig,
stt: OpenAISTT | null,
tts: OpenAITTS | null,
terminalManager: TerminalManager | null,
options?: {
conversationId?: string;
initialMessages?: ModelMessage[];
@@ -277,6 +287,7 @@ export class Session {
this.agentManager = agentManager;
this.agentRegistry = agentRegistry;
this.agentMcpConfig = agentMcpConfig;
this.terminalManager = terminalManager;
this.abortController = new AbortController();
this.sessionLogger = logger.child({
module: "session",
@@ -777,8 +788,8 @@ export class Session {
);
break;
case "send_agent_audio":
await this.handleSendAgentAudio(msg);
case "transcribe_audio_request":
await this.handleTranscribeAudio(msg);
break;
case "create_agent_request":
@@ -856,6 +867,30 @@ export class Session {
case "register_push_token":
this.handleRegisterPushToken(msg.token);
break;
case "list_terminals_request":
await this.handleListTerminalsRequest(msg);
break;
case "create_terminal_request":
await this.handleCreateTerminalRequest(msg);
break;
case "subscribe_terminal_request":
await this.handleSubscribeTerminalRequest(msg);
break;
case "unsubscribe_terminal_request":
this.handleUnsubscribeTerminalRequest(msg);
break;
case "terminal_input":
this.handleTerminalInput(msg);
break;
case "kill_terminal_request":
await this.handleKillTerminalRequest(msg);
break;
}
} catch (error: any) {
this.sessionLogger.error(
@@ -1112,65 +1147,28 @@ export class Session {
}
/**
* Handle audio message to agent (transcribe then send)
* Handle audio transcription request
*/
private async handleSendAgentAudio(
msg: Extract<SessionInboundMessage, { type: "send_agent_audio" }>
private async handleTranscribeAudio(
msg: Extract<SessionInboundMessage, { type: "transcribe_audio_request" }>
): Promise<void> {
const { agentId, audio, format, isLast, requestId, mode } = msg;
const shouldAutoRun = mode === "auto_run";
const { audio, format, requestId } = msg;
// agentId is required for auto_run mode
if (shouldAutoRun && !agentId) {
this.sessionLogger.error("agentId is required for auto_run mode");
return;
}
// Use placeholder for logging when agentId is not provided
const logAgentId = agentId ?? "transcription";
// Decode base64
const audioBuffer = Buffer.from(audio, "base64");
// For now, we'll process each audio segment immediately
// In the future, we might want to buffer chunks similar to realtime audio
if (!isLast) {
this.sessionLogger.debug(
{ agentId: logAgentId },
`Buffering agent audio chunk for agent ${logAgentId}`
);
// TODO: Implement buffering if needed
return;
}
this.sessionLogger.debug(
{ agentId: logAgentId },
`Transcribing audio for agent ${logAgentId}`
);
this.sessionLogger.debug({ requestId }, "Transcribing audio");
try {
// Transcribe the audio
const result = await this.sttManager.transcribe(audioBuffer, format, {
agentId: logAgentId,
requestId,
label: shouldAutoRun ? "dictation:auto_run" : "dictation",
label: "dictation",
});
const transcriptText = result.text.trim();
if (!transcriptText) {
this.sessionLogger.debug(
{ agentId: logAgentId },
`Empty transcription for agent ${logAgentId}, ignoring`
);
return;
}
this.sessionLogger.info(
{ agentId: logAgentId, transcriptText },
`Transcribed audio for agent ${logAgentId}`
{ requestId, textLength: result.text.length },
"Transcription complete"
);
// Emit transcription result to client with requestId
this.emit({
type: "transcription_result",
payload: {
@@ -1185,67 +1183,15 @@ export class Session {
debugRecordingPath: result.debugRecordingPath,
},
});
if (!shouldAutoRun) {
this.sessionLogger.info(
{ agentId: logAgentId, requestId },
`Completed transcription for agent ${logAgentId} (requestId: ${requestId})`
);
return;
}
// At this point, agentId is guaranteed to be defined (validated at function start)
const validAgentId = agentId!;
try {
await this.ensureAgentLoaded(validAgentId);
} catch (error) {
this.handleAgentRunError(
validAgentId,
error,
"Failed to initialize agent before sending audio prompt"
);
return;
}
try {
await this.interruptAgentIfRunning(validAgentId);
} catch (error) {
this.handleAgentRunError(
validAgentId,
error,
"Failed to interrupt running agent before sending audio prompt"
);
return;
}
try {
this.agentManager.recordUserMessage(validAgentId, transcriptText);
} catch (recordError) {
this.sessionLogger.error(
{ err: recordError, agentId: validAgentId },
`Failed to record transcribed user message for agent ${validAgentId}`
);
}
// Send transcribed text to agent
this.startAgentStream(validAgentId, transcriptText);
this.sessionLogger.info(
{ agentId: validAgentId },
`Sent transcribed text to agent ${validAgentId}`
);
} catch (error: any) {
this.sessionLogger.error(
{ err: error, agentId: logAgentId },
`Failed to process audio for agent ${logAgentId}`
);
this.sessionLogger.error({ err: error, requestId }, "Transcription failed");
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to process audio for agent: ${error.message}`,
content: `Transcription failed: ${error.message}`,
},
});
throw error;
@@ -3493,5 +3439,202 @@ export class Session {
this.agentTools = null;
}
// Unsubscribe from all terminals
for (const unsubscribe of this.terminalSubscriptions.values()) {
unsubscribe();
}
this.terminalSubscriptions.clear();
}
// ============================================================================
// Terminal Handlers
// ============================================================================
private async handleListTerminalsRequest(msg: ListTerminalsRequest): Promise<void> {
if (!this.terminalManager) {
this.emit({
type: "list_terminals_response",
payload: {
cwd: msg.cwd,
terminals: [],
requestId: msg.requestId,
},
});
return;
}
try {
const terminals = await this.terminalManager.getTerminals(msg.cwd);
this.emit({
type: "list_terminals_response",
payload: {
cwd: msg.cwd,
terminals: terminals.map((t) => ({ id: t.id, name: t.name })),
requestId: msg.requestId,
},
});
} catch (error: any) {
this.sessionLogger.error({ err: error, cwd: msg.cwd }, "Failed to list terminals");
this.emit({
type: "list_terminals_response",
payload: {
cwd: msg.cwd,
terminals: [],
requestId: msg.requestId,
},
});
}
}
private async handleCreateTerminalRequest(msg: CreateTerminalRequest): Promise<void> {
if (!this.terminalManager) {
this.emit({
type: "create_terminal_response",
payload: {
terminal: null,
error: "Terminal manager not available",
requestId: msg.requestId,
},
});
return;
}
try {
const session = await this.terminalManager.createTerminal({
cwd: msg.cwd,
name: msg.name,
});
this.emit({
type: "create_terminal_response",
payload: {
terminal: { id: session.id, name: session.name, cwd: session.cwd },
error: null,
requestId: msg.requestId,
},
});
} catch (error: any) {
this.sessionLogger.error({ err: error, cwd: msg.cwd }, "Failed to create terminal");
this.emit({
type: "create_terminal_response",
payload: {
terminal: null,
error: error.message,
requestId: msg.requestId,
},
});
}
}
private async handleSubscribeTerminalRequest(msg: SubscribeTerminalRequest): Promise<void> {
if (!this.terminalManager) {
this.emit({
type: "subscribe_terminal_response",
payload: {
terminalId: msg.terminalId,
state: null,
error: "Terminal manager not available",
requestId: msg.requestId,
},
});
return;
}
const session = this.terminalManager.getTerminal(msg.terminalId);
if (!session) {
this.emit({
type: "subscribe_terminal_response",
payload: {
terminalId: msg.terminalId,
state: null,
error: "Terminal not found",
requestId: msg.requestId,
},
});
return;
}
// Unsubscribe from previous subscription if any
const existing = this.terminalSubscriptions.get(msg.terminalId);
if (existing) {
existing();
}
// Subscribe to terminal updates
const unsubscribe = session.subscribe((serverMsg) => {
if (serverMsg.type === "full") {
this.emit({
type: "terminal_output",
payload: {
terminalId: msg.terminalId,
state: serverMsg.state,
},
});
}
});
this.terminalSubscriptions.set(msg.terminalId, unsubscribe);
// Send initial state
this.emit({
type: "subscribe_terminal_response",
payload: {
terminalId: msg.terminalId,
state: session.getState(),
error: null,
requestId: msg.requestId,
},
});
}
private handleUnsubscribeTerminalRequest(msg: UnsubscribeTerminalRequest): void {
const unsubscribe = this.terminalSubscriptions.get(msg.terminalId);
if (unsubscribe) {
unsubscribe();
this.terminalSubscriptions.delete(msg.terminalId);
}
}
private handleTerminalInput(msg: TerminalInput): void {
if (!this.terminalManager) {
return;
}
const session = this.terminalManager.getTerminal(msg.terminalId);
if (!session) {
this.sessionLogger.warn({ terminalId: msg.terminalId }, "Terminal not found for input");
return;
}
session.send(msg.message);
}
private async handleKillTerminalRequest(msg: KillTerminalRequest): Promise<void> {
if (!this.terminalManager) {
this.emit({
type: "kill_terminal_response",
payload: {
terminalId: msg.terminalId,
success: false,
requestId: msg.requestId,
},
});
return;
}
// Unsubscribe first
const unsubscribe = this.terminalSubscriptions.get(msg.terminalId);
if (unsubscribe) {
unsubscribe();
this.terminalSubscriptions.delete(msg.terminalId);
}
this.terminalManager.killTerminal(msg.terminalId);
this.emit({
type: "kill_terminal_response",
payload: {
terminalId: msg.terminalId,
success: true,
requestId: msg.requestId,
},
});
}
}
@@ -37,7 +37,6 @@ export async function createDaemonTestContext(
const daemon = await createTestPaseoDaemon(options);
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
authHeader: daemon.agentMcpAuthHeader,
});
await client.connect();
@@ -7,7 +7,6 @@ import pino from "pino";
import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js";
type TestPaseoDaemonOptions = {
basicUsers?: Record<string, string>;
downloadTokenTtlMs?: number;
corsAllowedOrigins?: string[];
listen?: string;
@@ -19,8 +18,6 @@ export type TestPaseoDaemon = {
port: number;
paseoHome: string;
staticDir: string;
agentMcpAuthHeader?: string;
agentMcpBearerToken?: string;
close: () => Promise<void>;
};
@@ -42,16 +39,6 @@ async function getAvailablePort(): Promise<number> {
export async function createTestPaseoDaemon(
options: TestPaseoDaemonOptions = {}
): Promise<TestPaseoDaemon> {
const basicUsers = options.basicUsers ?? { test: "pass" };
const [agentMcpUser, agentMcpPassword] = Object.entries(basicUsers)[0] ?? [];
const agentMcpAuthHeader =
agentMcpUser && agentMcpPassword
? `Basic ${Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")}`
: undefined;
const agentMcpBearerToken =
agentMcpUser && agentMcpPassword
? Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")
: undefined;
const openaiApiKey = process.env.OPENAI_API_KEY;
const maxAttempts = 5;
let lastError: unknown;
@@ -68,21 +55,12 @@ export async function createTestPaseoDaemon(
corsAllowedOrigins: options.corsAllowedOrigins ?? [],
agentMcpRoute: "/mcp/agents",
agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`, `${listenHost}:${port}`],
auth: {
basicUsers,
agentMcpAuthHeader,
agentMcpBearerToken,
realm: "Voice Assistant",
},
staticDir,
mcpDebug: false,
agentClients: {},
agentRegistryPath: path.join(paseoHome, "agents.json"),
agentControlMcp: {
url: `http://127.0.0.1:${port}/mcp/agents`,
...(agentMcpAuthHeader
? { headers: { Authorization: agentMcpAuthHeader } }
: {}),
},
openai: openaiApiKey ? { apiKey: openaiApiKey } : undefined,
downloadTokenTtlMs: options.downloadTokenTtlMs,
@@ -106,8 +84,6 @@ export async function createTestPaseoDaemon(
port,
paseoHome,
staticDir,
agentMcpAuthHeader,
agentMcpBearerToken,
close,
};
} catch (error) {
@@ -18,6 +18,7 @@ import { PushTokenStore } from "./push/token-store.js";
import { PushService } from "./push/push-service.js";
import type { OpenAISTT } from "./agent/stt-openai.js";
import type { OpenAITTS } from "./agent/tts-openai.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type pino from "pino";
type AgentMcpClientConfig = {
@@ -47,6 +48,7 @@ export class VoiceAssistantWebSocketServer {
private readonly agentMcpConfig: AgentMcpClientConfig;
private readonly stt: OpenAISTT | null;
private readonly tts: OpenAITTS | null;
private readonly terminalManager: TerminalManager | null;
constructor(
server: HTTPServer,
@@ -56,7 +58,8 @@ export class VoiceAssistantWebSocketServer {
downloadTokenStore: DownloadTokenStore,
agentMcpConfig: AgentMcpClientConfig,
wsConfig: WebSocketServerConfig,
speech?: { stt: OpenAISTT | null; tts: OpenAITTS | null }
speech?: { stt: OpenAISTT | null; tts: OpenAITTS | null },
terminalManager?: TerminalManager | null
) {
this.logger = logger.child({ module: "websocket-server" });
this.agentManager = agentManager;
@@ -64,6 +67,7 @@ export class VoiceAssistantWebSocketServer {
this.downloadTokenStore = downloadTokenStore;
this.stt = speech?.stt ?? null;
this.tts = speech?.tts ?? null;
this.terminalManager = terminalManager ?? null;
const pushLogger = this.logger.child({ module: "push" });
this.pushTokenStore = new PushTokenStore(pushLogger);
@@ -140,6 +144,7 @@ export class VoiceAssistantWebSocketServer {
this.agentMcpConfig,
this.stt,
this.tts,
this.terminalManager,
{
conversationId,
initialMessages: initialMessages || undefined,
+155 -10
View File
@@ -318,16 +318,11 @@ export const SendAgentMessageSchema = z.object({
})).optional(),
});
export const SendAgentAudioSchema = z.object({
type: z.literal("send_agent_audio"),
agentId: z.string().optional(), // Required for auto_run mode, optional for transcribe_only
export const TranscribeAudioRequestSchema = z.object({
type: z.literal("transcribe_audio_request"),
audio: z.string(), // base64 encoded
format: z.string(),
isLast: z.boolean(),
requestId: z.string(), // Client-provided ID for tracking transcription
mode: z
.enum(["transcribe_only", "auto_run"])
.optional(),
requestId: z.string(),
});
const GitSetupOptionsSchema = z.object({
@@ -528,6 +523,58 @@ export const RegisterPushTokenMessageSchema = z.object({
token: z.string(),
});
// ============================================================================
// Terminal Messages
// ============================================================================
export const ListTerminalsRequestSchema = z.object({
type: z.literal("list_terminals_request"),
cwd: z.string(),
requestId: z.string(),
});
export const CreateTerminalRequestSchema = z.object({
type: z.literal("create_terminal_request"),
cwd: z.string(),
name: z.string().optional(),
requestId: z.string(),
});
export const SubscribeTerminalRequestSchema = z.object({
type: z.literal("subscribe_terminal_request"),
terminalId: z.string(),
requestId: z.string(),
});
export const UnsubscribeTerminalRequestSchema = z.object({
type: z.literal("unsubscribe_terminal_request"),
terminalId: z.string(),
});
const TerminalClientMessageSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("input"), data: z.string() }),
z.object({ type: z.literal("resize"), rows: z.number(), cols: z.number() }),
z.object({
type: z.literal("mouse"),
row: z.number(),
col: z.number(),
button: z.number(),
action: z.enum(["down", "up", "move"]),
}),
]);
export const TerminalInputSchema = z.object({
type: z.literal("terminal_input"),
terminalId: z.string(),
message: TerminalClientMessageSchema,
});
export const KillTerminalRequestSchema = z.object({
type: z.literal("kill_terminal_request"),
terminalId: z.string(),
requestId: z.string(),
});
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
UserTextMessageSchema,
RealtimeAudioChunkMessageSchema,
@@ -539,7 +586,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
DeleteAgentRequestMessageSchema,
SetRealtimeModeMessageSchema,
SendAgentMessageSchema,
SendAgentAudioSchema,
TranscribeAudioRequestSchema,
CreateAgentRequestMessageSchema,
ListProviderModelsRequestMessageSchema,
ResumeAgentRequestMessageSchema,
@@ -558,6 +605,12 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ClientHeartbeatMessageSchema,
ListCommandsRequestSchema,
RegisterPushTokenMessageSchema,
ListTerminalsRequestSchema,
CreateTerminalRequestSchema,
SubscribeTerminalRequestSchema,
UnsubscribeTerminalRequestSchema,
TerminalInputSchema,
KillTerminalRequestSchema,
]);
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
@@ -884,6 +937,80 @@ export const ListCommandsResponseSchema = z.object({
}),
});
// ============================================================================
// Terminal Outbound Messages
// ============================================================================
const TerminalInfoSchema = z.object({
id: z.string(),
name: z.string(),
cwd: z.string(),
});
const TerminalCellSchema = z.object({
char: z.string(),
fg: z.number().optional(),
bg: z.number().optional(),
fgMode: z.number().optional(),
bgMode: z.number().optional(),
bold: z.boolean().optional(),
italic: z.boolean().optional(),
underline: z.boolean().optional(),
});
const TerminalStateSchema = z.object({
rows: z.number(),
cols: z.number(),
grid: z.array(z.array(TerminalCellSchema)),
scrollback: z.array(z.array(TerminalCellSchema)),
cursor: z.object({ row: z.number(), col: z.number() }),
});
export const ListTerminalsResponseSchema = z.object({
type: z.literal("list_terminals_response"),
payload: z.object({
cwd: z.string(),
terminals: z.array(TerminalInfoSchema.omit({ cwd: true })),
requestId: z.string(),
}),
});
export const CreateTerminalResponseSchema = z.object({
type: z.literal("create_terminal_response"),
payload: z.object({
terminal: TerminalInfoSchema.nullable(),
error: z.string().nullable(),
requestId: z.string(),
}),
});
export const SubscribeTerminalResponseSchema = z.object({
type: z.literal("subscribe_terminal_response"),
payload: z.object({
terminalId: z.string(),
state: TerminalStateSchema.nullable(),
error: z.string().nullable(),
requestId: z.string(),
}),
});
export const TerminalOutputSchema = z.object({
type: z.literal("terminal_output"),
payload: z.object({
terminalId: z.string(),
state: TerminalStateSchema,
}),
});
export const KillTerminalResponseSchema = z.object({
type: z.literal("kill_terminal_response"),
payload: z.object({
terminalId: z.string(),
success: z.boolean(),
requestId: z.string(),
}),
});
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ActivityLogMessageSchema,
AssistantChunkMessageSchema,
@@ -910,6 +1037,11 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
GitRepoInfoResponseSchema,
ListProviderModelsResponseMessageSchema,
ListCommandsResponseSchema,
ListTerminalsResponseSchema,
CreateTerminalResponseSchema,
SubscribeTerminalResponseSchema,
TerminalOutputSchema,
KillTerminalResponseSchema,
]);
export type SessionOutboundMessage = z.infer<
@@ -948,7 +1080,7 @@ export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
export type UserTextMessage = z.infer<typeof UserTextMessageSchema>;
export type RealtimeAudioChunkMessage = z.infer<typeof RealtimeAudioChunkMessageSchema>;
export type SendAgentMessage = z.infer<typeof SendAgentMessageSchema>;
export type SendAgentAudio = z.infer<typeof SendAgentAudioSchema>;
export type TranscribeAudioRequest = z.infer<typeof TranscribeAudioRequestSchema>;
export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>;
export type ListProviderModelsRequestMessage = z.infer<
typeof ListProviderModelsRequestMessageSchema
@@ -974,6 +1106,19 @@ export type ListCommandsRequest = z.infer<typeof ListCommandsRequestSchema>;
export type ListCommandsResponse = z.infer<typeof ListCommandsResponseSchema>;
export type RegisterPushTokenMessage = z.infer<typeof RegisterPushTokenMessageSchema>;
// Terminal message types
export type ListTerminalsRequest = z.infer<typeof ListTerminalsRequestSchema>;
export type ListTerminalsResponse = z.infer<typeof ListTerminalsResponseSchema>;
export type CreateTerminalRequest = z.infer<typeof CreateTerminalRequestSchema>;
export type CreateTerminalResponse = z.infer<typeof CreateTerminalResponseSchema>;
export type SubscribeTerminalRequest = z.infer<typeof SubscribeTerminalRequestSchema>;
export type SubscribeTerminalResponse = z.infer<typeof SubscribeTerminalResponseSchema>;
export type UnsubscribeTerminalRequest = z.infer<typeof UnsubscribeTerminalRequestSchema>;
export type TerminalInput = z.infer<typeof TerminalInputSchema>;
export type TerminalOutput = z.infer<typeof TerminalOutputSchema>;
export type KillTerminalRequest = z.infer<typeof KillTerminalRequestSchema>;
export type KillTerminalResponse = z.infer<typeof KillTerminalResponseSchema>;
// ============================================================================
// WebSocket Level Messages (wraps session messages)
// ============================================================================
+103 -66
View File
@@ -609,11 +609,28 @@ program
interface AgentConfig {
cli: "claude" | "codex";
model?: string;
effort?: string;
}
function getAgentConfig(model: ModelName): AgentConfig {
// Parse model string like "gpt-5.2-xhigh" into model and effort
function parseModelString(modelStr: string): { model: string; effort?: string } {
// GPT models with effort: gpt-5.2-low, gpt-5.2-medium, gpt-5.2-high, gpt-5.2-xhigh
const effortLevels = ["low", "medium", "high", "xhigh"];
for (const effort of effortLevels) {
if (modelStr.endsWith(`-${effort}`)) {
return {
model: modelStr.slice(0, -(effort.length + 1)),
effort,
};
}
}
return { model: modelStr };
}
function getAgentConfig(modelStr: string): AgentConfig {
const { model, effort } = parseModelString(modelStr);
if (model.startsWith("gpt-")) {
return { cli: "codex", model };
return { cli: "codex", model, effort };
}
// Claude CLI accepts aliases directly: haiku, sonnet, opus
return { cli: "claude", model };
@@ -621,10 +638,10 @@ function getAgentConfig(model: ModelName): AgentConfig {
function runAgentWithModel(
prompt: string,
model: ModelName,
modelStr: string,
logFile: string
): { success: boolean; output: string } {
const config = getAgentConfig(model);
const config = getAgentConfig(modelStr);
let args: string[];
if (config.cli === "claude") {
@@ -634,12 +651,13 @@ function runAgentWithModel(
}
args.push("-p", prompt);
} else {
const effort = config.effort ?? "medium";
args = [
"exec",
"--dangerously-bypass-approvals-and-sandbox",
"--skip-git-repo-check",
"-c",
"model_reasoning_effort=\"medium\"",
`model_reasoning_effort="${effort}"`,
];
if (config.model) {
args.push("--model", config.model);
@@ -686,28 +704,9 @@ async function buildTaskContext(task: Task, scopeId?: string): Promise<string> {
}
}
let acceptanceCriteriaText = "";
if (task.acceptanceCriteria.length > 0) {
acceptanceCriteriaText = "\n## Acceptance Criteria\n\n";
for (const criterion of task.acceptanceCriteria) {
acceptanceCriteriaText += `- [ ] ${criterion}\n`;
}
}
let notesText = "";
if (task.notes.length > 0) {
notesText = "\n## Notes\n";
for (const note of task.notes) {
notesText += `\n**${note.timestamp}**\n${note.content}\n`;
}
}
return `Working directory: ${process.cwd()}
${scopeContext}${parentContext}
# Task: ${task.title}
${task.body || "(no body)"}
${acceptanceCriteriaText}${notesText}`;
${task.raw}`;
}
function makePlannerPrompt(task: Task, context: string, reason: string): string {
@@ -917,36 +916,30 @@ You are a WORKER agent implementing this task.
Current iteration: ${iteration}
IMPORTANT RULES:
- You CANNOT mark this task as done (task close is forbidden for you)
## How to Work
Make MEANINGFUL progress toward the acceptance criteria:
- If the task can be completed in one iteration, do it all
- If it's too large, make the largest meaningful chunk of progress you can
- Read the notes from previous iterations - don't repeat work, BUILD on it
- A judge will verify your work against the acceptance criteria and leave feedback
The judge's feedback (in notes) tells you exactly what's still failing. Address it.
## Rules
- You CANNOT mark this task as done (a judge verifies completion)
- You MUST add a note documenting what you did: \`task note ${task.id} "WORKER: what you did"\`
- You CAN use \`task show ${task.id}\` to see full context
- You CAN use \`task children ${task.id}\` to see subtasks
- You CAN use \`task show ${task.id}\` to refresh context
Focus on implementing the task. The acceptance criteria will be verified by a separate judge agent.
When you've done your best work, add a note explaining what you did and exit.
When you've made meaningful progress, add a note explaining what you did and exit.
`;
}
function makeJudgePrompt(task: Task): string {
let criteriaText = "";
if (task.acceptanceCriteria.length > 0) {
criteriaText = "## Acceptance Criteria to Verify\n\n";
for (let i = 0; i < task.acceptanceCriteria.length; i++) {
criteriaText += `${i + 1}. ${task.acceptanceCriteria[i]}\n`;
}
} else {
criteriaText = "No explicit acceptance criteria defined. Verify the task is reasonably complete based on the title and body.\n";
}
return `You are a JUDGE agent. Your ONLY job is to verify if acceptance criteria are met.
# Task: ${task.title}
${task.body || "(no body)"}
${criteriaText}
${task.raw}
## Your Instructions
@@ -954,6 +947,28 @@ ${criteriaText}
2. You may run commands to check (e.g., \`npm run test\`, \`npm run typecheck\`, check file existence, run the code)
3. After checking all criteria, output your verdict
## FAIL EARLY for NOT_DONE
For large tasks, the worker may yield after implementing incremental progress. Don't waste time
checking every criterion if it's obvious the task isn't complete.
**Fail early strategy:**
- Start with a quick smoke test (e.g., does \`npm run typecheck\` or \`npm test\` pass?)
- If the smoke test fails badly, you can immediately return NOT_DONE
- If you find ANY failing criterion, you can immediately return NOT_DONE
- No need to check all criteria if you already know the verdict is NOT_DONE
**Example:** If you run \`npm test\` and see 15 failures, don't methodically check each acceptance
criterion - just note the failures and return NOT_DONE.
## For DONE: Full Verification Required
Unlike NOT_DONE, you CANNOT shortcut DONE. To mark something DONE:
- You MUST verify EVERY acceptance criterion explicitly
- You MUST run the actual commands to check (not assume from context)
- You MUST confirm each criterion passes before concluding DONE
- No shortcuts, no assumptions, no "it probably works"
## CRITICAL: No Excuses Policy
You are evaluating RESULTS, not effort or intent. The following are NOT acceptable reasons to mark something DONE:
@@ -977,18 +992,38 @@ After verifying, you MUST leave feedback for the worker by adding a note. The wo
this note in the next iteration - it's their ONLY way to know what went wrong and what's missing.
\`\`\`
task note ${task.id} "JUDGE: [verdict] - [specific feedback for the worker]"
task note ${task.id} "JUDGE: [verdict] - [observed facts only]"
\`\`\`
Your feedback should tell the worker exactly what to fix:
- Which specific tests failed and with what error messages
- Which files or commands still need attention
- What's missing from the acceptance criteria
**CRITICAL: State ONLY observed facts. Do NOT provide solutions or debugging advice.**
BAD: "tests failed" (useless - worker doesn't know which ones)
GOOD: "3 tests fail: codex-mcp-agent.test.ts 'Session not found', opencode-agent.test.ts timeout after 60s, tool-calls.e2e.test.ts shell assertion"
You are a verification agent, not a debugging assistant. The worker has access to the task body
which contains the full context and instructions. Your job is to report WHAT failed, not HOW to fix it.
The worker cannot see your output - only the notes you leave.
DO:
- State which tests failed and their error messages
- State which commands returned non-zero exit codes
- State which files are missing or have wrong content
- Quote exact error output
DO NOT:
- Suggest fixes or solutions
- Explain why something might have failed
- Offer debugging strategies
- Recommend approaches or alternatives
BAD: "3 tests fail - try mocking the database connection"
BAD: "typecheck fails - you need to add the missing type annotation"
GOOD: "3 tests fail: auth.test.ts:42 'expected 200, got 401', user.test.ts:15 'timeout after 5000ms'"
GOOD: "npm run typecheck exit 1: src/api.ts:23 - Property 'foo' does not exist on type 'Bar'"
For non-test criteria, state observable facts about the code:
GOOD: "validateUser() in auth.ts:45-72 duplicates validateAdmin() in admin.ts:23-50 (criterion: no code duplication)"
GOOD: "processOrder() is 187 lines (criterion: functions under 50 lines)"
GOOD: "UserService calls database directly at line 34 (criterion: all DB access via repository layer)"
GOOD: "auth module exports JWT secret at line 12 (criterion: secrets not exported from modules)"
The worker has the task body with full instructions. Just tell them what's broken.
## Output Format - CRITICAL
@@ -1030,26 +1065,29 @@ program
.description("Run agent loop on tasks with planner/worker/judge")
.option("--plan", "Enable planner agent")
.option("--planner <model>", "Planner model (default: gpt-5.2)", "gpt-5.2")
.option("--judge <model>", "Judge model (default: haiku)", "haiku")
.option("--worker-model <model>", "Worker model (default: sonnet). For GPT models, append effort: gpt-5.2-high", "sonnet")
.option("--judge-model <model>", "Judge model (default: haiku)", "haiku")
.option("--max-iterations <n>", "Max worker/judge iterations per task (0 = no limit)", "0")
.option("-w, --watch", "Keep running and wait for new tasks")
.action(async (scopeId: string | undefined, opts) => {
const enablePlanner = opts.plan;
const plannerModel = opts.planner as ModelName;
const judgeModel = opts.judge as ModelName;
const plannerModel = opts.planner as string;
const baseWorkerModel = opts.workerModel as string;
const judgeModel = opts.judgeModel as string;
const maxIterations = parseInt(opts.maxIterations, 10);
const watchMode = opts.watch;
const logFile = getLogFile();
process.stdout.write("Task Runner started (planner/worker/judge loop)\n");
process.stdout.write(`Planner: ${enablePlanner ? plannerModel : "disabled"}\n`);
process.stdout.write(`Worker: ${baseWorkerModel}\n`);
process.stdout.write(`Judge: ${judgeModel}\n`);
process.stdout.write(`Max iterations: ${maxIterations === 0 ? "unlimited" : maxIterations}\n`);
if (scopeId) process.stdout.write(`Scope: ${scopeId}\n`);
process.stdout.write(`Log: ${logFile}\n`);
process.stdout.write("\n");
log(logFile, `Started with planner=${enablePlanner ? plannerModel : "disabled"} judge=${judgeModel} maxIter=${maxIterations} scope=${scopeId || "all"}`);
log(logFile, `Started with planner=${enablePlanner ? plannerModel : "disabled"} worker=${baseWorkerModel} judge=${judgeModel} maxIter=${maxIterations} scope=${scopeId || "all"}`);
const runPlanner = async (task: Task, reason: string): Promise<boolean> => {
log(logFile, `[PLANNER] Running ${plannerModel} (${reason})...`);
@@ -1113,13 +1151,12 @@ program
}
const task = ready[0];
const baseModel = (
task.assignee === "codex" ? "gpt-5.2-codex" :
task.assignee ? task.assignee :
"sonnet"
) as ModelName;
let workerModel = baseModel;
const canUpgrade = baseModel === "sonnet"; // Only upgrade if starting from sonnet
// CLI --worker-model takes precedence, then task assignee, then default
let workerModel = baseWorkerModel;
if (!workerModel && task.assignee) {
workerModel = task.assignee === "codex" ? "gpt-5.2-codex" : task.assignee;
}
const canUpgrade = workerModel === "sonnet"; // Only upgrade if starting from sonnet
log(logFile, `\n=== Starting task: ${task.id} - ${task.title} ===`);
await store.start(task.id);
@@ -103,6 +103,20 @@ describe("FileTaskStore", () => {
expect(task.created >= before).toBe(true);
expect(task.created <= after).toBe(true);
});
it("returns task with raw content", async () => {
const task = await store.create("Test task", {
body: "Task body here",
acceptanceCriteria: ["criterion 1", "criterion 2"],
});
expect(task.raw).toContain("---");
expect(task.raw).toContain("title: Test task");
expect(task.raw).toContain("Task body here");
expect(task.raw).toContain("## Acceptance Criteria");
expect(task.raw).toContain("criterion 1");
expect(task.raw).toContain("criterion 2");
});
});
describe("get", () => {
@@ -125,6 +139,24 @@ describe("FileTaskStore", () => {
expect(retrieved?.assignee).toBe("codex");
});
it("returns raw content matching the file", async () => {
const created = await store.create("Task with body", {
body: "Some body content",
acceptanceCriteria: ["test passes", "lint passes"],
});
await store.addNote(created.id, "A note was added");
const retrieved = await store.get(created.id);
expect(retrieved?.raw).toContain("title: Task with body");
expect(retrieved?.raw).toContain("Some body content");
expect(retrieved?.raw).toContain("## Acceptance Criteria");
expect(retrieved?.raw).toContain("test passes");
expect(retrieved?.raw).toContain("lint passes");
expect(retrieved?.raw).toContain("## Notes");
expect(retrieved?.raw).toContain("A note was added");
});
});
describe("list", () => {
@@ -935,6 +967,29 @@ describe("FileTaskStore", () => {
expect(retrieved?.priority).toBe(3);
});
it("persists raw content across store instances", async () => {
const task = await store.create("Task with everything", {
body: "Detailed body",
acceptanceCriteria: ["tests pass", "build succeeds"],
assignee: "claude",
priority: 1,
});
await store.addNote(task.id, "Implementation note");
const store2 = new FileTaskStore(tempDir);
const retrieved = await store2.get(task.id);
expect(retrieved?.raw).toContain("title: Task with everything");
expect(retrieved?.raw).toContain("Detailed body");
expect(retrieved?.raw).toContain("## Acceptance Criteria");
expect(retrieved?.raw).toContain("tests pass");
expect(retrieved?.raw).toContain("build succeeds");
expect(retrieved?.raw).toContain("## Notes");
expect(retrieved?.raw).toContain("Implementation note");
expect(retrieved?.raw).toContain("assignee: claude");
expect(retrieved?.raw).toContain("priority: 1");
});
});
describe("parent-child hierarchy", () => {
+5 -1
View File
@@ -155,6 +155,7 @@ function parseTask(content: string): Task {
created: getValue("created") || new Date().toISOString(),
assignee: assignee || undefined,
priority,
raw: content,
};
}
@@ -387,10 +388,13 @@ export class FileTaskStore implements TaskStore {
created: new Date().toISOString(),
assignee: opts?.assignee,
priority: opts?.priority,
raw: "", // will be set after serialization
};
await this.writeTask(task);
return task;
// Re-read to get the raw content
const saved = await this.get(task.id);
return saved!;
}
async update(
+1
View File
@@ -25,6 +25,7 @@ export interface Task {
created: string; // ISO date
assignee?: AgentType; // optional agent override
priority?: number; // lower number = higher priority (1 is highest), tasks with priority sort before those without
raw: string; // the raw markdown file content
}
export interface CreateTaskOptions {
@@ -0,0 +1,172 @@
import { describe, it, expect, afterEach } from "vitest";
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
describe("TerminalManager", () => {
let manager: TerminalManager;
afterEach(() => {
if (manager) {
manager.killAll();
}
});
describe("getTerminals", () => {
it("auto-creates first terminal for new cwd", async () => {
manager = createTerminalManager();
const terminals = await manager.getTerminals("/tmp");
expect(terminals.length).toBe(1);
expect(terminals[0].name).toBe("Terminal 1");
expect(terminals[0].cwd).toBe("/tmp");
});
it("returns existing terminals on subsequent calls", async () => {
manager = createTerminalManager();
const first = await manager.getTerminals("/tmp");
const second = await manager.getTerminals("/tmp");
expect(first).toBe(second);
expect(first.length).toBe(1);
});
it("throws for relative paths", async () => {
manager = createTerminalManager();
await expect(manager.getTerminals("tmp")).rejects.toThrow("cwd must be absolute path");
});
it("creates separate terminals for different cwds", async () => {
manager = createTerminalManager();
const tmpTerminals = await manager.getTerminals("/tmp");
const homeTerminals = await manager.getTerminals("/home");
expect(tmpTerminals.length).toBe(1);
expect(homeTerminals.length).toBe(1);
expect(tmpTerminals[0].id).not.toBe(homeTerminals[0].id);
});
});
describe("createTerminal", () => {
it("creates additional terminal with auto-incrementing name", async () => {
manager = createTerminalManager();
await manager.getTerminals("/tmp");
const second = await manager.createTerminal({ cwd: "/tmp" });
expect(second.name).toBe("Terminal 2");
const terminals = await manager.getTerminals("/tmp");
expect(terminals.length).toBe(2);
});
it("uses custom name when provided", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" });
expect(session.name).toBe("Dev Server");
});
it("creates first terminal if none exist", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp" });
expect(session.name).toBe("Terminal 1");
const terminals = await manager.getTerminals("/tmp");
expect(terminals.length).toBe(1);
expect(terminals[0].id).toBe(session.id);
});
it("throws for relative paths", async () => {
manager = createTerminalManager();
await expect(manager.createTerminal({ cwd: "tmp" })).rejects.toThrow("cwd must be absolute path");
});
});
describe("getTerminal", () => {
it("returns terminal by id", async () => {
manager = createTerminalManager();
const terminals = await manager.getTerminals("/tmp");
const found = manager.getTerminal(terminals[0].id);
expect(found).toBe(terminals[0]);
});
it("returns undefined for unknown id", () => {
manager = createTerminalManager();
const found = manager.getTerminal("unknown-id");
expect(found).toBeUndefined();
});
});
describe("killTerminal", () => {
it("removes terminal from manager", async () => {
manager = createTerminalManager();
const terminals = await manager.getTerminals("/tmp");
const id = terminals[0].id;
manager.killTerminal(id);
expect(manager.getTerminal(id)).toBeUndefined();
});
it("removes cwd entry when last terminal is killed", async () => {
manager = createTerminalManager();
const terminals = await manager.getTerminals("/tmp");
manager.killTerminal(terminals[0].id);
expect(manager.listDirectories()).not.toContain("/tmp");
});
it("keeps cwd entry when other terminals remain", async () => {
manager = createTerminalManager();
await manager.getTerminals("/tmp");
const second = await manager.createTerminal({ cwd: "/tmp" });
const terminals = await manager.getTerminals("/tmp");
manager.killTerminal(terminals[0].id);
expect(manager.listDirectories()).toContain("/tmp");
const remaining = await manager.getTerminals("/tmp");
expect(remaining.length).toBe(1);
expect(remaining[0].id).toBe(second.id);
});
it("is no-op for unknown id", () => {
manager = createTerminalManager();
expect(() => manager.killTerminal("unknown-id")).not.toThrow();
});
});
describe("listDirectories", () => {
it("returns empty array initially", () => {
manager = createTerminalManager();
expect(manager.listDirectories()).toEqual([]);
});
it("returns all cwds with active terminals", async () => {
manager = createTerminalManager();
await manager.getTerminals("/tmp");
await manager.getTerminals("/home");
const dirs = manager.listDirectories();
expect(dirs).toContain("/tmp");
expect(dirs).toContain("/home");
expect(dirs.length).toBe(2);
});
});
describe("killAll", () => {
it("kills all terminals and clears state", async () => {
manager = createTerminalManager();
const tmpTerminals = await manager.getTerminals("/tmp");
const homeTerminals = await manager.getTerminals("/home");
manager.killAll();
expect(manager.listDirectories()).toEqual([]);
expect(manager.getTerminal(tmpTerminals[0].id)).toBeUndefined();
expect(manager.getTerminal(homeTerminals[0].id)).toBeUndefined();
});
});
});
@@ -0,0 +1,88 @@
import { createTerminal, type TerminalSession } from "./terminal.js";
export interface TerminalManager {
getTerminals(cwd: string): Promise<TerminalSession[]>;
createTerminal(options: { cwd: string; name?: string }): Promise<TerminalSession>;
getTerminal(id: string): TerminalSession | undefined;
killTerminal(id: string): void;
listDirectories(): string[];
killAll(): void;
}
export function createTerminalManager(): TerminalManager {
const terminalsByCwd = new Map<string, TerminalSession[]>();
const terminalsById = new Map<string, TerminalSession>();
function assertAbsolutePath(cwd: string): void {
if (!cwd.startsWith("/")) {
throw new Error("cwd must be absolute path");
}
}
return {
async getTerminals(cwd: string): Promise<TerminalSession[]> {
assertAbsolutePath(cwd);
let terminals = terminalsByCwd.get(cwd);
if (!terminals || terminals.length === 0) {
const session = await createTerminal({ cwd, name: "Terminal 1" });
terminals = [session];
terminalsByCwd.set(cwd, terminals);
terminalsById.set(session.id, session);
}
return terminals;
},
async createTerminal(options: { cwd: string; name?: string }): Promise<TerminalSession> {
assertAbsolutePath(options.cwd);
const terminals = terminalsByCwd.get(options.cwd) ?? [];
const defaultName = `Terminal ${terminals.length + 1}`;
const session = await createTerminal({
cwd: options.cwd,
name: options.name ?? defaultName,
});
terminals.push(session);
terminalsByCwd.set(options.cwd, terminals);
terminalsById.set(session.id, session);
return session;
},
getTerminal(id: string): TerminalSession | undefined {
return terminalsById.get(id);
},
killTerminal(id: string): void {
const session = terminalsById.get(id);
if (!session) return;
session.kill();
terminalsById.delete(id);
const terminals = terminalsByCwd.get(session.cwd);
if (terminals) {
const index = terminals.indexOf(session);
if (index !== -1) {
terminals.splice(index, 1);
}
if (terminals.length === 0) {
terminalsByCwd.delete(session.cwd);
}
}
},
listDirectories(): string[] {
return Array.from(terminalsByCwd.keys());
},
killAll(): void {
for (const session of terminalsById.values()) {
session.kill();
}
terminalsByCwd.clear();
terminalsById.clear();
},
};
}
@@ -0,0 +1,565 @@
import { describe, it, expect, afterEach } from "vitest";
import { createTerminal, type TerminalSession } from "./terminal.js";
// Extract text from a single row
function getRowText(state: ReturnType<TerminalSession["getState"]>, rowIndex: number): string {
return state.grid[rowIndex].map((cell) => cell.char).join("").trimEnd();
}
// Extract all visible lines as array (trimmed, empty lines included)
function getLines(state: ReturnType<TerminalSession["getState"]>): string[] {
return state.grid.map((row) => row.map((cell) => cell.char).join("").trimEnd());
}
// Wait for terminal state to match expected lines
async function waitForLines(
session: TerminalSession,
expectedLines: string[],
timeoutMs = 5000
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const lines = getLines(session.getState());
let matches = true;
for (let i = 0; i < expectedLines.length; i++) {
if (lines[i] !== expectedLines[i]) {
matches = false;
break;
}
}
if (matches) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
const actual = getLines(session.getState()).slice(0, expectedLines.length);
throw new Error(
`Timeout waiting for expected lines.\nExpected:\n${JSON.stringify(expectedLines, null, 2)}\nActual:\n${JSON.stringify(actual, null, 2)}`
);
}
describe("Terminal", () => {
const sessions: TerminalSession[] = [];
afterEach(async () => {
for (const session of sessions) {
session.kill();
}
sessions.length = 0;
});
function trackSession(session: TerminalSession): TerminalSession {
sessions.push(session);
return session;
}
describe("createTerminal", () => {
it("creates a terminal session with an id, name, and cwd", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
})
);
expect(session.id).toBeDefined();
expect(typeof session.id).toBe("string");
expect(session.id.length).toBeGreaterThan(0);
expect(session.name).toBe("Terminal");
expect(session.cwd).toBe("/tmp");
});
it("uses custom name when provided", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
name: "Dev Server",
})
);
expect(session.name).toBe("Dev Server");
});
it("uses default shell if not specified", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
})
);
expect(session.id).toBeDefined();
});
it("uses default rows and cols", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
})
);
const state = session.getState();
expect(state.rows).toBe(24);
expect(state.cols).toBe(80);
});
it("respects custom rows and cols", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
rows: 40,
cols: 120,
})
);
const state = session.getState();
expect(state.rows).toBe(40);
expect(state.cols).toBe(120);
});
});
describe("send input", () => {
it("executes a simple echo command", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
})
);
// Wait for initial prompt, then send command
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "echo hello\r" });
// After running "echo hello", terminal should show:
// Line 0: "$ echo hello"
// Line 1: "hello"
// Line 2: "$"
await waitForLines(session, ["$ echo hello", "hello", "$"]);
const state = session.getState();
expect(getRowText(state, 0)).toBe("$ echo hello");
expect(getRowText(state, 1)).toBe("hello");
expect(getRowText(state, 2)).toBe("$");
});
it("executes multiple commands sequentially", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
})
);
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "echo first\r" });
await waitForLines(session, ["$ echo first", "first", "$"]);
session.send({ type: "input", data: "echo second\r" });
await waitForLines(session, [
"$ echo first",
"first",
"$ echo second",
"second",
"$",
]);
const state = session.getState();
expect(getRowText(state, 0)).toBe("$ echo first");
expect(getRowText(state, 1)).toBe("first");
expect(getRowText(state, 2)).toBe("$ echo second");
expect(getRowText(state, 3)).toBe("second");
expect(getRowText(state, 4)).toBe("$");
});
it("captures output from pwd in specified cwd", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
})
);
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "pwd\r" });
await waitForLines(session, ["$ pwd", "/tmp", "$"]);
const state = session.getState();
expect(getRowText(state, 0)).toBe("$ pwd");
expect(getRowText(state, 1)).toBe("/tmp");
expect(getRowText(state, 2)).toBe("$");
});
});
describe("colors", () => {
it("captures ANSI 16 color codes (mode 1)", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ ", TERM: "xterm-256color" },
})
);
await waitForLines(session, ["$"]);
// \033[31m = ANSI red (color 1)
session.send({ type: "input", data: "printf '\\033[31mRED\\033[0m'\r" });
await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]);
const state = session.getState();
const outputRow = state.grid[1];
expect(outputRow[0].char).toBe("R");
expect(outputRow[0].fg).toBe(1); // ANSI red = 1
expect(outputRow[0].fgMode).toBe(1); // Mode 1 = 16 ANSI colors
// The "$" after RED should have default color
expect(outputRow[3].char).toBe("$");
expect(outputRow[3].fg).toBe(undefined);
expect(outputRow[3].fgMode).toBe(undefined);
});
it("captures 256 color codes (mode 2)", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ ", TERM: "xterm-256color" },
})
);
await waitForLines(session, ["$"]);
// \033[38;5;208m = 256-color orange (color 208)
session.send({ type: "input", data: "printf '\\033[38;5;208mORG\\033[0m'\r" });
await waitForLines(session, ["$ printf '\\033[38;5;208mORG\\033[0m'", "ORG$"]);
const state = session.getState();
const outputRow = state.grid[1];
// Check O cell
expect(outputRow[0].char).toBe("O");
expect(outputRow[0].fg).toBe(208); // 256-color index
expect(outputRow[0].fgMode).toBe(2); // Mode 2 = 256 colors
});
it("captures true color RGB (mode 3)", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ ", TERM: "xterm-256color" },
})
);
await waitForLines(session, ["$"]);
// \033[38;2;255;128;64m = true color RGB(255, 128, 64)
session.send({ type: "input", data: "printf '\\033[38;2;255;128;64mRGB\\033[0m'\r" });
await waitForLines(session, ["$ printf '\\033[38;2;255;128;64mRGB\\033[0m'", "RGB$"]);
const state = session.getState();
const outputRow = state.grid[1];
// Check R cell
expect(outputRow[0].char).toBe("R");
expect(outputRow[0].fgMode).toBe(3); // Mode 3 = true color
// The color value should be packed RGB: (255 << 16) | (128 << 8) | 64
const expectedPacked = (255 << 16) | (128 << 8) | 64;
expect(outputRow[0].fg).toBe(expectedPacked);
});
it("captures background colors", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ ", TERM: "xterm-256color" },
})
);
await waitForLines(session, ["$"]);
// \033[41m = ANSI red background
session.send({ type: "input", data: "printf '\\033[41mBG\\033[0m'\r" });
await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]);
const state = session.getState();
const outputRow = state.grid[1];
expect(outputRow[0].char).toBe("B");
expect(outputRow[0].bg).toBe(1); // ANSI red = 1
expect(outputRow[0].bgMode).toBe(1); // Mode 1 = 16 ANSI colors
});
});
describe("resize", () => {
it("updates terminal dimensions on resize", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
rows: 24,
cols: 80,
})
);
session.send({ type: "resize", rows: 40, cols: 120 });
await new Promise((resolve) => setTimeout(resolve, 100));
const state = session.getState();
expect(state.rows).toBe(40);
expect(state.cols).toBe(120);
});
it("grid reflects new dimensions after resize", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
rows: 24,
cols: 80,
})
);
session.send({ type: "resize", rows: 10, cols: 40 });
await new Promise((resolve) => setTimeout(resolve, 100));
const state = session.getState();
expect(state.grid.length).toBe(10);
expect(state.grid[0].length).toBe(40);
});
});
describe("subscribe", () => {
it("receives full state on initial subscription", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
})
);
const messages: Array<{ type: string }> = [];
const unsubscribe = session.subscribe((msg) => {
messages.push(msg);
});
await new Promise((resolve) => setTimeout(resolve, 100));
expect(messages.length).toBeGreaterThan(0);
expect(messages[0].type).toBe("full");
unsubscribe();
});
it("receives messages on updates", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
})
);
await waitForLines(session, ["$"]);
const messages: Array<{ type: string }> = [];
const unsubscribe = session.subscribe((msg) => {
messages.push(msg);
});
messages.length = 0;
session.send({ type: "input", data: "echo test\r" });
await waitForLines(session, ["$ echo test", "test", "$"]);
expect(messages.length).toBeGreaterThan(0);
unsubscribe();
});
it("unsubscribe stops receiving messages", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
})
);
await waitForLines(session, ["$"]);
const messages: Array<{ type: string }> = [];
const unsubscribe = session.subscribe((msg) => {
messages.push(msg);
});
unsubscribe();
messages.length = 0;
session.send({ type: "input", data: "echo after\r" });
await new Promise((resolve) => setTimeout(resolve, 200));
expect(messages.length).toBe(0);
});
});
describe("getState", () => {
it("returns current terminal state with grid", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
rows: 24,
cols: 80,
})
);
const state = session.getState();
expect(state.rows).toBe(24);
expect(state.cols).toBe(80);
expect(state.grid).toBeDefined();
expect(state.grid.length).toBe(24);
expect(state.grid[0].length).toBe(80);
expect(state.cursor).toBeDefined();
expect(typeof state.cursor.row).toBe("number");
expect(typeof state.cursor.col).toBe("number");
});
it("grid cells have char and color attributes", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
})
);
await waitForLines(session, ["$"]);
const state = session.getState();
// First cell should be "$"
expect(state.grid[0][0].char).toBe("$");
expect(state.grid[0][0]).toHaveProperty("fg");
expect(state.grid[0][0]).toHaveProperty("bg");
});
});
describe("scrollback", () => {
it("preserves scrollback buffer", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
rows: 5,
cols: 80,
})
);
await waitForLines(session, ["$"]);
// seq 1 20 produces 20 lines of output
// With 5 rows, we expect lines to scroll into scrollback
session.send({ type: "input", data: "seq 1 20\r" });
// Wait for command to finish - final prompt appears after "20"
// In a 5-row terminal, we'll see the last lines plus prompt
// The visible area will show something like: 17, 18, 19, 20, $
await waitForLines(session, ["17", "18", "19", "20", "$"]);
const state = session.getState();
// Scrollback should contain the earlier output
expect(state.scrollback.length).toBeGreaterThan(0);
const scrollbackText = state.scrollback
.map((row) => row.map((cell) => cell.char).join("").trimEnd())
.filter((line) => line.length > 0);
// The scrollback should contain the command and early numbers
expect(scrollbackText).toContain("$ seq 1 20");
expect(scrollbackText).toContain("1");
expect(scrollbackText).toContain("2");
expect(scrollbackText).toContain("3");
});
});
describe("kill", () => {
it("terminates the shell process", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
})
);
await waitForLines(session, ["$"]);
session.kill();
// Should not throw when trying to get state after kill
const state = session.getState();
expect(state).toBeDefined();
});
it("send after kill is a no-op", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
})
);
session.kill();
// Should not throw
session.send({ type: "input", data: "echo test\r" });
});
});
describe("mouse events", () => {
it("accepts mouse events without throwing", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
})
);
// Should not throw
session.send({ type: "mouse", row: 0, col: 0, button: 0, action: "down" });
session.send({ type: "mouse", row: 0, col: 0, button: 0, action: "up" });
});
});
});
+276
View File
@@ -0,0 +1,276 @@
import * as pty from "node-pty";
import xterm, { type Terminal as TerminalType } from "@xterm/headless";
import { randomUUID } from "crypto";
const { Terminal } = xterm;
export interface Cell {
char: string;
fg: number | undefined;
bg: number | undefined;
fgMode?: number; // 0=default, 1=16 ANSI, 2=256, 3=RGB
bgMode?: number;
bold?: boolean;
italic?: boolean;
underline?: boolean;
}
export interface Pos {
row: number;
col: number;
}
export interface TerminalState {
rows: number;
cols: number;
grid: Cell[][];
scrollback: Cell[][];
cursor: Pos;
}
export interface CellChange {
row: number;
col: number;
cell: Cell;
}
export type ClientMessage =
| { type: "input"; data: string }
| { type: "resize"; rows: number; cols: number }
| { type: "mouse"; row: number; col: number; button: number; action: "down" | "up" | "move" };
export type ServerMessage =
| { type: "full"; state: TerminalState }
| { type: "diff"; changes: CellChange[]; cursor: Pos };
export interface TerminalSession {
id: string;
name: string;
cwd: string;
send(msg: ClientMessage): void;
subscribe(listener: (msg: ServerMessage) => void): () => void;
getState(): TerminalState;
kill(): void;
}
export interface CreateTerminalOptions {
cwd: string;
shell?: string;
env?: Record<string, string>;
rows?: number;
cols?: number;
name?: string;
}
function extractCell(terminal: TerminalType, row: number, col: number): Cell {
const buffer = terminal.buffer.active;
const line = buffer.getLine(row);
if (!line) {
return { char: " ", fg: undefined, bg: undefined };
}
const cell = line.getCell(col);
if (!cell) {
return { char: " ", fg: undefined, bg: undefined };
}
// Color modes from xterm.js: 0=DEFAULT, 1=16 colors (ANSI), 2=256 colors, 3=RGB
// getFgColorMode() returns packed value with mode in upper byte (e.g. 0x01000000 for mode 1)
const fgModeRaw = cell.getFgColorMode();
const bgModeRaw = cell.getBgColorMode();
const fgMode = fgModeRaw >> 24;
const bgMode = bgModeRaw >> 24;
// Only return color if not default (mode 0)
const fg = fgMode !== 0 ? cell.getFgColor() : undefined;
const bg = bgMode !== 0 ? cell.getBgColor() : undefined;
return {
char: cell.getChars() || " ",
fg,
bg,
fgMode: fgMode !== 0 ? fgMode : undefined,
bgMode: bgMode !== 0 ? bgMode : undefined,
bold: cell.isBold() !== 0,
italic: cell.isItalic() !== 0,
underline: cell.isUnderline() !== 0,
};
}
function extractGrid(terminal: TerminalType): Cell[][] {
const grid: Cell[][] = [];
const buffer = terminal.buffer.active;
// Visible viewport starts at baseY
const baseY = buffer.baseY;
for (let row = 0; row < terminal.rows; row++) {
const rowCells: Cell[] = [];
for (let col = 0; col < terminal.cols; col++) {
rowCells.push(extractCell(terminal, baseY + row, col));
}
grid.push(rowCells);
}
return grid;
}
function extractScrollback(terminal: TerminalType): Cell[][] {
const scrollback: Cell[][] = [];
const buffer = terminal.buffer.active;
// baseY is the first row of the visible viewport (0-indexed)
// Lines 0 to baseY-1 are in scrollback, lines baseY onwards are visible
const scrollbackLines = buffer.baseY;
for (let row = 0; row < scrollbackLines; row++) {
const rowCells: Cell[] = [];
const line = buffer.getLine(row);
for (let col = 0; col < terminal.cols; col++) {
if (line) {
const cell = line.getCell(col);
if (cell) {
const fgModeRaw = cell.getFgColorMode();
const bgModeRaw = cell.getBgColorMode();
const fgMode = fgModeRaw >> 24;
const bgMode = bgModeRaw >> 24;
const fg = fgMode !== 0 ? cell.getFgColor() : undefined;
const bg = bgMode !== 0 ? cell.getBgColor() : undefined;
rowCells.push({
char: cell.getChars() || " ",
fg,
bg,
fgMode: fgMode !== 0 ? fgMode : undefined,
bgMode: bgMode !== 0 ? bgMode : undefined,
bold: cell.isBold() !== 0,
italic: cell.isItalic() !== 0,
underline: cell.isUnderline() !== 0,
});
} else {
rowCells.push({ char: " ", fg: undefined, bg: undefined });
}
} else {
rowCells.push({ char: " ", fg: undefined, bg: undefined });
}
}
scrollback.push(rowCells);
}
return scrollback;
}
export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> {
const {
cwd,
shell = process.env.SHELL || "/bin/sh",
env = {},
rows = 24,
cols = 80,
name = "Terminal",
} = options;
const id = randomUUID();
const listeners = new Set<(msg: ServerMessage) => void>();
let killed = false;
// Create xterm.js headless terminal
const terminal = new Terminal({
rows,
cols,
scrollback: 1000,
allowProposedApi: true,
});
// Create PTY
const ptyProcess = pty.spawn(shell, [], {
name: "xterm-256color",
cols,
rows,
cwd,
env: {
...process.env,
...env,
TERM: "xterm-256color",
},
});
// Pipe PTY output to terminal emulator
ptyProcess.onData((data) => {
if (killed) return;
terminal.write(data, () => {
// Notify listeners of changes
const state = getState();
for (const listener of listeners) {
listener({ type: "full", state });
}
});
});
ptyProcess.onExit(() => {
killed = true;
});
function getState(): TerminalState {
return {
rows: terminal.rows,
cols: terminal.cols,
grid: extractGrid(terminal),
scrollback: extractScrollback(terminal),
cursor: {
row: terminal.buffer.active.cursorY,
col: terminal.buffer.active.cursorX,
},
};
}
function send(msg: ClientMessage): void {
if (killed) return;
switch (msg.type) {
case "input":
ptyProcess.write(msg.data);
break;
case "resize":
terminal.resize(msg.cols, msg.rows);
ptyProcess.resize(msg.cols, msg.rows);
break;
case "mouse":
// Mouse events can be sent as escape sequences if terminal supports it
// For now, we'll just ignore them - can be implemented later
break;
}
}
function subscribe(listener: (msg: ServerMessage) => void): () => void {
listeners.add(listener);
// Send initial full state
queueMicrotask(() => {
if (listeners.has(listener)) {
listener({ type: "full", state: getState() });
}
});
return () => {
listeners.delete(listener);
};
}
function kill(): void {
if (killed) return;
killed = true;
ptyProcess.kill();
terminal.dispose();
}
// Small delay to let shell initialize
await new Promise((resolve) => setTimeout(resolve, 50));
return {
id,
name,
cwd,
send,
subscribe,
getState,
kill,
};
}
@@ -0,0 +1,107 @@
import { describe, expect, test } from "vitest";
import {
stripShellWrapperPrefix,
extractPrincipalParam,
stripCwdPrefix,
} from "./tool-call-parsers.js";
describe("stripShellWrapperPrefix", () => {
test("strips /bin/zsh -lc cd path && prefix", () => {
const command = "/bin/zsh -lc cd /Users/moboudra/dev/blankpage/editor && npm run format";
expect(stripShellWrapperPrefix(command)).toBe("npm run format");
});
test("strips /bin/zsh -c cd path && prefix", () => {
const command = "/bin/zsh -c cd /path/to/project && git status";
expect(stripShellWrapperPrefix(command)).toBe("git status");
});
test("strips /bin/bash -lc cd path && prefix", () => {
const command = "/bin/bash -lc cd /home/user/project && npm test";
expect(stripShellWrapperPrefix(command)).toBe("npm test");
});
test("strips /bin/sh -c cd path && prefix", () => {
const command = "/bin/sh -c cd /tmp && ls -la";
expect(stripShellWrapperPrefix(command)).toBe("ls -la");
});
test("returns command unchanged when no prefix", () => {
const command = "npm run build";
expect(stripShellWrapperPrefix(command)).toBe("npm run build");
});
test("returns command unchanged for partial match", () => {
const command = "/bin/zsh -lc npm run build";
expect(stripShellWrapperPrefix(command)).toBe("/bin/zsh -lc npm run build");
});
test("handles paths with spaces in quotes", () => {
// The regex matches \S+ for the path, so quoted paths won't fully match
// This is acceptable - quoted paths are edge cases
const command = '/bin/zsh -lc cd "/path with spaces" && npm test';
// Won't strip because the path pattern expects non-whitespace
expect(stripShellWrapperPrefix(command)).toBe('/bin/zsh -lc cd "/path with spaces" && npm test');
});
});
describe("extractPrincipalParam", () => {
test("extracts and strips shell wrapper from command string", () => {
const args = { command: "/bin/zsh -lc cd /Users/dev/project && npm run format" };
expect(extractPrincipalParam(args)).toBe("npm run format");
});
test("extracts and strips shell wrapper from command array", () => {
const args = { command: ["/bin/bash", "-lc", "cd /path && git status"] };
// Array is joined with spaces first: "/bin/bash -lc cd /path && git status"
// Then shell wrapper is stripped
expect(extractPrincipalParam(args)).toBe("git status");
});
test("extracts plain command without shell wrapper", () => {
const args = { command: "npm run build" };
expect(extractPrincipalParam(args)).toBe("npm run build");
});
test("extracts file_path and strips cwd", () => {
const args = { file_path: "/Users/dev/project/src/file.ts" };
expect(extractPrincipalParam(args, "/Users/dev/project")).toBe("src/file.ts");
});
test("extracts pattern without modification", () => {
const args = { pattern: "*.ts" };
expect(extractPrincipalParam(args)).toBe("*.ts");
});
test("extracts query without modification", () => {
const args = { query: "search term" };
expect(extractPrincipalParam(args)).toBe("search term");
});
test("extracts url without modification", () => {
const args = { url: "https://example.com" };
expect(extractPrincipalParam(args)).toBe("https://example.com");
});
test("returns undefined for empty object", () => {
expect(extractPrincipalParam({})).toBeUndefined();
});
});
describe("stripCwdPrefix", () => {
test("strips cwd prefix from file path", () => {
expect(stripCwdPrefix("/Users/dev/project/src/file.ts", "/Users/dev/project")).toBe("src/file.ts");
});
test("returns . for exact cwd match", () => {
expect(stripCwdPrefix("/Users/dev/project", "/Users/dev/project")).toBe(".");
});
test("returns path unchanged when no cwd prefix", () => {
expect(stripCwdPrefix("/other/path/file.ts", "/Users/dev/project")).toBe("/other/path/file.ts");
});
test("returns path unchanged when no cwd provided", () => {
expect(stripCwdPrefix("/Users/dev/project/file.ts")).toBe("/Users/dev/project/file.ts");
});
});
+17 -3
View File
@@ -18,9 +18,9 @@ const PrincipalParamSchema = z.union([
z.object({ filePath: z.string() }).transform((d) => ({ type: "path" as const, value: d.filePath })),
z.object({ path: z.string() }).transform((d) => ({ type: "path" as const, value: d.path })),
// Command as string
z.object({ command: z.string() }).transform((d) => ({ type: "text" as const, value: d.command })),
z.object({ command: z.string() }).transform((d) => ({ type: "command" as const, value: d.command })),
// Command as array (Codex sends this)
z.object({ command: z.array(z.string()).nonempty() }).transform((d) => ({ type: "text" as const, value: d.command.join(" ") })),
z.object({ command: z.array(z.string()).nonempty() }).transform((d) => ({ type: "command" as const, value: d.command.join(" ") })),
// Other text params
z.object({ pattern: z.string() }).transform((d) => ({ type: "text" as const, value: d.pattern })),
z.object({ query: z.string() }).transform((d) => ({ type: "text" as const, value: d.query })),
@@ -53,6 +53,14 @@ export function stripCwdPrefix(filePath: string, cwd?: string): string {
return filePath;
}
// Strips shell wrapper prefixes like "/bin/zsh -lc cd /path && " from commands
// This is used for display purposes to show the actual command being run
const SHELL_WRAPPER_PATTERN = /^\/bin\/(?:zsh|bash|sh)\s+(?:-[a-zA-Z]+\s+)?cd\s+\S+\s+&&\s+/;
export function stripShellWrapperPrefix(command: string): string {
return command.replace(SHELL_WRAPPER_PATTERN, "");
}
export function extractPrincipalParam(args: unknown, cwd?: string): string | undefined {
const parsed = PrincipalParamSchema.safeParse(args);
if (!parsed.success) {
@@ -60,7 +68,13 @@ export function extractPrincipalParam(args: unknown, cwd?: string): string | und
}
const { type, value } = parsed.data;
return type === "path" ? stripCwdPrefix(value, cwd) : value;
if (type === "path") {
return stripCwdPrefix(value, cwd);
}
if (type === "command") {
return stripShellWrapperPrefix(value);
}
return value;
}
// ---- TodoWrite Extraction ----