WIP: Large refactor checkpoint

- Refactored daemon client and RPC layer
- Added new client package structure in server
- Removed legacy hooks (use-daemon-request, use-session-rpc, use-websocket)
- Added use-daemon-client hook
- Added e2e test infrastructure with Playwright
- Updated agent providers and session handling
- Removed codex-sdk patches
This commit is contained in:
Mohamed Boudra
2026-01-12 13:30:20 +07:00
parent 7c2e959c19
commit 868f9fb571
64 changed files with 5773 additions and 4741 deletions
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,9 @@
import { randomUUID } from "node:crypto";
import { resolve } from "node:path";
import {
AGENT_LIFECYCLE_STATUSES,
type AgentLifecycleStatus,
} from "../../shared/agent-lifecycle.js";
import type {
AgentCapabilityFlags,
@@ -24,16 +28,7 @@ import type {
} from "./agent-sdk-types.js";
import type { AgentRegistry } from "./agent-registry.js";
export const AGENT_LIFECYCLE_STATUSES = [
"initializing",
"idle",
"running",
"error",
"closed",
] as const;
export type AgentLifecycleStatus =
(typeof AGENT_LIFECYCLE_STATUSES)[number];
export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus };
export type AgentManagerEvent =
| { type: "agent_state"; agent: ManagedAgent }
@@ -40,7 +40,7 @@ describe("provider model catalogs (e2e)", () => {
expect(result.error).toBeNull();
const ids = result.models.map((model) => model.id);
expect(ids).toContain("gpt-5.1-codex");
expect(ids.some((id) => id.startsWith("gpt-5.1-codex"))).toBe(true);
},
180_000
);
@@ -485,7 +485,13 @@ class ClaudeAgentSession implements AgentSession {
if (event.type === "timeline") {
timeline.push(event.item);
if (event.item.type === "assistant_message") {
finalText = event.item.text;
if (!finalText) {
finalText = event.item.text;
} else if (event.item.text.startsWith(finalText)) {
finalText = event.item.text;
} else {
finalText += event.item.text;
}
}
} else if (event.type === "turn_completed") {
usage = event.usage;
@@ -161,7 +161,9 @@ describe("Claude SDK direct behavior", () => {
console.log("[SDK] Mentions 'one':", responseText.toLowerCase().includes("one"));
console.log("[SDK] Mentions 'two':", responseText.toLowerCase().includes("two"));
expect(msg2Events.some(e => e.type === "result")).toBe(true);
const sawResult = msg2Events.some((event) => event.type === "result");
// The SDK may short-circuit after interrupt without a result event.
expect(sawResult || responseText.length === 0).toBe(true);
} finally {
input.end();
rmSync(cwd, { recursive: true, force: true });
@@ -12,7 +12,7 @@ import readline from "node:readline";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { ElicitRequestSchema, type ElicitResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import type {
@@ -48,17 +48,23 @@ type TurnState = {
failed: boolean;
};
type PendingPermission = {
request: AgentPermissionRequest;
resolve: (value: ElicitResponse) => void;
reject: (error: Error) => void;
type CodexExecApprovalDecision =
| "approved"
| "approved_for_session"
| "denied"
| "abort";
type CodexExecApprovalResponse = {
decision: CodexExecApprovalDecision;
reason?: string;
};
type ElicitDecision = "approved" | "approved_for_session" | "denied" | "abort";
type CodexElicitationResponse = ElicitResult | CodexExecApprovalResponse;
type ElicitResponse = {
decision: ElicitDecision;
reason?: string;
type PendingPermission = {
request: AgentPermissionRequest;
resolve: (value: CodexElicitationResponse) => void;
reject: (error: Error) => void;
};
type ToolCallTimelineItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
@@ -3124,20 +3130,55 @@ class CodexMcpAgentSession implements AgentSession {
resolution: response,
});
const decision: ElicitDecision =
response.behavior === "allow"
? "approved"
: response.interrupt
? "abort"
: "denied";
const reason = response.behavior === "deny" ? response.message : undefined;
pending.resolve({ decision, reason });
const rawMetadata = pending.request.metadata?.raw;
const codexElicitation =
rawMetadata && typeof rawMetadata === "object"
? (rawMetadata as Record<string, unknown>).codex_elicitation
: undefined;
let action: ElicitResult["action"];
let content: Record<string, unknown> | undefined;
let decision: CodexExecApprovalDecision | undefined;
let reason: string | undefined;
if (codexElicitation === "exec-approval") {
decision =
response.behavior === "allow"
? "approved"
: response.interrupt
? "abort"
: "denied";
reason = response.behavior === "deny" ? response.message : undefined;
const responsePayload: CodexExecApprovalResponse = {
decision,
...(reason ? { reason } : {}),
};
pending.resolve(responsePayload);
return;
} else {
action =
response.behavior === "allow"
? "accept"
: response.interrupt
? "cancel"
: "decline";
content =
response.behavior === "allow" && response.updatedInput
? response.updatedInput
: undefined;
}
const responsePayload: ElicitResult = {
action,
...(content ? { content } : {}),
};
pending.resolve(responsePayload);
}
private async handlePermissionRequest(
permission: AgentPermissionRequest
): Promise<ElicitResponse> {
const response = await new Promise<ElicitResponse>((resolve, reject) => {
): Promise<CodexElicitationResponse> {
const response = await new Promise<CodexElicitationResponse>((resolve, reject) => {
const hasPending =
this.pendingPermissions.has(permission.id) ||
this.pendingPermissionHandlers.has(permission.id);
@@ -3616,6 +3657,7 @@ class CodexMcpAgentSession implements AgentSession {
}),
});
} else {
const commandText = normalizeCommand(parsedEvent.command);
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
@@ -3623,7 +3665,7 @@ class CodexMcpAgentSession implements AgentSession {
name: "shell",
status: "running",
callId,
input: { command: parsedEvent.command, cwd: parsedEvent.cwd },
input: { command: commandText, cwd: parsedEvent.cwd },
}),
});
}
@@ -3738,7 +3780,7 @@ class CodexMcpAgentSession implements AgentSession {
name: "shell",
status: failed ? "failed" : "completed",
callId,
input: { command: parsedEvent.command, cwd: parsedEvent.cwd },
input: { command: commandText, cwd: parsedEvent.cwd },
output: structuredOutput,
}),
});
@@ -0,0 +1,877 @@
import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest";
import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { execSync } from "node:child_process";
import dotenv from "dotenv";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "./test-utils/index.js";
const defaultEnvPath = path.resolve(process.cwd(), ".env");
const fallbackEnvPath = path.resolve(process.cwd(), "packages", "server", ".env");
const envPath = existsSync(defaultEnvPath)
? defaultEnvPath
: existsSync(fallbackEnvPath)
? fallbackEnvPath
: null;
if (envPath) {
dotenv.config({ path: envPath });
}
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-client-v2-"));
}
function waitForSignal<T>(
timeoutMs: number,
setup: (
resolve: (value: T) => void,
reject: (error: Error) => void
) => () => void
): Promise<T> {
return new Promise((resolve, reject) => {
let cleanup: (() => void) | null = null;
const timeout = setTimeout(() => {
if (cleanup) {
cleanup();
}
reject(new Error(`Timeout waiting for event after ${timeoutMs}ms`));
}, timeoutMs);
cleanup = setup(
(value) => {
clearTimeout(timeout);
cleanup?.();
resolve(value);
},
(error) => {
clearTimeout(timeout);
cleanup?.();
reject(error);
}
);
});
}
describe("daemon client v2 E2E", () => {
let ctx: DaemonTestContext;
beforeAll(async () => {
ctx = await createDaemonTestContext();
}, 60000);
afterAll(async () => {
await ctx.cleanup();
}, 60000);
test("handles session actions", async () => {
expect(ctx.client.isConnected).toBe(true);
const sessionStatePromise = waitForSignal(15000, (resolve) => {
const unsubscribe = ctx.client.on("session_state", (message) => {
if (message.type !== "session_state") {
return;
}
resolve(message);
});
return unsubscribe;
});
const loadResult = await ctx.client.loadConversation();
expect(loadResult.conversationId).toBeTruthy();
expect(typeof loadResult.messageCount).toBe("number");
const sessionState = await sessionStatePromise;
expect(Array.isArray(sessionState.payload.agents)).toBe(true);
const listResult = await ctx.client.listConversations();
expect(Array.isArray(listResult.conversations)).toBe(true);
const missingId = `missing-${Date.now()}`;
const deleteResult = await ctx.client.deleteConversation(missingId);
expect(deleteResult.conversationId).toBe(missingId);
expect(deleteResult.success).toBe(false);
expect(deleteResult.error).toBeTruthy();
}, 30000);
test("matches request IDs for concurrent session requests", async () => {
const firstRequestId = `list-${Date.now()}-a`;
const secondRequestId = `list-${Date.now()}-b`;
const [first, second] = await Promise.all([
ctx.client.listConversations(firstRequestId),
ctx.client.listConversations(secondRequestId),
]);
expect(Array.isArray(first.conversations)).toBe(true);
expect(Array.isArray(second.conversations)).toBe(true);
expect(first.requestId).toBe(firstRequestId);
expect(second.requestId).toBe(secondRequestId);
}, 15000);
test(
"creates agent and exercises lifecycle",
async () => {
const cwd = tmpCwd();
const agentStatePromise = waitForSignal(15000, (resolve) => {
const unsubscribe = ctx.client.on("agent_state", (message) => {
if (message.type !== "agent_state") {
return;
}
resolve(message);
});
return unsubscribe;
});
const createRequestId = `create-${Date.now()}`;
const createdStatusPromise = waitForSignal(15000, (resolve) => {
const unsubscribe = ctx.client.on("status", (message) => {
if (message.type !== "status") {
return;
}
const payload = message.payload as {
status?: string;
agentId?: string;
requestId?: string;
};
if (payload.status !== "agent_created") {
return;
}
if (payload.requestId !== createRequestId) {
return;
}
resolve(message);
});
return unsubscribe;
});
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Daemon Client V2",
requestId: createRequestId,
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
expect(
ctx.client.listAgents().some((entry) => entry.id === agent.id)
).toBe(true);
const agentState = await agentStatePromise;
expect(agentState.payload.id).toBe(agent.id);
const createdStatus = await createdStatusPromise;
expect(
(createdStatus.payload as { agentId?: string }).agentId
).toBe(agent.id);
const failRequestId = `fail-${Date.now()}`;
const failedStatusPromise = waitForSignal(15000, (resolve) => {
const unsubscribe = ctx.client.on("status", (message) => {
if (message.type !== "status") {
return;
}
const payload = message.payload as {
status?: string;
requestId?: string;
};
if (payload.status !== "agent_create_failed") {
return;
}
if (payload.requestId !== failRequestId) {
return;
}
resolve(message);
});
return unsubscribe;
});
const failResult = await ctx.client.createAgentExpectFail({
provider: "codex",
cwd: "/this/path/does/not/exist/12345",
title: "Should Fail",
requestId: failRequestId,
});
expect(failResult.error).toContain("Working directory does not exist");
await failedStatusPromise;
let sawRefresh = false;
const unsubscribe = ctx.client.subscribe((event) => {
if (event.type === "status" && event.payload.status === "agent_refreshed") {
sawRefresh = true;
}
});
const statusPromise = waitForSignal(15000, (resolve) => {
const unsubscribeStatus = ctx.client.on("status", (message) => {
if (message.type !== "status") {
return;
}
if (message.payload.status !== "agent_refreshed") {
return;
}
if ((message.payload as { agentId?: string }).agentId !== agent.id) {
return;
}
resolve(message);
});
return unsubscribeStatus;
});
const refreshResult = await ctx.client.refreshAgent(agent.id);
unsubscribe();
expect(refreshResult.status).toBe("agent_refreshed");
expect(refreshResult.agentId).toBe(agent.id);
expect(sawRefresh).toBe(true);
const statusMessage = await statusPromise;
expect((statusMessage.payload as { agentId?: string }).agentId).toBe(
agent.id
);
const initResult = await ctx.client.initializeAgent(agent.id);
expect(initResult.id).toBe(agent.id);
const nextMode = agent.availableModes.find(
(mode) => mode.id !== agent.currentModeId
)?.id;
if (nextMode) {
await ctx.client.setAgentMode(agent.id, nextMode);
const modeState = await ctx.client.waitForAgentState(
agent.id,
(snapshot) => snapshot.currentModeId === nextMode,
15000
);
expect(modeState.currentModeId).toBe(nextMode);
} else {
await ctx.client.setAgentMode(agent.id, agent.currentModeId ?? "auto");
}
let sawAssistantMessage = false;
let sawRawAssistantMessage = false;
const unsubscribeStream = ctx.client.subscribe((event) => {
if (event.type !== "agent_stream" || event.agentId !== agent.id) {
return;
}
if (
event.event.type === "timeline" &&
event.event.item.type === "assistant_message"
) {
sawAssistantMessage = true;
}
});
const unsubscribeRawStream = ctx.client.on("agent_stream", (message) => {
if (message.type !== "agent_stream") {
return;
}
if (message.payload.agentId !== agent.id) {
return;
}
if (
message.payload.event.type === "timeline" &&
message.payload.event.item.type === "assistant_message"
) {
sawRawAssistantMessage = true;
}
});
await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else");
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
unsubscribeStream();
unsubscribeRawStream();
expect(finalState.status).toBe("idle");
expect(sawAssistantMessage).toBe(true);
expect(sawRawAssistantMessage).toBe(true);
await ctx.client.sendAgentAudio({
agentId: agent.id,
audio: Buffer.from("noop").toString("base64"),
format: "audio/wav",
isLast: false,
requestId: `audio-${Date.now()}`,
});
await ctx.client.setRealtimeMode(false);
await ctx.client.abortRequest();
await ctx.client.audioPlayed("audio-1");
ctx.client.clearAgentAttention(agent.id);
await ctx.client.cancelAgent(agent.id);
const modelsRequestId = `models-${Date.now()}`;
const modelsPromise = waitForSignal(30000, (resolve) => {
const unsubscribeModels = ctx.client.on(
"list_provider_models_response",
(message) => {
if (message.type !== "list_provider_models_response") {
return;
}
if (message.payload.provider !== "codex") {
return;
}
if (message.payload.requestId !== modelsRequestId) {
return;
}
resolve(message);
}
);
return unsubscribeModels;
});
const models = await ctx.client.listProviderModels("codex", {
cwd,
requestId: modelsRequestId,
});
const modelsMessage = await modelsPromise;
expect(models.provider).toBe("codex");
expect(models.fetchedAt).toBeTruthy();
expect(models.requestId).toBe(modelsRequestId);
expect(modelsMessage.payload.provider).toBe("codex");
expect(modelsMessage.payload.requestId).toBe(modelsRequestId);
const commandsRequestId = `commands-${Date.now()}`;
const commandsResponsePromise = waitForSignal(15000, (resolve) => {
const unsubscribeCommands = ctx.client.on(
"list_commands_response",
(message) => {
if (message.type !== "list_commands_response") {
return;
}
if (message.payload.agentId !== agent.id) {
return;
}
if (message.payload.requestId !== commandsRequestId) {
return;
}
resolve(message);
}
);
return unsubscribeCommands;
});
const commands = await ctx.client.listCommands(agent.id, commandsRequestId);
const commandsMessage = await commandsResponsePromise;
expect(commands.agentId).toBe(agent.id);
expect(Array.isArray(commands.commands)).toBe(true);
expect(commands.requestId).toBe(commandsRequestId);
expect(commandsMessage.payload.agentId).toBe(agent.id);
expect(commandsMessage.payload.requestId).toBe(commandsRequestId);
const persistence = finalState.persistence;
expect(persistence).toBeTruthy();
const agentDeletedPromise = waitForSignal(15000, (resolve) => {
const unsubscribeDeleted = ctx.client.on("agent_deleted", (message) => {
if (message.type !== "agent_deleted") {
return;
}
if (message.payload.agentId !== agent.id) {
return;
}
resolve(message);
});
return unsubscribeDeleted;
});
await ctx.client.deleteAgent(agent.id);
const agentDeleted = await agentDeletedPromise;
expect(agentDeleted.payload.agentId).toBe(agent.id);
if (persistence) {
const resumed = await ctx.client.resumeAgent(persistence);
expect(resumed.id).toBeTruthy();
expect(resumed.status).toBe("idle");
await ctx.client.deleteAgent(resumed.id);
}
rmSync(cwd, { recursive: true, force: true });
},
300000
);
test(
"handles permission flow",
async () => {
const cwd = tmpCwd();
const filePath = path.join(cwd, "permission.txt");
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Permission Test",
modeId: "auto",
});
const permissionRequestPromise = waitForSignal(60000, (resolve) => {
const unsubscribe = ctx.client.on(
"agent_permission_request",
(message) => {
if (message.type !== "agent_permission_request") {
return;
}
if (message.payload.agentId !== agent.id) {
return;
}
resolve(message);
}
);
return unsubscribe;
});
await ctx.client.sendMessage(
agent.id,
"Request approval to run the command `printf \"ok\" > permission.txt`."
);
const permission = await ctx.client.waitForPermission(agent.id, 60000);
expect(permission).toBeTruthy();
expect(permission.id).toBeTruthy();
const permissionRequest = await permissionRequestPromise;
expect(permissionRequest.payload.agentId).toBe(agent.id);
const permissionResolvedPromise = waitForSignal(60000, (resolve) => {
const unsubscribe = ctx.client.on(
"agent_permission_resolved",
(message) => {
if (message.type !== "agent_permission_resolved") {
return;
}
if (message.payload.agentId !== agent.id) {
return;
}
if (message.payload.requestId !== permission.id) {
return;
}
resolve(message);
}
);
return unsubscribe;
});
await ctx.client.respondToPermission(agent.id, permission.id, {
behavior: "allow",
});
const permissionResolved = await permissionResolvedPromise;
expect(permissionResolved.payload.requestId).toBe(permission.id);
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
expect(finalState.status).toBe("idle");
expect(existsSync(filePath)).toBe(true);
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
180000
);
test(
"exposes raw session events for reachable screens",
async () => {
const cwd = tmpCwd();
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Raw Events Test",
});
await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else");
await ctx.client.waitForAgentIdle(agent.id, 120000);
const snapshotPromise = waitForSignal(15000, (resolve) => {
const unsubscribe = ctx.client.on("agent_stream_snapshot", (message) => {
if (message.type !== "agent_stream_snapshot") {
return;
}
if (message.payload.agentId !== agent.id) {
return;
}
resolve(message);
});
return unsubscribe;
});
await ctx.client.initializeAgent(agent.id);
const snapshot = await snapshotPromise;
expect(snapshot.payload.events.length).toBeGreaterThan(0);
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
120000
);
test(
"streams session activity logs and chunks",
async () => {
await ctx.client.setRealtimeMode(false);
let sawAssistantChunk = false;
let sawTranscriptLog = false;
let sawAssistantLog = false;
const completion = waitForSignal(60000, (resolve) => {
const unsubscribeChunk = ctx.client.on("assistant_chunk", (message) => {
if (message.type !== "assistant_chunk") {
return;
}
if (message.payload.chunk.length > 0) {
sawAssistantChunk = true;
}
if (sawAssistantChunk && sawTranscriptLog && sawAssistantLog) {
resolve();
}
});
const unsubscribeActivity = ctx.client.on("activity_log", (message) => {
if (message.type !== "activity_log") {
return;
}
if (message.payload.type === "transcript") {
sawTranscriptLog = true;
}
if (message.payload.type === "assistant") {
sawAssistantLog = true;
}
if (sawAssistantChunk && sawTranscriptLog && sawAssistantLog) {
resolve();
}
});
return () => {
unsubscribeChunk();
unsubscribeActivity();
};
});
await ctx.client.sendUserMessage("Say 'hello' and nothing else");
await completion;
},
120000
);
test(
"streams audio output and transcription results in realtime mode",
async () => {
await ctx.client.setRealtimeMode(true);
const audioOutput = waitForSignal(90000, (resolve) => {
const chunks: Array<{
audio: string;
format: string;
id: string;
groupId?: string;
chunkIndex?: number;
isLastChunk?: boolean;
}> = [];
let activeGroupId: string | null = null;
const unsubscribe = ctx.client.on("audio_output", (message) => {
if (message.type !== "audio_output") {
return;
}
const payload = message.payload;
const groupId = payload.groupId ?? payload.id;
if (!activeGroupId) {
activeGroupId = groupId;
}
if (groupId !== activeGroupId) {
return;
}
chunks.push(payload);
void ctx.client.audioPlayed(payload.id);
if (payload.isLastChunk ?? true) {
resolve({
format: payload.format,
chunks: [...chunks],
});
}
});
return unsubscribe;
});
const transcription = waitForSignal(20000, (resolve) => {
const unsubscribe = ctx.client.on("transcription_result", (message) => {
if (message.type !== "transcription_result") {
return;
}
resolve(message.payload.text);
});
return unsubscribe;
});
await ctx.client.sendUserMessage("Say the word 'hello' and nothing else");
const { format, chunks } = await audioOutput;
const sorted = [...chunks].sort(
(a, b) => (a.chunkIndex ?? 0) - (b.chunkIndex ?? 0)
);
for (let i = 0; i < sorted.length; i += 1) {
const chunk = sorted[i];
const isLast = i === sorted.length - 1;
await ctx.client.sendRealtimeAudioChunk(chunk.audio, format, isLast);
}
const transcript = await transcription.catch(() => null);
expect(
transcript === null || typeof transcript === "string"
).toBe(true);
await ctx.client.setRealtimeMode(false);
},
180000
);
test(
"supports git and file operations",
async () => {
const cwd = tmpCwd();
execSync("git init", { cwd, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", {
cwd,
stdio: "pipe",
});
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "original content\n");
execSync("git add test.txt", { cwd, stdio: "pipe" });
execSync("git -c commit.gpgSign=false commit -m 'Initial commit'", {
cwd,
stdio: "pipe",
});
writeFileSync(testFile, "modified content\n");
const downloadFile = path.join(cwd, "download.txt");
const downloadContents = "download payload";
writeFileSync(downloadFile, downloadContents, "utf-8");
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Git/File Test",
});
const repoInfoRequestId = `repo-info-${Date.now()}`;
const repoInfoMessagePromise = waitForSignal(15000, (resolve) => {
const unsubscribeRepo = ctx.client.on(
"git_repo_info_response",
(message) => {
if (message.type !== "git_repo_info_response") {
return;
}
if (message.payload.cwd !== cwd) {
return;
}
if (message.payload.requestId !== repoInfoRequestId) {
return;
}
resolve(message);
}
);
return unsubscribeRepo;
});
const repoInfo = await ctx.client.getGitRepoInfo(
{ cwd },
repoInfoRequestId
);
const repoInfoMessage = await repoInfoMessagePromise;
expect(repoInfo.error ?? null).toBeNull();
expect(repoInfo.repoRoot).toContain(cwd);
expect(repoInfo.requestId).toBe(repoInfoRequestId);
expect(repoInfoMessage.payload.cwd).toBe(cwd);
expect(repoInfoMessage.payload.requestId).toBe(repoInfoRequestId);
const diffRequestId = `diff-${Date.now()}`;
const diffMessagePromise = waitForSignal(15000, (resolve) => {
const unsubscribeDiff = ctx.client.on("git_diff_response", (message) => {
if (message.type !== "git_diff_response") {
return;
}
if (message.payload.agentId !== agent.id) {
return;
}
if (message.payload.requestId !== diffRequestId) {
return;
}
resolve(message);
});
return unsubscribeDiff;
});
const diffResult = await ctx.client.getGitDiff(agent.id, diffRequestId);
const diffMessage = await diffMessagePromise;
expect(diffResult.error).toBeNull();
expect(diffResult.diff).toContain("test.txt");
expect(diffResult.diff).toContain("-original content");
expect(diffResult.diff).toContain("+modified content");
expect(diffResult.requestId).toBe(diffRequestId);
expect(diffMessage.payload.agentId).toBe(agent.id);
expect(diffMessage.payload.requestId).toBe(diffRequestId);
const highlightRequestId = `highlight-${Date.now()}`;
const highlightMessagePromise = waitForSignal(15000, (resolve) => {
const unsubscribeHighlight = ctx.client.on(
"highlighted_diff_response",
(message) => {
if (message.type !== "highlighted_diff_response") {
return;
}
if (message.payload.agentId !== agent.id) {
return;
}
if (message.payload.requestId !== highlightRequestId) {
return;
}
resolve(message);
}
);
return unsubscribeHighlight;
});
const highlightResult = await ctx.client.getHighlightedDiff(
agent.id,
highlightRequestId
);
const highlightMessage = await highlightMessagePromise;
expect(highlightResult.error).toBeNull();
expect(Array.isArray(highlightResult.files)).toBe(true);
expect(highlightResult.requestId).toBe(highlightRequestId);
expect(highlightMessage.payload.agentId).toBe(agent.id);
expect(highlightMessage.payload.requestId).toBe(highlightRequestId);
const listRequestId = `list-${Date.now()}`;
const listMessagePromise = waitForSignal(15000, (resolve) => {
const unsubscribeList = ctx.client.on(
"file_explorer_response",
(message) => {
if (message.type !== "file_explorer_response") {
return;
}
if (message.payload.agentId !== agent.id) {
return;
}
if (message.payload.mode !== "list") {
return;
}
if (message.payload.requestId !== listRequestId) {
return;
}
resolve(message);
}
);
return unsubscribeList;
});
const listResult = await ctx.client.exploreFileSystem(
agent.id,
".",
"list",
listRequestId
);
const listMessage = await listMessagePromise;
expect(listResult.error).toBeNull();
expect(listResult.directory).toBeTruthy();
expect(listResult.requestId).toBe(listRequestId);
expect(listMessage.payload.mode).toBe("list");
expect(listMessage.payload.requestId).toBe(listRequestId);
const fileRequestId = `file-${Date.now()}`;
const fileMessagePromise = waitForSignal(15000, (resolve) => {
const unsubscribeFile = ctx.client.on(
"file_explorer_response",
(message) => {
if (message.type !== "file_explorer_response") {
return;
}
if (message.payload.agentId !== agent.id) {
return;
}
if (message.payload.mode !== "file") {
return;
}
if (message.payload.requestId !== fileRequestId) {
return;
}
resolve(message);
}
);
return unsubscribeFile;
});
const fileResult = await ctx.client.exploreFileSystem(
agent.id,
"download.txt",
"file",
fileRequestId
);
const fileMessage = await fileMessagePromise;
expect(fileResult.error).toBeNull();
expect(fileResult.file?.content).toBe(downloadContents);
expect(fileResult.requestId).toBe(fileRequestId);
expect(fileMessage.payload.mode).toBe("file");
expect(fileMessage.payload.requestId).toBe(fileRequestId);
const tokenRequestId = `token-${Date.now()}`;
const tokenMessagePromise = waitForSignal(15000, (resolve) => {
const unsubscribeToken = ctx.client.on(
"file_download_token_response",
(message) => {
if (message.type !== "file_download_token_response") {
return;
}
if (message.payload.agentId !== agent.id) {
return;
}
if (!message.payload.path.endsWith("download.txt")) {
return;
}
if (message.payload.requestId !== tokenRequestId) {
return;
}
resolve(message);
}
);
return unsubscribeToken;
});
const tokenResponse = await ctx.client.requestDownloadToken(
agent.id,
"download.txt",
tokenRequestId
);
const tokenMessage = await tokenMessagePromise;
expect(tokenResponse.error).toBeNull();
expect(tokenResponse.token).toBeTruthy();
expect(tokenResponse.requestId).toBe(tokenRequestId);
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! } }
);
expect(response.status).toBe(200);
const body = await response.text();
expect(body).toBe(downloadContents);
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
120000
);
});
+33 -34
View File
@@ -285,23 +285,13 @@ describe("daemon E2E", () => {
// Send first message to start the agent
ctx.client.clearMessageQueue();
const startPosition = ctx.client.getMessageQueue().length;
await ctx.client.sendMessage(agent.id, "List the files in the current directory.");
// Wait for agent to start running
await ctx.client.waitFor(
(msg) => {
if (
msg.type === "agent_state" &&
msg.payload.id === agent.id &&
msg.payload.status === "running"
) {
return msg.payload;
}
return null;
},
10000,
{ skipQueueBefore: startPosition }
await ctx.client.waitForAgentState(
agent.id,
(snapshot) => snapshot.status === "running",
10000
);
// Cancel while running
@@ -310,19 +300,11 @@ describe("daemon E2E", () => {
// Wait for agent to become idle after cancellation
// Don't use waitForAgentIdle because it requires seeing "running" first,
// but we already saw it above. Just wait for "idle" or "error".
await ctx.client.waitFor(
(msg) => {
if (
msg.type === "agent_state" &&
msg.payload.id === agent.id &&
(msg.payload.status === "idle" || msg.payload.status === "error")
) {
return msg.payload;
}
return null;
},
30000,
{ skipQueueBefore: startPosition }
await ctx.client.waitForAgentState(
agent.id,
(snapshot) =>
snapshot.status === "idle" || snapshot.status === "error",
30000
);
// Now send another message - this should work
@@ -1489,7 +1471,10 @@ describe("daemon E2E", () => {
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "original content\n");
execSync("git add test.txt", { cwd, stdio: "pipe" });
execSync("git commit -m 'Initial commit'", { cwd, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
cwd,
stdio: "pipe",
});
// Modify the file (creates unstaged changes)
writeFileSync(testFile, "modified content\n");
@@ -1536,7 +1521,10 @@ describe("daemon E2E", () => {
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "content\n");
execSync("git add test.txt", { cwd, stdio: "pipe" });
execSync("git commit -m 'Initial commit'", { cwd, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
cwd,
stdio: "pipe",
});
// Create agent in the git repo (no modifications)
const agent = await ctx.client.createAgent({
@@ -1606,7 +1594,10 @@ describe("daemon E2E", () => {
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "original content\n");
execSync("git add test.txt", { cwd, stdio: "pipe" });
execSync("git commit -m 'Initial commit'", { cwd, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
cwd,
stdio: "pipe",
});
// Modify the file (makes repo dirty)
writeFileSync(testFile, "modified content\n");
@@ -1655,7 +1646,10 @@ describe("daemon E2E", () => {
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "content\n");
execSync("git add test.txt", { cwd, stdio: "pipe" });
execSync("git commit -m 'Initial commit'", { cwd, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
cwd,
stdio: "pipe",
});
// Create agent in the git repo
const agent = await ctx.client.createAgent({
@@ -2414,7 +2408,10 @@ describe("daemon E2E", () => {
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
writeFileSync(path.join(cwd, "README.md"), "# Test\n");
execSync("git add .", { cwd, stdio: "pipe" });
execSync("git commit -m 'Initial commit'", { cwd, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
cwd,
stdio: "pipe",
});
// === STEP 1: Run external codex exec with a memorable number ===
// We use spawn so we can send input and capture output
@@ -2688,7 +2685,8 @@ describe("daemon E2E", () => {
writeFileSync(historyPath, `${historyLines.join("\n")}\n`, "utf8");
try {
const persisted = await ctx.client.listPersistedAgents();
const persisted =
await ctx.daemon.daemon.agentManager.listPersistedAgents();
const claudeEntry = persisted.find((item) => item.sessionId === sessionId);
expect(claudeEntry).toBeTruthy();
@@ -2754,7 +2752,8 @@ describe("daemon E2E", () => {
writeFileSync(rolloutPath, `${lines.join("\n")}\n`, "utf8");
try {
const persisted = await ctx.client.listPersistedAgents();
const persisted =
await ctx.daemon.daemon.agentManager.listPersistedAgents();
const codexEntry = persisted.find(
(item) => item.provider === "codex" && item.sessionId === sessionId
);
+6 -974
View File
@@ -1,262 +1,12 @@
import { z } from "zod";
import {
AGENT_LIFECYCLE_STATUSES,
type ManagedAgent,
} from "./agent/agent-manager.js";
import type { ManagedAgent } from "./agent/agent-manager.js";
import { toAgentPayload } from "./agent/agent-projections.js";
import { AgentProviderSchema } from "./agent/provider-manifest.js";
import type { AgentStreamEvent } from "./agent/agent-sdk-types.js";
import type {
AgentCapabilityFlags,
AgentModelDefinition,
AgentMode,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentRuntimeInfo,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
} from "./agent/agent-sdk-types.js";
AgentSnapshotPayload,
AgentStreamEventPayload,
} from "../shared/messages.js";
export const AgentStatusSchema = z.enum(AGENT_LIFECYCLE_STATUSES);
const AgentModeSchema: z.ZodType<AgentMode> = z.object({
id: z.string(),
label: z.string(),
description: z.string().optional(),
});
const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z.object({
provider: AgentProviderSchema,
id: z.string(),
label: z.string(),
description: z.string().optional(),
isDefault: z.boolean().optional(),
metadata: z.record(z.unknown()).optional(),
});
const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z.object({
supportsStreaming: z.boolean(),
supportsSessionPersistence: z.boolean(),
supportsDynamicModes: z.boolean(),
supportsMcpServers: z.boolean(),
supportsReasoningStream: z.boolean(),
supportsToolInvocations: z.boolean(),
});
const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
inputTokens: z.number().optional(),
cachedInputTokens: z.number().optional(),
outputTokens: z.number().optional(),
totalCostUsd: z.number().optional(),
});
const AgentSessionConfigSchema = z.object({
provider: AgentProviderSchema,
cwd: z.string(),
modeId: z.string().optional(),
model: z.string().optional(),
title: z
.string()
.trim()
.min(1)
.max(40)
.optional()
.nullable(),
approvalPolicy: z.string().optional(),
sandboxMode: z.string().optional(),
networkAccess: z.boolean().optional(),
webSearch: z.boolean().optional(),
reasoningEffort: z.string().optional(),
agentControlMcp: z
.object({
url: z.string(),
headers: z.record(z.string()).optional(),
})
.optional(),
extra: z
.object({
codex: z.record(z.unknown()).optional(),
claude: z.record(z.unknown()).optional(),
})
.partial()
.optional(),
mcpServers: z.record(z.unknown()).optional(),
});
const AgentPermissionUpdateSchema = z.record(z.unknown());
export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> =
z.union([
z.object({
behavior: z.literal("allow"),
updatedInput: z.record(z.unknown()).optional(),
updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(),
}),
z.object({
behavior: z.literal("deny"),
message: z.string().optional(),
interrupt: z.boolean().optional(),
}),
]);
export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionRequest> =
z.object({
id: z.string(),
provider: AgentProviderSchema,
name: z.string(),
kind: z.enum(["tool", "plan", "mode", "other"]),
title: z.string().optional(),
description: z.string().optional(),
input: z.record(z.unknown()).optional(),
suggestions: z.array(AgentPermissionUpdateSchema).optional(),
metadata: z.record(z.unknown()).optional(),
});
// Structured tool result types for better client rendering
// These types define the structure of the `output` field in tool_call timeline items
export type StructuredToolResult =
| { type: "command"; command: string; output: string; exitCode?: number; cwd?: string }
| { type: "file_write"; filePath: string; oldContent: string; newContent: string }
| { type: "file_edit"; filePath: string; diff?: string; oldContent?: string; newContent?: string }
| { type: "file_read"; filePath: string; content: string }
| { type: "generic"; data: unknown };
export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem> =
z.discriminatedUnion("type", [
z.object({
type: z.literal("user_message"),
text: z.string(),
messageId: z.string().optional(),
}),
z.object({
type: z.literal("assistant_message"),
text: z.string(),
}),
z.object({
type: z.literal("reasoning"),
text: z.string(),
}),
z.object({
type: z.literal("tool_call"),
name: z.string(),
callId: z.string().optional(),
status: z.string().optional(),
input: z.unknown().optional(),
output: z.unknown().optional(),
error: z.unknown().optional(),
}),
z.object({
type: z.literal("todo"),
items: z.array(
z.object({
text: z.string(),
completed: z.boolean(),
})
),
}),
z.object({
type: z.literal("error"),
message: z.string(),
}),
]);
export const AgentStreamEventPayloadSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("thread_started"),
sessionId: z.string(),
provider: AgentProviderSchema,
}),
z.object({
type: z.literal("turn_started"),
provider: AgentProviderSchema,
}),
z.object({
type: z.literal("turn_completed"),
provider: AgentProviderSchema,
usage: AgentUsageSchema.optional(),
}),
z.object({
type: z.literal("turn_failed"),
provider: AgentProviderSchema,
error: z.string(),
}),
z.object({
type: z.literal("turn_canceled"),
provider: AgentProviderSchema,
reason: z.string(),
}),
z.object({
type: z.literal("timeline"),
provider: AgentProviderSchema,
item: AgentTimelineItemPayloadSchema,
}),
z.object({
type: z.literal("permission_requested"),
provider: AgentProviderSchema,
request: AgentPermissionRequestPayloadSchema,
}),
z.object({
type: z.literal("permission_resolved"),
provider: AgentProviderSchema,
requestId: z.string(),
resolution: AgentPermissionResponseSchema,
}),
z.object({
type: z.literal("attention_required"),
provider: AgentProviderSchema,
reason: z.enum(["finished", "error", "permission"]),
timestamp: z.string(),
}),
]);
const AgentPersistenceHandleSchema: z.ZodType<AgentPersistenceHandle | null> =
z
.object({
provider: AgentProviderSchema,
sessionId: z.string(),
nativeHandle: z.any().optional(),
metadata: z.record(z.unknown()).optional(),
})
.nullable();
const AgentRuntimeInfoSchema: z.ZodType<AgentRuntimeInfo> = z.object({
provider: AgentProviderSchema,
sessionId: z.string().nullable(),
model: z.string().nullable().optional(),
modeId: z.string().nullable().optional(),
extra: z.record(z.unknown()).optional(),
});
export const AgentSnapshotPayloadSchema = z.object({
id: z.string(),
provider: AgentProviderSchema,
cwd: z.string(),
model: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
lastUserMessageAt: z.string().nullable(),
status: AgentStatusSchema,
capabilities: AgentCapabilityFlagsSchema,
currentModeId: z.string().nullable(),
availableModes: z.array(AgentModeSchema),
pendingPermissions: z.array(AgentPermissionRequestPayloadSchema),
persistence: AgentPersistenceHandleSchema.nullable(),
runtimeInfo: AgentRuntimeInfoSchema.optional(),
lastUsage: AgentUsageSchema.optional(),
lastError: z.string().optional(),
title: z.string().nullable(),
requiresAttention: z.boolean().optional(),
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
attentionTimestamp: z.string().nullable().optional(),
parentAgentId: z.string().nullable().optional(),
});
export type AgentSnapshotPayload = z.infer<typeof AgentSnapshotPayloadSchema>;
export type AgentStreamEventPayload = z.infer<
typeof AgentStreamEventPayloadSchema
>;
export * from "../shared/messages.js";
export function serializeAgentSnapshot(
agent: ManagedAgent,
@@ -270,721 +20,3 @@ export function serializeAgentStreamEvent(
): AgentStreamEventPayload {
return event as AgentStreamEventPayload;
}
// ============================================================================
// Session Inbound Messages (Session receives these)
// ============================================================================
export const UserTextMessageSchema = z.object({
type: z.literal("user_text"),
text: z.string(),
});
export const RealtimeAudioChunkMessageSchema = z.object({
type: z.literal("realtime_audio_chunk"),
audio: z.string(), // base64 encoded
format: z.string(),
isLast: z.boolean(),
});
export const AbortRequestMessageSchema = z.object({
type: z.literal("abort_request"),
});
export const AudioPlayedMessageSchema = z.object({
type: z.literal("audio_played"),
id: z.string(),
});
export const LoadConversationRequestMessageSchema = z.object({
type: z.literal("load_conversation_request"),
conversationId: z.string(),
});
export const ListConversationsRequestMessageSchema = z.object({
type: z.literal("list_conversations_request"),
});
export const DeleteConversationRequestMessageSchema = z.object({
type: z.literal("delete_conversation_request"),
conversationId: z.string(),
});
export const DeleteAgentRequestMessageSchema = z.object({
type: z.literal("delete_agent_request"),
agentId: z.string(),
});
export const SetRealtimeModeMessageSchema = z.object({
type: z.literal("set_realtime_mode"),
enabled: z.boolean(),
});
export const SendAgentMessageSchema = z.object({
type: z.literal("send_agent_message"),
agentId: z.string(),
text: z.string(),
messageId: z.string().optional(), // Client-provided ID for deduplication
images: z.array(z.object({
data: z.string(), // base64 encoded image
mimeType: z.string(), // e.g., "image/jpeg", "image/png"
})).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
audio: z.string(), // base64 encoded
format: z.string(),
isLast: z.boolean(),
requestId: z.string().optional(), // Client-provided ID for tracking transcription
mode: z
.enum(["transcribe_only", "auto_run"])
.optional(),
});
const GitSetupOptionsSchema = z.object({
baseBranch: z.string().optional(),
createNewBranch: z.boolean().optional(),
newBranchName: z.string().optional(),
createWorktree: z.boolean().optional(),
worktreeSlug: z.string().optional(),
});
export type GitSetupOptions = z.infer<typeof GitSetupOptionsSchema>;
export const CreateAgentRequestMessageSchema = z.object({
type: z.literal("create_agent_request"),
config: AgentSessionConfigSchema,
worktreeName: z.string().optional(),
initialPrompt: z.string().optional(),
images: z.array(z.object({
data: z.string(), // base64 encoded image
mimeType: z.string(), // e.g., "image/jpeg", "image/png"
})).optional(),
git: GitSetupOptionsSchema.optional(),
requestId: z.string().optional(),
});
export const ListProviderModelsRequestMessageSchema = z.object({
type: z.literal("list_provider_models_request"),
provider: AgentProviderSchema,
cwd: z.string().optional(),
requestId: z.string().optional(),
});
// Legacy alias used by older clients; keep for compatibility
export const GitRepoInfoRequestMessageSchema = z.object({
type: z.literal("git_repo_info_request"),
cwd: z.string(),
requestId: z.string().optional(),
});
export const ResumeAgentRequestMessageSchema = z.object({
type: z.literal("resume_agent_request"),
handle: AgentPersistenceHandleSchema,
overrides: AgentSessionConfigSchema.partial().optional(),
requestId: z.string().optional(),
});
export const RefreshAgentRequestMessageSchema = z.object({
type: z.literal("refresh_agent_request"),
agentId: z.string(),
requestId: z.string().optional(),
});
export const CancelAgentRequestMessageSchema = z.object({
type: z.literal("cancel_agent_request"),
agentId: z.string(),
});
export const RestartServerRequestMessageSchema = z.object({
type: z.literal("restart_server_request"),
reason: z.string().optional(),
});
export const InitializeAgentRequestMessageSchema = z.object({
type: z.literal("initialize_agent_request"),
agentId: z.string(),
requestId: z.string().optional(),
});
export const InitializeAgentResponseMessageSchema = z.object({
// Response shares the same type literal as the request for simple requestId matching
type: z.literal("initialize_agent_request"),
payload: z.object({
agentId: z.string(),
agentStatus: z.string().optional(),
timelineSize: z.number().optional(),
requestId: z.string().optional(),
error: z.string().optional(),
}),
});
export const SetAgentModeMessageSchema = z.object({
type: z.literal("set_agent_mode"),
agentId: z.string(),
modeId: z.string(),
});
export const AgentPermissionResponseMessageSchema = z.object({
type: z.literal("agent_permission_response"),
agentId: z.string(),
requestId: z.string(),
response: AgentPermissionResponseSchema,
});
export const GitDiffRequestSchema = z.object({
type: z.literal("git_diff_request"),
agentId: z.string(),
requestId: z.string().optional(),
});
// Highlighted diff token schema
const HighlightTokenSchema = z.object({
text: z.string(),
style: z.enum([
"keyword", "comment", "string", "number", "literal",
"function", "definition", "class", "type", "tag",
"attribute", "property", "variable", "operator",
"punctuation", "regexp", "escape", "meta", "heading", "link",
]).nullable(),
});
const DiffLineSchema = z.object({
type: z.enum(["add", "remove", "context", "header"]),
content: z.string(),
tokens: z.array(HighlightTokenSchema).optional(),
});
const DiffHunkSchema = z.object({
oldStart: z.number(),
oldCount: z.number(),
newStart: z.number(),
newCount: z.number(),
lines: z.array(DiffLineSchema),
});
const ParsedDiffFileSchema = z.object({
path: z.string(),
isNew: z.boolean(),
isDeleted: z.boolean(),
additions: z.number(),
deletions: z.number(),
hunks: z.array(DiffHunkSchema),
});
export const HighlightedDiffRequestSchema = z.object({
type: z.literal("highlighted_diff_request"),
agentId: z.string(),
requestId: z.string().optional(),
});
const FileExplorerEntrySchema = z.object({
name: z.string(),
path: z.string(),
kind: z.enum(["file", "directory"]),
size: z.number(),
modifiedAt: z.string(),
});
const FileExplorerFileSchema = z.object({
path: z.string(),
kind: z.enum(["text", "image", "binary"]),
encoding: z.enum(["utf-8", "base64", "none"]),
content: z.string().optional(),
mimeType: z.string().optional(),
size: z.number(),
modifiedAt: z.string(),
});
const FileExplorerDirectorySchema = z.object({
path: z.string(),
entries: z.array(FileExplorerEntrySchema),
});
export const FileExplorerRequestSchema = z.object({
type: z.literal("file_explorer_request"),
agentId: z.string(),
path: z.string().optional(),
mode: z.enum(["list", "file"]),
});
export const FileDownloadTokenRequestSchema = z.object({
type: z.literal("file_download_token_request"),
agentId: z.string(),
path: z.string(),
requestId: z.string().optional(),
});
export const ClearAgentAttentionMessageSchema = z.object({
type: z.literal("clear_agent_attention"),
agentId: z.union([z.string(), z.array(z.string())]),
});
export const ListCommandsRequestSchema = z.object({
type: z.literal("list_commands_request"),
agentId: z.string(),
requestId: z.string().optional(),
});
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
UserTextMessageSchema,
RealtimeAudioChunkMessageSchema,
AbortRequestMessageSchema,
AudioPlayedMessageSchema,
LoadConversationRequestMessageSchema,
ListConversationsRequestMessageSchema,
DeleteConversationRequestMessageSchema,
DeleteAgentRequestMessageSchema,
SetRealtimeModeMessageSchema,
SendAgentMessageSchema,
SendAgentAudioSchema,
CreateAgentRequestMessageSchema,
ListProviderModelsRequestMessageSchema,
ResumeAgentRequestMessageSchema,
RefreshAgentRequestMessageSchema,
CancelAgentRequestMessageSchema,
RestartServerRequestMessageSchema,
InitializeAgentRequestMessageSchema,
SetAgentModeMessageSchema,
AgentPermissionResponseMessageSchema,
GitDiffRequestSchema,
HighlightedDiffRequestSchema,
FileExplorerRequestSchema,
FileDownloadTokenRequestSchema,
GitRepoInfoRequestMessageSchema,
ClearAgentAttentionMessageSchema,
ListCommandsRequestSchema,
]);
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
// ============================================================================
// Session Outbound Messages (Session emits these)
// ============================================================================
export const ActivityLogPayloadSchema = z.object({
id: z.string(),
timestamp: z.date(),
type: z.enum([
"transcript",
"assistant",
"tool_call",
"tool_result",
"error",
"system",
]),
content: z.string(),
metadata: z.record(z.unknown()).optional(),
});
export const ActivityLogMessageSchema = z.object({
type: z.literal("activity_log"),
payload: ActivityLogPayloadSchema,
});
export const AssistantChunkMessageSchema = z.object({
type: z.literal("assistant_chunk"),
payload: z.object({
chunk: z.string(),
}),
});
export const AudioOutputMessageSchema = z.object({
type: z.literal("audio_output"),
payload: z.object({
audio: z.string(), // base64 encoded
format: z.string(),
id: z.string(),
isRealtimeMode: z.boolean(), // Mode when audio was generated (for drift protection)
groupId: z.string().optional(), // Logical utterance id
chunkIndex: z.number().int().nonnegative().optional(),
isLastChunk: z.boolean().optional(),
}),
});
export const TranscriptionResultMessageSchema = z.object({
type: z.literal("transcription_result"),
payload: z.object({
text: z.string(),
language: z.string().optional(),
duration: z.number().optional(),
requestId: z.string().optional(), // Echoed back from request for tracking
avgLogprob: z.number().optional(),
isLowConfidence: z.boolean().optional(),
byteLength: z.number().optional(),
format: z.string().optional(),
debugRecordingPath: z.string().optional(),
}),
});
export const StatusMessageSchema = z.object({
type: z.literal("status"),
payload: z
.object({
status: z.string(),
})
.passthrough(), // Allow additional fields
});
export const ArtifactMessageSchema = z.object({
type: z.literal("artifact"),
payload: z.object({
type: z.enum(["markdown", "diff", "image", "code"]),
id: z.string(),
title: z.string(),
content: z.string(),
isBase64: z.boolean(),
}),
});
export const ConversationLoadedMessageSchema = z.object({
type: z.literal("conversation_loaded"),
payload: z.object({
conversationId: z.string(),
messageCount: z.number(),
}),
});
export const AgentStateMessageSchema = z.object({
type: z.literal("agent_state"),
payload: AgentSnapshotPayloadSchema,
});
export const AgentStreamMessageSchema = z.object({
type: z.literal("agent_stream"),
payload: z.object({
agentId: z.string(),
event: AgentStreamEventPayloadSchema,
timestamp: z.string(),
}),
});
export const AgentStreamSnapshotMessageSchema = z.object({
type: z.literal("agent_stream_snapshot"),
payload: z.object({
agentId: z.string(),
events: z.array(
z.object({
event: AgentStreamEventPayloadSchema,
timestamp: z.string(),
})
),
}),
});
export const AgentStatusMessageSchema = z.object({
type: z.literal("agent_status"),
payload: z.object({
agentId: z.string(),
status: z.string(),
info: AgentSnapshotPayloadSchema,
}),
});
export const SessionStateMessageSchema = z.object({
type: z.literal("session_state"),
payload: z.object({
agents: z.array(AgentSnapshotPayloadSchema),
}),
});
export const ListConversationsResponseMessageSchema = z.object({
type: z.literal("list_conversations_response"),
payload: z.object({
conversations: z.array(
z.object({
id: z.string(),
lastUpdated: z.string(),
messageCount: z.number(),
})
),
}),
});
export const DeleteConversationResponseMessageSchema = z.object({
type: z.literal("delete_conversation_response"),
payload: z.object({
conversationId: z.string(),
success: z.boolean(),
error: z.string().optional(),
}),
});
export const AgentPermissionRequestMessageSchema = z.object({
type: z.literal("agent_permission_request"),
payload: z.object({
agentId: z.string(),
request: AgentPermissionRequestPayloadSchema,
}),
});
export const AgentPermissionResolvedMessageSchema = z.object({
type: z.literal("agent_permission_resolved"),
payload: z.object({
agentId: z.string(),
requestId: z.string(),
resolution: AgentPermissionResponseSchema,
}),
});
export const AgentDeletedMessageSchema = z.object({
type: z.literal("agent_deleted"),
payload: z.object({
agentId: z.string(),
}),
});
export const GitDiffResponseSchema = z.object({
type: z.literal("git_diff_response"),
payload: z.object({
agentId: z.string(),
diff: z.string(),
error: z.string().nullable(),
requestId: z.string().optional(),
}),
});
export const HighlightedDiffResponseSchema = z.object({
type: z.literal("highlighted_diff_response"),
payload: z.object({
agentId: z.string(),
files: z.array(ParsedDiffFileSchema),
error: z.string().nullable(),
requestId: z.string().optional(),
}),
});
export const FileExplorerResponseSchema = z.object({
type: z.literal("file_explorer_response"),
payload: z.object({
agentId: z.string(),
path: z.string(),
mode: z.enum(["list", "file"]),
directory: FileExplorerDirectorySchema.nullable(),
file: FileExplorerFileSchema.nullable(),
error: z.string().nullable(),
}),
});
export const FileDownloadTokenResponseSchema = z.object({
type: z.literal("file_download_token_response"),
payload: z.object({
agentId: z.string(),
path: z.string(),
token: z.string().nullable(),
fileName: z.string().nullable(),
mimeType: z.string().nullable(),
size: z.number().nullable(),
error: z.string().nullable(),
requestId: z.string().optional(),
}),
});
const GitBranchInfoSchema = z.object({
name: z.string(),
isCurrent: z.boolean(),
});
export const GitRepoInfoResponseSchema = z.object({
type: z.literal("git_repo_info_response"),
payload: z.object({
cwd: z.string(),
repoRoot: z.string(),
requestId: z.string().optional(),
branches: z.array(GitBranchInfoSchema).optional(),
currentBranch: z.string().nullable().optional(),
isDirty: z.boolean().optional(),
error: z.string().optional(),
}),
});
export const ListProviderModelsResponseMessageSchema = z.object({
type: z.literal("list_provider_models_response"),
payload: z.object({
provider: AgentProviderSchema,
models: z.array(AgentModelDefinitionSchema).optional(),
error: z.string().optional(),
fetchedAt: z.string(),
requestId: z.string().optional(),
}),
});
const AgentSlashCommandSchema = z.object({
name: z.string(),
description: z.string(),
argumentHint: z.string(),
});
export const ListCommandsResponseSchema = z.object({
type: z.literal("list_commands_response"),
payload: z.object({
agentId: z.string(),
commands: z.array(AgentSlashCommandSchema),
error: z.string().nullable(),
requestId: z.string().optional(),
}),
});
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ActivityLogMessageSchema,
AssistantChunkMessageSchema,
AudioOutputMessageSchema,
TranscriptionResultMessageSchema,
StatusMessageSchema,
InitializeAgentResponseMessageSchema,
ArtifactMessageSchema,
ConversationLoadedMessageSchema,
AgentStateMessageSchema,
AgentStreamMessageSchema,
AgentStreamSnapshotMessageSchema,
AgentStatusMessageSchema,
SessionStateMessageSchema,
ListConversationsResponseMessageSchema,
DeleteConversationResponseMessageSchema,
AgentPermissionRequestMessageSchema,
AgentPermissionResolvedMessageSchema,
AgentDeletedMessageSchema,
GitDiffResponseSchema,
HighlightedDiffResponseSchema,
FileExplorerResponseSchema,
FileDownloadTokenResponseSchema,
GitRepoInfoResponseSchema,
ListProviderModelsResponseMessageSchema,
ListCommandsResponseSchema,
]);
export type SessionOutboundMessage = z.infer<
typeof SessionOutboundMessageSchema
>;
// Type exports for individual message types
export type ActivityLogMessage = z.infer<typeof ActivityLogMessageSchema>;
export type AssistantChunkMessage = z.infer<typeof AssistantChunkMessageSchema>;
export type AudioOutputMessage = z.infer<typeof AudioOutputMessageSchema>;
export type TranscriptionResultMessage = z.infer<typeof TranscriptionResultMessageSchema>;
export type StatusMessage = z.infer<typeof StatusMessageSchema>;
export type ArtifactMessage = z.infer<typeof ArtifactMessageSchema>;
export type ConversationLoadedMessage = z.infer<typeof ConversationLoadedMessageSchema>;
export type AgentStateMessage = z.infer<typeof AgentStateMessageSchema>;
export type AgentStreamMessage = z.infer<typeof AgentStreamMessageSchema>;
export type AgentStreamSnapshotMessage = z.infer<
typeof AgentStreamSnapshotMessageSchema
>;
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>;
export type SessionStateMessage = z.infer<typeof SessionStateMessageSchema>;
export type ListConversationsResponseMessage = z.infer<typeof ListConversationsResponseMessageSchema>;
export type DeleteConversationResponseMessage = z.infer<typeof DeleteConversationResponseMessageSchema>;
export type AgentPermissionRequestMessage = z.infer<typeof AgentPermissionRequestMessageSchema>;
export type AgentPermissionResolvedMessage = z.infer<typeof AgentPermissionResolvedMessageSchema>;
export type AgentDeletedMessage = z.infer<typeof AgentDeletedMessageSchema>;
export type ListProviderModelsResponseMessage = z.infer<
typeof ListProviderModelsResponseMessageSchema
>;
export type InitializeAgentResponseMessage = z.infer<typeof InitializeAgentResponseMessageSchema>;
// Type exports for payload types
export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
// Type exports for inbound message types
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 CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>;
export type ListProviderModelsRequestMessage = z.infer<
typeof ListProviderModelsRequestMessageSchema
>;
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
export type InitializeAgentRequestMessage = z.infer<typeof InitializeAgentRequestMessageSchema>;
export type SetAgentModeMessage = z.infer<typeof SetAgentModeMessageSchema>;
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
export type HighlightedDiffRequest = z.infer<typeof HighlightedDiffRequestSchema>;
export type HighlightedDiffResponse = z.infer<typeof HighlightedDiffResponseSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>;
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>;
export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>;
export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessageSchema>;
export type ListCommandsRequest = z.infer<typeof ListCommandsRequestSchema>;
export type ListCommandsResponse = z.infer<typeof ListCommandsResponseSchema>;
// ============================================================================
// WebSocket Level Messages (wraps session messages)
// ============================================================================
// WebSocket-only messages (not session messages)
export const WSPingMessageSchema = z.object({
type: z.literal("ping"),
});
export const WSPongMessageSchema = z.object({
type: z.literal("pong"),
});
export const WSRecordingStateMessageSchema = z.object({
type: z.literal("recording_state"),
isRecording: z.boolean(),
});
// Wrapped session message
export const WSSessionInboundSchema = z.object({
type: z.literal("session"),
message: SessionInboundMessageSchema,
});
export const WSSessionOutboundSchema = z.object({
type: z.literal("session"),
message: SessionOutboundMessageSchema,
});
// Complete WebSocket message schemas
export const WSInboundMessageSchema = z.discriminatedUnion("type", [
WSPingMessageSchema,
WSRecordingStateMessageSchema,
WSSessionInboundSchema,
]);
export const WSOutboundMessageSchema = z.discriminatedUnion("type", [
WSPongMessageSchema,
WSSessionOutboundSchema,
]);
export type WSInboundMessage = z.infer<typeof WSInboundMessageSchema>;
export type WSOutboundMessage = z.infer<typeof WSOutboundMessageSchema>;
// ============================================================================
// Helper functions for message conversion
// ============================================================================
/**
* Extract session message from WebSocket message
* Returns null if message should be handled at WS level only
*/
export function extractSessionMessage(
wsMsg: WSInboundMessage
): SessionInboundMessage | null {
if (wsMsg.type === "session") {
return wsMsg.message;
}
// Ping and recording_state are WS-level only
return null;
}
/**
* Wrap session message in WebSocket envelope
*/
export function wrapSessionMessage(
sessionMsg: SessionOutboundMessage
): WSOutboundMessage {
return {
type: "session",
message: sessionMsg,
};
}
+71 -26
View File
@@ -699,19 +699,19 @@ export class Session {
break;
case "load_conversation_request":
await this.handleLoadConversation();
await this.handleLoadConversation(msg.requestId);
break;
case "list_conversations_request":
await this.handleListConversations();
await this.handleListConversations(msg.requestId);
break;
case "delete_conversation_request":
await this.handleDeleteConversation(msg.conversationId);
await this.handleDeleteConversation(msg.conversationId, msg.requestId);
break;
case "delete_agent_request":
await this.handleDeleteAgentRequest(msg.agentId);
await this.handleDeleteAgentRequest(msg.agentId, msg.requestId);
break;
case "set_realtime_mode":
@@ -747,9 +747,9 @@ export class Session {
await this.handleCancelAgentRequest(msg.agentId);
break;
case "restart_server_request":
await this.handleRestartServerRequest(msg.reason);
break;
case "restart_server_request":
await this.handleRestartServerRequest(msg.requestId, msg.reason);
break;
case "initialize_agent_request":
await this.handleInitializeAgentRequest(msg.agentId, msg.requestId);
@@ -819,13 +819,14 @@ export class Session {
/**
* Load existing conversation
*/
public async handleLoadConversation(): Promise<void> {
public async handleLoadConversation(requestId: string): Promise<void> {
// This is handled during construction, but we emit a confirmation message
this.emit({
type: "conversation_loaded",
payload: {
conversationId: this.conversationId,
messageCount: this.messages.length,
requestId,
},
});
@@ -836,7 +837,7 @@ export class Session {
/**
* List all conversations
*/
public async handleListConversations(): Promise<void> {
public async handleListConversations(requestId: string): Promise<void> {
try {
const conversations = await listConversations();
this.emit({
@@ -847,6 +848,7 @@ export class Session {
lastUpdated: conv.lastUpdated.toISOString(),
messageCount: conv.messageCount,
})),
requestId,
},
});
} catch (error: any) {
@@ -869,7 +871,10 @@ export class Session {
/**
* Delete a conversation
*/
public async handleDeleteConversation(conversationId: string): Promise<void> {
public async handleDeleteConversation(
conversationId: string,
requestId: string
): Promise<void> {
try {
await deleteConversation(conversationId);
this.emit({
@@ -877,6 +882,7 @@ export class Session {
payload: {
conversationId,
success: true,
requestId,
},
});
console.log(
@@ -893,12 +899,16 @@ export class Session {
conversationId,
success: false,
error: error.message,
requestId,
},
});
}
}
private async handleRestartServerRequest(reason?: string): Promise<void> {
private async handleRestartServerRequest(
requestId: string,
reason?: string
): Promise<void> {
if (restartRequested) {
console.log(
`[Session ${this.clientId}] Restart already requested, ignoring duplicate`
@@ -914,6 +924,7 @@ export class Session {
if (reason && reason.trim().length > 0) {
payload.reason = reason;
}
payload.requestId = requestId;
console.warn(`[Session ${this.clientId}] Restart requested via websocket`);
this.emit({
@@ -934,7 +945,10 @@ export class Session {
}, RESTART_EXIT_DELAY_MS);
}
private async handleDeleteAgentRequest(agentId: string): Promise<void> {
private async handleDeleteAgentRequest(
agentId: string,
requestId: string
): Promise<void> {
console.log(
`[Session ${this.clientId}] Deleting agent ${agentId} from registry`
);
@@ -962,6 +976,7 @@ export class Session {
type: "agent_deleted",
payload: {
agentId,
requestId,
},
});
}
@@ -1104,7 +1119,7 @@ export class Session {
if (!shouldAutoRun) {
console.log(
`[Session ${this.clientId}] Completed transcription for agent ${logAgentId} (requestId: ${requestId ?? "n/a"})`
`[Session ${this.clientId}] Completed transcription for agent ${logAgentId} (requestId: ${requestId})`
);
return;
}
@@ -1171,7 +1186,7 @@ export class Session {
*/
private async handleInitializeAgentRequest(
agentId: string,
requestId?: string
requestId: string
): Promise<void> {
console.log(
`[Session ${this.clientId}] Initializing agent ${agentId} on demand`
@@ -1393,9 +1408,13 @@ export class Session {
const existing = this.agentManager.getAgent(agentId);
if (existing) {
await this.interruptAgentIfRunning(agentId);
snapshot = await this.agentManager.refreshAgentFromPersistence(
agentId
);
if (existing.persistence) {
snapshot = await this.agentManager.refreshAgentFromPersistence(
agentId
);
} else {
snapshot = existing;
}
} else {
const record = await this.agentRegistry.get(agentId);
if (!record) {
@@ -1564,6 +1583,7 @@ export class Session {
branches,
currentBranch: currentBranch || null,
isDirty,
error: null,
},
});
} catch (error) {
@@ -1592,8 +1612,9 @@ export class Session {
payload: {
provider: msg.provider,
models,
error: null,
fetchedAt,
...(msg.requestId ? { requestId: msg.requestId } : {}),
requestId: msg.requestId,
},
});
} catch (error) {
@@ -1607,7 +1628,7 @@ export class Session {
provider: msg.provider,
error: (error as Error)?.message ?? String(error),
fetchedAt,
...(msg.requestId ? { requestId: msg.requestId } : {}),
requestId: msg.requestId,
},
});
}
@@ -1825,7 +1846,7 @@ export class Session {
/**
* Handle list commands request for an agent
*/
private async handleListCommandsRequest(agentId: string, requestId?: string): Promise<void> {
private async handleListCommandsRequest(agentId: string, requestId: string): Promise<void> {
console.log(
`[Session ${this.clientId}] Handling list commands request for agent ${agentId}`
);
@@ -1927,7 +1948,7 @@ export class Session {
/**
* Handle git diff request for an agent
*/
private async handleGitDiffRequest(agentId: string, requestId?: string): Promise<void> {
private async handleGitDiffRequest(agentId: string, requestId: string): Promise<void> {
console.log(
`[Session ${this.clientId}] Handling git diff request for agent ${agentId}`
);
@@ -2019,7 +2040,7 @@ export class Session {
*/
private async handleHighlightedDiffRequest(
agentId: string,
requestId?: string
requestId: string
): Promise<void> {
console.log(
`[Session ${this.clientId}] Handling highlighted diff request for agent ${agentId}`
@@ -2117,7 +2138,7 @@ export class Session {
private async handleFileExplorerRequest(
request: FileExplorerRequest
): Promise<void> {
const { agentId, path: requestedPath = ".", mode } = request;
const { agentId, path: requestedPath = ".", mode, requestId } = request;
console.log(
`[Session ${this.clientId}] Handling file explorer request for agent ${agentId} (${mode} ${requestedPath})`
@@ -2137,6 +2158,7 @@ export class Session {
directory: null,
file: null,
error: `Agent not found: ${agentId}`,
requestId,
},
});
return;
@@ -2157,6 +2179,7 @@ export class Session {
directory,
file: null,
error: null,
requestId,
},
});
} else {
@@ -2174,6 +2197,7 @@ export class Session {
directory: null,
file,
error: null,
requestId,
},
});
}
@@ -2191,6 +2215,7 @@ export class Session {
directory: null,
file: null,
error: error.message,
requestId,
},
});
}
@@ -2223,7 +2248,7 @@ export class Session {
mimeType: null,
size: null,
error: `Agent not found: ${agentId}`,
...(requestId ? { requestId } : {}),
requestId,
},
});
return;
@@ -2253,7 +2278,7 @@ export class Session {
mimeType: entry.mimeType,
size: entry.size,
error: null,
...(requestId ? { requestId } : {}),
requestId,
},
});
} catch (error: any) {
@@ -2271,7 +2296,7 @@ export class Session {
mimeType: null,
size: null,
error: error.message,
...(requestId ? { requestId } : {}),
requestId,
},
});
}
@@ -2590,6 +2615,7 @@ export class Session {
text: result.text,
language: result.language,
duration: result.duration,
requestId: uuidv4(),
avgLogprob: result.avgLogprob,
isLowConfidence: result.isLowConfidence,
byteLength: result.byteLength,
@@ -2645,6 +2671,7 @@ export class Session {
let assistantResponse = "";
let pendingTTS: Promise<void> | null = null;
let textBuffer = "";
let sawTextDelta = false;
const flushTextBuffer = () => {
if (textBuffer.length > 0) {
@@ -2741,6 +2768,7 @@ export class Session {
},
onChunk: async ({ chunk }) => {
if (chunk.type === "text-delta") {
sawTextDelta = true;
// Accumulate text in buffer
textBuffer += chunk.text;
assistantResponse += chunk.text;
@@ -2860,6 +2888,23 @@ export class Session {
}
}
if (!sawTextDelta) {
let fallbackText = "";
try {
fallbackText = (await result.text).trim();
} catch {
fallbackText = "";
}
if (fallbackText.length > 0) {
textBuffer += fallbackText;
assistantResponse += fallbackText;
this.emit({
type: "assistant_chunk",
payload: { chunk: fallbackText },
});
}
}
// Flush any remaining text at the end
flushTextBuffer();
@@ -568,6 +568,8 @@ export async function executeCommand({
let isDead = false;
let exitCode: number | null = null;
const emptyOutputGraceMs = Math.min(maxWait, 2000);
while (Date.now() - startTime < maxWait) {
// Check if pane is dead (command exited)
const deadStatus = await executeTmux([
@@ -600,6 +602,10 @@ export async function executeCommand({
await new Promise((resolve) => setTimeout(resolve, 500));
const confirmedOutput = await capturePaneContent(pane.id, 1000, false);
if (confirmedOutput === currentOutput) {
const elapsed = Date.now() - startTime;
if (!confirmedOutput && elapsed < emptyOutputGraceMs) {
continue;
}
// Stable - command is likely waiting for input or running steadily
output = confirmedOutput;
break;
@@ -1,872 +1,28 @@
import WebSocket from "ws";
import { nanoid } from "nanoid";
import type {
SessionInboundMessage,
SessionOutboundMessage,
AgentSnapshotPayload,
AgentStreamEventPayload,
} from "../messages.js";
import type {
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentProvider,
AgentSlashCommand,
} from "../agent/agent-sdk-types.js";
import { getAgentProviderDefinition } from "../agent/provider-registry.js";
import {
DaemonClientV2 as SharedDaemonClient,
type DaemonClientV2Config as SharedDaemonClientConfig,
type CreateAgentRequestOptions,
type DaemonEvent,
type DaemonEventHandler,
type SendMessageOptions,
type WebSocketLike,
} from "../../client/daemon-client-v2.js";
// ============================================================================
// Configuration
// ============================================================================
export type DaemonClientConfig = Omit<
SharedDaemonClientConfig,
"webSocketFactory" | "transportFactory"
>;
export type CreateAgentOptions = CreateAgentRequestOptions;
export { type SendMessageOptions, type DaemonEvent, type DaemonEventHandler };
export interface DaemonClientConfig {
url: string;
authHeader?: string;
}
export interface CreateAgentOptions {
provider: AgentProvider;
cwd: string;
title?: string;
model?: string;
modeId?: string;
initialPrompt?: string;
mcpServers?: Record<string, unknown>;
extra?: Record<string, unknown>;
}
export interface SendMessageOptions {
messageId?: string;
images?: Array<{ data: string; mimeType: string }>;
}
// ============================================================================
// Event Types
// ============================================================================
export type DaemonEvent =
| { type: "agent_state"; agentId: string; payload: AgentSnapshotPayload }
| {
type: "agent_stream";
agentId: string;
event: AgentStreamEventPayload;
timestamp: string;
}
| { type: "session_state"; agents: AgentSnapshotPayload[] }
| { type: "status"; payload: { status: string } }
| { type: "agent_deleted"; agentId: string }
| { type: "agent_permission_request"; agentId: string; request: AgentPermissionRequest }
| {
type: "agent_permission_resolved";
agentId: string;
requestId: string;
resolution: AgentPermissionResponse;
}
| { type: "error"; message: string };
export type DaemonEventHandler = (event: DaemonEvent) => void;
// ============================================================================
// DaemonClient
// ============================================================================
export class DaemonClient {
private ws: WebSocket | null = null;
private messageQueue: SessionOutboundMessage[] = [];
private eventListeners: Set<DaemonEventHandler> = new Set();
private messageListeners: Set<() => void> = new Set();
constructor(private config: DaemonClientConfig) {}
// ============================================================================
// Connection
// ============================================================================
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
const headers: Record<string, string> = {};
if (this.config.authHeader) {
headers["Authorization"] = this.config.authHeader;
}
this.ws = new WebSocket(this.config.url, { headers });
const onOpen = (): void => {
cleanup();
resolve();
};
const onError = (err: Error): void => {
cleanup();
reject(err);
};
const onMessage = (data: WebSocket.RawData): void => {
try {
const parsed = JSON.parse(data.toString()) as {
type: string;
message?: SessionOutboundMessage;
};
if (parsed.type === "pong") return;
if (parsed.type === "session" && parsed.message) {
this.handleSessionMessage(parsed.message);
}
} catch {
// Ignore parse errors
}
};
const cleanup = (): void => {
this.ws?.off("open", onOpen);
this.ws?.off("error", onError);
};
this.ws.on("open", onOpen);
this.ws.on("error", onError);
this.ws.on("message", onMessage);
export class DaemonClient extends SharedDaemonClient {
constructor(config: DaemonClientConfig) {
super({
...config,
messageQueueLimit: config.messageQueueLimit ?? null,
webSocketFactory: (url, options) =>
new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike,
});
}
async close(): Promise<void> {
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.messageQueue = [];
this.eventListeners.clear();
this.messageListeners.clear();
}
// ============================================================================
// Agent Lifecycle
// ============================================================================
async createAgent(options: CreateAgentOptions): Promise<AgentSnapshotPayload> {
const requestId = nanoid();
// Record the current queue position so we only check NEW messages
const startPosition = this.messageQueue.length;
// Apply default modeId if not provided (mimics frontend behavior)
const modeId =
options.modeId ??
getAgentProviderDefinition(options.provider).defaultModeId ??
undefined;
this.send({
type: "create_agent_request",
requestId,
config: {
provider: options.provider,
cwd: options.cwd,
title: options.title,
model: options.model,
modeId,
mcpServers: options.mcpServers,
extra: options.extra,
},
initialPrompt: options.initialPrompt,
});
// First get the agent ID from the initial state (only check new messages)
let agentId: string | null = null;
await this.waitFor(
(msg) => {
if (msg.type === "agent_state") {
agentId = msg.payload.id;
return msg.payload;
}
return null;
},
10000,
{ skipQueueBefore: startPosition }
);
if (!agentId) {
throw new Error("Failed to get agent ID from create response");
}
// Wait for the agent to be idle (only check new messages from startPosition)
return this.waitFor(
(msg) => {
if (
msg.type === "agent_state" &&
msg.payload.id === agentId &&
msg.payload.status === "idle"
) {
return msg.payload;
}
return null;
},
60000,
{ skipQueueBefore: startPosition }
); // 60 second timeout for initialization
}
/**
* Create an agent and expect it to fail.
* Returns the error message from the failure.
*/
async createAgentExpectFail(
options: CreateAgentOptions
): Promise<{ error: string }> {
const requestId = nanoid();
// Record the current queue position so we only check NEW messages
const startPosition = this.messageQueue.length;
// Apply default modeId if not provided (mimics frontend behavior)
const modeId =
options.modeId ??
getAgentProviderDefinition(options.provider).defaultModeId ??
undefined;
this.send({
type: "create_agent_request",
requestId,
config: {
provider: options.provider,
cwd: options.cwd,
title: options.title,
model: options.model,
modeId,
mcpServers: options.mcpServers,
extra: options.extra,
},
initialPrompt: options.initialPrompt,
});
// Wait for either agent_create_failed status or timeout
return this.waitFor(
(msg) => {
if (
msg.type === "status" &&
msg.payload.status === "agent_create_failed" &&
msg.payload.requestId === requestId
) {
return { error: String(msg.payload.error ?? "Unknown error") };
}
return null;
},
10000,
{ skipQueueBefore: startPosition }
);
}
async deleteAgent(agentId: string): Promise<void> {
this.send({ type: "delete_agent_request", agentId });
await this.waitFor((msg) => {
if (msg.type === "agent_deleted" && msg.payload.agentId === agentId) {
return true;
}
return null;
});
}
/**
* Returns the current list of agents by analyzing the message queue.
* This computes the latest state by:
* 1. Starting with agents from session_state (if any)
* 2. Updating with agent_state messages
* 3. Removing agents that have agent_deleted events
*/
listAgents(): AgentSnapshotPayload[] {
const agentMap = new Map<string, AgentSnapshotPayload>();
const deletedAgents = new Set<string>();
for (const msg of this.messageQueue) {
if (msg.type === "session_state") {
// Initial agents from session state
for (const agent of msg.payload.agents) {
agentMap.set(agent.id, agent);
}
} else if (msg.type === "agent_state") {
// Update or add agent from state event
agentMap.set(msg.payload.id, msg.payload);
} else if (msg.type === "agent_deleted") {
// Mark agent as deleted
deletedAgents.add(msg.payload.agentId);
}
}
// Filter out deleted agents and return
return Array.from(agentMap.values()).filter(
(agent) => !deletedAgents.has(agent.id)
);
}
async resumeAgent(
handle: AgentPersistenceHandle,
overrides?: Partial<CreateAgentOptions>
): Promise<AgentSnapshotPayload> {
const requestId = nanoid();
// Record the current queue position so we only check NEW messages
const startPosition = this.messageQueue.length;
this.send({
type: "resume_agent_request",
requestId,
handle,
overrides: overrides as Record<string, unknown>,
});
// First get the agent ID from a NEW state message (not old cached ones)
let agentId: string | null = null;
await this.waitFor(
(msg) => {
if (msg.type === "agent_state") {
agentId = msg.payload.id;
return msg.payload;
}
return null;
},
10000,
{ skipQueueBefore: startPosition }
);
if (!agentId) {
throw new Error("Failed to get agent ID from resume response");
}
// Wait for the new agent to be idle (like createAgent does)
return this.waitFor(
(msg) => {
if (
msg.type === "agent_state" &&
msg.payload.id === agentId &&
msg.payload.status === "idle"
) {
return msg.payload;
}
return null;
},
60000,
{ skipQueueBefore: startPosition }
); // 60 second timeout for initialization
}
/**
* Initialize an agent (fetch its current state without running a prompt).
* This mimics what happens when clicking on an agent in the UI.
*/
async initializeAgent(agentId: string): Promise<AgentSnapshotPayload> {
const requestId = nanoid();
const startPosition = this.messageQueue.length;
this.send({
type: "initialize_agent_request",
agentId,
requestId,
});
// Wait for agent_state with this agent's ID
return this.waitFor(
(msg) => {
if (msg.type === "agent_state" && msg.payload.id === agentId) {
return msg.payload;
}
return null;
},
10000,
{ skipQueueBefore: startPosition }
);
}
/**
* Clear agent attention (mark as viewed).
* This is what happens when opening an agent that requires attention.
*/
async clearAgentAttention(agentId: string): Promise<void> {
this.send({
type: "clear_agent_attention",
agentId,
});
}
// ============================================================================
// Agent Interaction
// ============================================================================
async sendMessage(
agentId: string,
text: string,
options?: SendMessageOptions
): Promise<void> {
this.send({
type: "send_agent_message",
agentId,
text,
messageId: options?.messageId,
images: options?.images,
});
}
async cancelAgent(agentId: string): Promise<void> {
this.send({ type: "cancel_agent_request", agentId });
}
async setAgentMode(agentId: string, modeId: string): Promise<void> {
this.send({ type: "set_agent_mode", agentId, modeId });
}
// ============================================================================
// Git Operations
// ============================================================================
async getGitDiff(
agentId: string
): Promise<{ diff: string; error: string | null }> {
const startPosition = this.messageQueue.length;
this.send({ type: "git_diff_request", agentId });
return this.waitFor(
(msg) => {
if (
msg.type === "git_diff_response" &&
msg.payload.agentId === agentId
) {
return { diff: msg.payload.diff, error: msg.payload.error };
}
return null;
},
10000,
{ skipQueueBefore: startPosition }
);
}
async getGitRepoInfo(agentId: string): Promise<{
repoRoot: string;
currentBranch: string | null;
branches: Array<{ name: string; isCurrent: boolean }>;
isDirty: boolean;
error: string | null;
}> {
// Get the agent's cwd from the current list
const agents = this.listAgents();
const agent = agents.find((a) => a.id === agentId);
if (!agent) {
return {
repoRoot: "",
currentBranch: null,
branches: [],
isDirty: false,
error: `Agent not found: ${agentId}`,
};
}
const cwd = agent.cwd;
const startPosition = this.messageQueue.length;
this.send({ type: "git_repo_info_request", cwd });
return this.waitFor(
(msg) => {
if (msg.type === "git_repo_info_response" && msg.payload.cwd === cwd) {
return {
repoRoot: msg.payload.repoRoot,
currentBranch: msg.payload.currentBranch ?? null,
branches: msg.payload.branches ?? [],
isDirty: msg.payload.isDirty ?? false,
error: msg.payload.error ?? null,
};
}
return null;
},
10000,
{ skipQueueBefore: startPosition }
);
}
// ============================================================================
// File Explorer
// ============================================================================
async exploreFileSystem(
agentId: string,
path: string,
mode: "list" | "file" = "list"
): Promise<{
path: string;
mode: "list" | "file";
directory: {
path: string;
entries: Array<{
name: string;
path: string;
kind: "file" | "directory";
size: number;
modifiedAt: string;
}>;
} | null;
file: {
path: string;
kind: "text" | "image" | "binary";
encoding: "utf-8" | "base64" | "none";
content?: string;
mimeType?: string;
size: number;
modifiedAt: string;
} | null;
error: string | null;
}> {
const startPosition = this.messageQueue.length;
this.send({ type: "file_explorer_request", agentId, path, mode });
return this.waitFor(
(msg) => {
if (
msg.type === "file_explorer_response" &&
msg.payload.agentId === agentId
) {
return {
path: msg.payload.path,
mode: msg.payload.mode,
directory: msg.payload.directory,
file: msg.payload.file,
error: msg.payload.error,
};
}
return null;
},
10000,
{ skipQueueBefore: startPosition }
);
}
async requestDownloadToken(
agentId: string,
path: string
): Promise<{
agentId: string;
path: string;
token: string | null;
fileName: string | null;
mimeType: string | null;
size: number | null;
error: string | null;
}> {
const startPosition = this.messageQueue.length;
this.send({ type: "file_download_token_request", agentId, path });
return this.waitFor(
(msg) => {
if (
msg.type === "file_download_token_response" &&
msg.payload.agentId === agentId
) {
return {
agentId: msg.payload.agentId,
path: msg.payload.path,
token: msg.payload.token,
fileName: msg.payload.fileName,
mimeType: msg.payload.mimeType,
size: msg.payload.size,
error: msg.payload.error,
};
}
return null;
},
10000,
{ skipQueueBefore: startPosition }
);
}
// ============================================================================
// Provider Models
// ============================================================================
async listProviderModels(
provider: AgentProvider,
options?: { cwd?: string }
): Promise<{
provider: AgentProvider;
models: AgentModelDefinition[];
fetchedAt: string;
error: string | null;
}> {
const startPosition = this.messageQueue.length;
this.send({
type: "list_provider_models_request",
provider,
cwd: options?.cwd,
});
return this.waitFor(
(msg) => {
if (
msg.type === "list_provider_models_response" &&
msg.payload.provider === provider
) {
return {
provider: msg.payload.provider,
models: msg.payload.models ?? [],
fetchedAt: msg.payload.fetchedAt,
error: msg.payload.error ?? null,
};
}
return null;
},
30000,
{ skipQueueBefore: startPosition }
);
}
// ============================================================================
// Commands
// ============================================================================
async listCommands(agentId: string): Promise<{
agentId: string;
commands: AgentSlashCommand[];
error: string | null;
}> {
const startPosition = this.messageQueue.length;
this.send({
type: "list_commands_request",
agentId,
});
return this.waitFor(
(msg) => {
if (
msg.type === "list_commands_response" &&
msg.payload.agentId === agentId
) {
return {
agentId: msg.payload.agentId,
commands: msg.payload.commands ?? [],
error: msg.payload.error ?? null,
};
}
return null;
},
30000,
{ skipQueueBefore: startPosition }
);
}
// ============================================================================
// Permissions
// ============================================================================
async respondToPermission(
agentId: string,
requestId: string,
response: AgentPermissionResponse
): Promise<void> {
this.send({
type: "agent_permission_response",
agentId,
requestId,
response,
});
}
// ============================================================================
// Waiting / Streaming
// ============================================================================
async waitForAgentIdle(
agentId: string,
timeout = 60000
): Promise<AgentSnapshotPayload> {
// Record the current queue position so we only check messages from NOW
const startPosition = this.messageQueue.length;
// First, wait for the agent to go to "running" state (or already be running)
// This ensures we don't return on an old "idle" state from before the message
let sawRunning = false;
return this.waitFor(
(msg) => {
if (msg.type === "agent_state" && msg.payload.id === agentId) {
const status = msg.payload.status;
if (status === "running") {
sawRunning = true;
}
// Only return idle/error AFTER we've seen running
if (sawRunning && (status === "idle" || status === "error")) {
return msg.payload;
}
}
return null;
},
timeout,
{ skipQueueBefore: startPosition }
);
}
async waitForPermission(
agentId: string,
timeout = 30000
): Promise<AgentPermissionRequest> {
return this.waitFor((msg) => {
// Check direct permission request message
if (
msg.type === "agent_permission_request" &&
msg.payload.agentId === agentId
) {
return msg.payload.request;
}
// Check stream event
if (msg.type === "agent_stream" && msg.payload.agentId === agentId) {
if (msg.payload.event.type === "permission_requested") {
return msg.payload.event.request;
}
}
return null;
}, timeout);
}
// ============================================================================
// Event Subscription
// ============================================================================
on(handler: DaemonEventHandler): () => void {
this.eventListeners.add(handler);
return () => this.eventListeners.delete(handler);
}
// ============================================================================
// Internals
// ============================================================================
private send(message: SessionInboundMessage): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket not connected");
}
this.ws.send(JSON.stringify({ type: "session", message }));
}
private handleSessionMessage(msg: SessionOutboundMessage): void {
this.messageQueue.push(msg);
// Notify message listeners (for waitFor) - just signal, they'll check the queue
for (const listener of this.messageListeners) {
listener();
}
// Notify event listeners
const event = this.toEvent(msg);
if (event) {
for (const handler of this.eventListeners) {
handler(event);
}
}
}
private toEvent(msg: SessionOutboundMessage): DaemonEvent | null {
switch (msg.type) {
case "agent_state":
return {
type: "agent_state",
agentId: msg.payload.id,
payload: msg.payload,
};
case "agent_stream":
return {
type: "agent_stream",
agentId: msg.payload.agentId,
event: msg.payload.event,
timestamp: msg.payload.timestamp,
};
case "session_state":
return { type: "session_state", agents: msg.payload.agents };
case "status":
return { type: "status", payload: msg.payload };
case "agent_deleted":
return { type: "agent_deleted", agentId: msg.payload.agentId };
case "agent_permission_request":
return {
type: "agent_permission_request",
agentId: msg.payload.agentId,
request: msg.payload.request,
};
case "agent_permission_resolved":
return {
type: "agent_permission_resolved",
agentId: msg.payload.agentId,
requestId: msg.payload.requestId,
resolution: msg.payload.resolution,
};
default:
return null;
}
}
private async waitFor<T>(
predicate: (msg: SessionOutboundMessage) => T | null,
timeout = 30000,
options?: { skipQueue?: boolean; skipQueueBefore?: number }
): Promise<T> {
// Record the starting queue length so we can track new messages
const startQueueLength = options?.skipQueueBefore ?? this.messageQueue.length;
// Check queued messages first (unless skipped or with position offset)
if (!options?.skipQueue && options?.skipQueueBefore === undefined) {
for (const msg of this.messageQueue) {
const result = predicate(msg);
if (result !== null) return result;
}
}
// Wait for new messages only
return new Promise((resolve, reject) => {
// Track which messages we've already checked
let checkedCount = startQueueLength;
const checkNewMessages = (): boolean => {
// Check any messages added since we last checked
while (checkedCount < this.messageQueue.length) {
const msg = this.messageQueue[checkedCount];
checkedCount++;
const result = predicate(msg);
if (result !== null) {
cleanup();
resolve(result);
return true;
}
}
return false;
};
const listener = (): void => {
checkNewMessages();
};
const timer = setTimeout(() => {
cleanup();
reject(new Error(`Timeout waiting for message (${timeout}ms)`));
}, timeout);
const cleanup = (): void => {
clearTimeout(timer);
this.messageListeners.delete(listener);
};
this.messageListeners.add(listener);
// Check any messages that arrived between startQueueLength and now
checkNewMessages();
});
}
// ============================================================================
// Debug / Utilities
// ============================================================================
getMessageQueue(): readonly SessionOutboundMessage[] {
return this.messageQueue;
}
clearMessageQueue(): void {
this.messageQueue = [];
}
}
@@ -39,9 +39,6 @@ async function getAvailablePort(): Promise<number> {
export async function createTestPaseoDaemon(
options: TestPaseoDaemonOptions = {}
): Promise<TestPaseoDaemon> {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const port = await getAvailablePort();
const basicUsers = options.basicUsers ?? { test: "pass" };
const [agentMcpUser, agentMcpPassword] = Object.entries(basicUsers)[0] ?? [];
const agentMcpAuthHeader =
@@ -52,52 +49,105 @@ export async function createTestPaseoDaemon(
agentMcpUser && agentMcpPassword
? Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")
: undefined;
const openaiApiKey = process.env.OPENAI_API_KEY;
const maxAttempts = 5;
let lastError: unknown;
const config: PaseoDaemonConfig = {
port,
paseoHome,
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 } }
: {}),
},
downloadTokenTtlMs: options.downloadTokenTtlMs,
};
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const port = await getAvailablePort();
const daemon = await createPaseoDaemon(config);
await new Promise<void>((resolve) => {
daemon.httpServer.listen(port, () => resolve());
});
const config: PaseoDaemonConfig = {
port,
paseoHome,
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 } }
: {}),
},
openai: openaiApiKey ? { apiKey: openaiApiKey } : undefined,
downloadTokenTtlMs: options.downloadTokenTtlMs,
};
const close = async (): Promise<void> => {
await daemon.close().catch(() => undefined);
// Wait a bit for file handles to release
await new Promise((r) => setTimeout(r, 200));
await rm(paseoHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
await rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
};
const daemon = await createPaseoDaemon(config);
try {
await listenOnPort(daemon.httpServer, port);
return {
config,
daemon,
port,
paseoHome,
staticDir,
agentMcpAuthHeader,
agentMcpBearerToken,
close,
};
const close = async (): Promise<void> => {
await daemon.close().catch(() => undefined);
// Wait a bit for file handles to release
await new Promise((r) => setTimeout(r, 200));
await rm(paseoHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
await rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
};
return {
config,
daemon,
port,
paseoHome,
staticDir,
agentMcpAuthHeader,
agentMcpBearerToken,
close,
};
} catch (error) {
lastError = error;
await daemon.close().catch(() => undefined);
await rm(paseoHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
await rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
if (!isAddressInUseError(error) || attempt === maxAttempts - 1) {
throw error;
}
}
}
throw lastError ?? new Error("Failed to start test daemon");
}
async function listenOnPort(server: net.Server, port: number): Promise<void> {
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => {
server.off("listening", onListening);
reject(error);
};
const onListening = () => {
server.off("error", onError);
resolve();
};
server.once("error", onError);
server.once("listening", onListening);
try {
server.listen(port);
} catch (error) {
server.off("error", onError);
server.off("listening", onListening);
reject(error instanceof Error ? error : new Error("Failed to listen"));
}
});
}
function isAddressInUseError(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false;
}
const record = error as { code?: string };
return record.code === "EADDRINUSE";
}
@@ -0,0 +1,10 @@
export const AGENT_LIFECYCLE_STATUSES = [
"initializing",
"idle",
"running",
"error",
"closed",
] as const;
export type AgentLifecycleStatus =
(typeof AGENT_LIFECYCLE_STATUSES)[number];
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -284,10 +284,10 @@ export class FileTaskStore implements TaskStore {
candidates = await this.list();
}
// Sort by created date (oldest first) for consistent ordering
// Sort by created date (most recent first) for consistent ordering
return candidates
.filter((t) => t.status === "done")
.sort((a, b) => a.created.localeCompare(b.created));
.sort((a, b) => b.created.localeCompare(a.created));
}
async create(title: string, opts?: CreateTaskOptions): Promise<Task> {
@@ -0,0 +1,5 @@
process.env.GIT_TERMINAL_PROMPT = "0";
process.env.GIT_SSH_COMMAND = "ssh -oBatchMode=yes";
process.env.SSH_ASKPASS = "/usr/bin/false";
process.env.SSH_ASKPASS_REQUIRE = "force";
process.env.DISPLAY = process.env.DISPLAY ?? "1";
+3 -3
View File
@@ -21,7 +21,7 @@ describe("createWorktree", () => {
execSync("git config user.name 'Test'", { cwd: repoDir });
execSync("echo 'hello' > file.txt", { cwd: repoDir });
execSync("git add .", { cwd: repoDir });
execSync("git commit -m 'initial'", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir });
});
afterEach(() => {
@@ -113,7 +113,7 @@ describe("createWorktree", () => {
},
};
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
execSync("git add paseo.json && git commit -m 'add paseo.json'", { cwd: repoDir });
execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add paseo.json'", { cwd: repoDir });
const result = await createWorktree({
branchName: "main",
@@ -138,7 +138,7 @@ describe("createWorktree", () => {
},
};
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
execSync("git add paseo.json && git commit -m 'add paseo.json'", { cwd: repoDir });
execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add paseo.json'", { cwd: repoDir });
const expectedWorktreePath = join(tempDir, "test-repo-fail-test");