feat: add Tauri desktop app support and terminal management
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
// ============================================================================
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 ----
|
||||
|
||||
Reference in New Issue
Block a user