chore: checkpoint

This commit is contained in:
Mohamed Boudra
2026-01-21 08:16:11 +07:00
parent 513138ac13
commit cba3ad2721
13 changed files with 1597 additions and 488 deletions

View File

@@ -0,0 +1,172 @@
import { describe, it, expect } from "vitest";
import { WebSocket } from "ws";
import {
generateKeyPair,
exportPublicKey,
importPublicKey,
deriveSharedKey,
encrypt,
decrypt,
} from "./crypto.js";
const RELAY_BASE_URL = "wss://relay.paseo.sh";
async function withRetry<T>(
fn: () => Promise<T>,
options: { retries: number; delayMs: number }
): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt <= options.retries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt < options.retries) {
await new Promise((r) => setTimeout(r, options.delayMs));
}
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError));
}
describe("Live relay (relay.paseo.sh) E2E", () => {
it("bridges encrypted traffic end-to-end", { timeout: 45_000 }, async () => {
await withRetry(
async () => {
const sessionId = `live-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const serverUrl = `${RELAY_BASE_URL}/ws?session=${encodeURIComponent(sessionId)}&role=server`;
const clientUrl = `${RELAY_BASE_URL}/ws?session=${encodeURIComponent(sessionId)}&role=client`;
// === Key setup ===
const daemonKeyPair = await generateKeyPair();
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
const clientKeyPair = await generateKeyPair();
const clientPubKeyB64 = await exportPublicKey(clientKeyPair.publicKey);
const daemonPubKeyOnClient = await importPublicKey(daemonPubKeyB64);
const clientSharedKey = await deriveSharedKey(
clientKeyPair.privateKey,
daemonPubKeyOnClient
);
// === Connect ===
const daemonWs = new WebSocket(serverUrl);
const clientWs = new WebSocket(clientUrl);
const waitOpen = (ws: WebSocket, label: string) =>
new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`Timed out opening ${label} websocket`)),
10_000
);
ws.once("open", () => {
clearTimeout(timeout);
resolve();
});
ws.once("error", (err) => {
clearTimeout(timeout);
reject(err);
});
});
try {
await Promise.all([waitOpen(daemonWs, "server"), waitOpen(clientWs, "client")]);
// === Handshake ===
// Client sends hello with its public key (not encrypted).
clientWs.send(JSON.stringify({ type: "hello", key: clientPubKeyB64 }));
const daemonReceivedHello = await new Promise<string>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Timed out waiting for hello")),
10_000
);
daemonWs.once("message", (data) => {
clearTimeout(timeout);
resolve(data.toString());
});
});
const hello = JSON.parse(daemonReceivedHello) as {
type: string;
key?: string;
};
expect(hello.type).toBe("hello");
expect(typeof hello.key).toBe("string");
const clientPubKeyOnDaemon = await importPublicKey(hello.key!);
const daemonSharedKey = await deriveSharedKey(
daemonKeyPair.privateKey,
clientPubKeyOnDaemon
);
// === Encrypted exchange ===
const plaintextFromClient = "hello-from-client";
const ciphertextFromClient = await encrypt(
clientSharedKey,
plaintextFromClient
);
clientWs.send(Buffer.from(ciphertextFromClient));
const daemonReceivedCiphertext = await new Promise<Buffer>(
(resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Timed out waiting for encrypted message")),
10_000
);
daemonWs.once("message", (data) => {
clearTimeout(timeout);
resolve(data as Buffer);
});
}
);
const decryptedOnDaemon = await decrypt(
daemonSharedKey,
daemonReceivedCiphertext.buffer.slice(
daemonReceivedCiphertext.byteOffset,
daemonReceivedCiphertext.byteOffset +
daemonReceivedCiphertext.byteLength
)
);
expect(decryptedOnDaemon).toBe(plaintextFromClient);
const plaintextFromDaemon = "hello-from-daemon";
const ciphertextFromDaemon = await encrypt(
daemonSharedKey,
plaintextFromDaemon
);
daemonWs.send(Buffer.from(ciphertextFromDaemon));
const clientReceivedCiphertext = await new Promise<Buffer>(
(resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Timed out waiting for encrypted response")),
10_000
);
clientWs.once("message", (data) => {
clearTimeout(timeout);
resolve(data as Buffer);
});
}
);
const decryptedOnClient = await decrypt(
clientSharedKey,
clientReceivedCiphertext.buffer.slice(
clientReceivedCiphertext.byteOffset,
clientReceivedCiphertext.byteOffset +
clientReceivedCiphertext.byteLength
)
);
expect(decryptedOnClient).toBe(plaintextFromDaemon);
} finally {
daemonWs.close();
clientWs.close();
}
},
{ retries: 2, delayMs: 250 }
);
});
});

View File

@@ -0,0 +1,19 @@
name = "paseo-relay"
account_id = "10ed39a1dbf316e30abd0c409bed40d6"
main = "src/cloudflare-adapter.ts"
compatibility_date = "2024-12-01"
routes = [
{ pattern = "relay.paseo.sh", custom_domain = true }
]
[observability]
enabled = true
[[durable_objects.bindings]]
name = "RELAY"
class_name = "RelayDurableObject"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["RelayDurableObject"]

View File

@@ -22,6 +22,7 @@ import type {
GitRepoInfoResponse,
HighlightedDiffResponse,
ListCommandsResponse,
ExecuteCommandResponse,
ListConversationsResponseMessage,
ListProviderModelsResponseMessage,
ListTerminalsResponse,
@@ -171,6 +172,7 @@ type FileExplorerPayload = FileExplorerResponse["payload"];
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
type ListCommandsPayload = ListCommandsResponse["payload"];
type ExecuteCommandPayload = ExecuteCommandResponse["payload"];
type TranscriptionResultPayload = TranscriptionResultMessage["payload"];
type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"];
type ListTerminalsPayload = ListTerminalsResponse["payload"];
@@ -1150,6 +1152,37 @@ export class DaemonClientV2 {
return response;
}
async executeCommand(
agentId: string,
commandName: string,
args?: string,
requestId?: string
): Promise<ExecuteCommandPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "execute_command_request",
agentId,
commandName,
args,
requestId: resolvedRequestId,
});
const response = this.waitFor(
(msg) => {
if (msg.type !== "execute_command_response") {
return null;
}
if (msg.payload.requestId !== resolvedRequestId) {
return null;
}
return msg.payload;
},
30000,
{ skipQueue: true }
);
this.sendSessionMessage(message);
return response;
}
// ============================================================================
// Permissions
// ============================================================================

View File

@@ -0,0 +1,92 @@
import { describe, expect, test } from "vitest";
import { execFileSync, spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
type RunResult = { code: number; stdout: string; stderr: string };
function isCodexAvailable(): boolean {
try {
execFileSync("codex", ["--version"], { stdio: "ignore" });
return true;
} catch {
return false;
}
}
function run(
cmd: string,
args: string[],
opts: { cwd: string; env?: NodeJS.ProcessEnv; timeoutMs: number },
): Promise<RunResult> {
return new Promise((resolve) => {
const child = spawn(cmd, args, {
cwd: opts.cwd,
env: opts.env,
stdio: ["ignore", "pipe", "pipe"],
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout.on("data", (d) => stdoutChunks.push(d));
child.stderr.on("data", (d) => stderrChunks.push(d));
const timeout = setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs);
child.on("close", (code) => {
clearTimeout(timeout);
resolve({
code: code ?? 1,
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
stderr: Buffer.concat(stderrChunks).toString("utf8"),
});
});
});
}
describe("Codex CLI full-access sandbox", () => {
test("can listen on unix socket (no EPERM)", { timeout: 240_000 }, async (ctx) => {
if (process.env.PASEO_CODEX_CLI_E2E !== "1") {
ctx.skip();
}
if (!isCodexAvailable()) {
ctx.skip();
}
const testDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(testDir, "../../../../../..");
const prompt =
"Run exactly this shell command and then stop:\n" +
"bash -lc 'node scripts/repro-ipc-listen.js; echo EXIT_CODE:$?'\n" +
"Reply with only the raw command stdout/stderr (no extra text).";
const result = await run(
"codex",
[
"-a",
"never",
"exec",
"--dangerously-bypass-approvals-and-sandbox",
"--color",
"never",
"-C",
repoRoot,
prompt,
],
{
cwd: repoRoot,
env: process.env,
timeoutMs: 180_000,
},
);
const output = `${result.stdout}\n${result.stderr}`;
if (result.code !== 0) {
throw new Error(`codex exited ${result.code}\n--- output ---\n${output}`);
}
expect(output).toMatch(/\bsandbox:\s*danger-full-access\b/);
expect(output).toMatch(/\bLISTENING\b/);
expect(output).toMatch(/\bEXIT_CODE:0\b/);
expect(output).not.toMatch(/\bEPERM\b/);
});
});

View File

@@ -0,0 +1,121 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../../test-utils/index.js";
import { getFullAccessConfig } from "../../daemon-e2e/agent-configs.js";
function tmpDir(prefix: string): string {
return mkdtempSync(path.join(tmpdir(), prefix));
}
describe("codex agent commands E2E", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
test("lists available slash commands for a codex agent", async () => {
const prevCodexHome = process.env.CODEX_HOME;
const codexHome = tmpDir("codex-home-");
const promptsDir = path.join(codexHome, "prompts");
const skillsDir = path.join(codexHome, "skills");
mkdirSync(promptsDir, { recursive: true });
mkdirSync(skillsDir, { recursive: true });
writeFileSync(
path.join(promptsDir, "hello.md"),
["---", "description: Test prompt", "argument-hint: NAME=<name>", "---", "", "Say hello to $NAME and then output exactly PASEO_OK.", ""].join("\n"),
"utf8"
);
mkdirSync(path.join(skillsDir, "my-skill"), { recursive: true });
writeFileSync(
path.join(skillsDir, "my-skill", "SKILL.md"),
["---", "name: my-skill", "description: Test skill", "user-invocable: true", "---", "", "When invoked, respond with exactly PASEO_SKILL_OK.", ""].join("\n"),
"utf8"
);
process.env.CODEX_HOME = codexHome;
const agent = await ctx.client.createAgent({
...getFullAccessConfig("codex"),
cwd: "/tmp",
title: "Codex Commands Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.provider).toBe("codex");
expect(agent.status).toBe("idle");
const result = await ctx.client.listCommands(agent.id);
expect(result.error).toBeNull();
expect(result.commands.length).toBeGreaterThan(0);
for (const cmd of result.commands) {
expect(cmd.name).toBeTruthy();
expect(typeof cmd.description).toBe("string");
expect(typeof cmd.argumentHint).toBe("string");
expect(cmd.name.startsWith("/")).toBe(false);
}
const names = result.commands.map((c) => c.name);
expect(names).toContain("my-skill");
expect(names).toContain("prompts:hello");
if (prevCodexHome === undefined) {
delete process.env.CODEX_HOME;
} else {
process.env.CODEX_HOME = prevCodexHome;
}
rmSync(codexHome, { recursive: true, force: true });
}, 120000);
test("executes a custom prompt command (prompts:*)", async () => {
const codexHome = process.env.CODEX_HOME ?? path.join(process.env.HOME ?? "/tmp", ".codex");
const authPath = path.join(codexHome, "auth.json");
if (!existsSync(authPath) && !process.env.OPENAI_API_KEY) {
// Skip when Codex isn't authenticated in this environment.
return;
}
const promptsDir = path.join(codexHome, "prompts");
mkdirSync(promptsDir, { recursive: true });
const promptPath = path.join(promptsDir, "paseo-test-sayok.md");
writeFileSync(
promptPath,
["---", "description: Say OK", "argument-hint: NAME=<name>", "---", "", "Output exactly: PASEO_OK $NAME", ""].join("\n"),
"utf8"
);
const agent = await ctx.client.createAgent({
...getFullAccessConfig("codex"),
cwd: "/tmp",
title: "Codex Prompt Execute Test Agent",
});
const result = await ctx.client.executeCommand(
agent.id,
"prompts:paseo-test-sayok",
"NAME=world"
);
expect(result.error).toBeNull();
expect(result.result?.text).toContain("PASEO_OK");
rmSync(promptPath, { force: true });
}, 180000);
test("returns error for non-existent agent", async () => {
const result = await ctx.client.listCommands("non-existent-agent-id");
expect(result.error).toBeTruthy();
expect(result.error).toContain("Agent not found");
expect(result.commands).toEqual([]);
}, 30000);
});

View File

@@ -0,0 +1,38 @@
import { describe, test, expect } from "vitest";
import { __test__ } from "./codex-mcp-agent.js";
describe("codex custom command plumbing", () => {
test("parseFrontMatter extracts metadata and body", () => {
const input = [
"---",
"description: Hello",
"argument-hint: NAME=<name>",
"---",
"",
"Say hi to $NAME",
"",
].join("\n");
const parsed = __test__.parseFrontMatter(input);
expect(parsed.frontMatter["description"]).toBe("Hello");
expect(parsed.frontMatter["argument-hint"]).toBe("NAME=<name>");
expect(parsed.body).toContain("Say hi to $NAME");
});
test("expandCodexCustomPrompt supports named + positional placeholders", () => {
const template = "A=$A B=$B 1=$1 2=$2 ARGS=$ARGUMENTS $$=$$";
const out = __test__.expandCodexCustomPrompt(template, "A=hello B=world one two");
expect(out).toContain("A=hello");
expect(out).toContain("B=world");
expect(out).toContain("1=one");
expect(out).toContain("2=two");
expect(out).toContain("ARGS=A=hello B=world one two");
expect(out).toContain("$=$");
});
test("tokenizeCommandArgs respects quotes", () => {
const tokens = __test__.tokenizeCommandArgs('A=\"hello world\" B=two three');
expect(tokens).toEqual(["A=hello world", "B=two", "three"]);
});
});

View File

@@ -0,0 +1,19 @@
import { describe, expect, test } from "vitest";
import { __test__ } from "./codex-mcp-agent.js";
describe("codex resume error detection", () => {
test("detects missing session errors for thread_id", () => {
const error = new Error(
"Session not found for thread_id: 019bda3e-ffe4-7bc1-ae0e-9b992d7c9360"
);
expect(__test__.isMissingConversationIdError(error)).toBe(true);
});
test("detects missing session responses for thread_id", () => {
const response = {
content: [{ type: "text", text: "Session not found for thread_id: abc" }],
};
expect(__test__.isMissingConversationIdResponse(response)).toBe(true);
});
});

View File

@@ -19,6 +19,7 @@ import type { Logger } from "pino";
import type {
AgentCapabilityFlags,
AgentClient,
AgentCommandResult,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
@@ -27,6 +28,7 @@ import type {
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentSlashCommand,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
@@ -1932,6 +1934,249 @@ function normalizeCommand(command: Command): string {
return typeof command === "string" ? command : command.join(" ");
}
function resolveCodexHomeDir(): string {
return process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
}
function tokenizeCommandArgs(args: string): string[] {
const tokens: string[] = [];
let current = "";
let quote: "'" | "\"" | null = null;
for (let i = 0; i < args.length; i += 1) {
const ch = args[i]!;
if (quote) {
if (ch === quote) {
quote = null;
continue;
}
if (ch === "\\" && i + 1 < args.length) {
const next = args[i + 1]!;
if (next === quote || next === "\\" || next === "n" || next === "t") {
i += 1;
current += next === "n" ? "\n" : next === "t" ? "\t" : next;
continue;
}
}
current += ch;
continue;
}
if (ch === "'" || ch === "\"") {
quote = ch;
continue;
}
if (/\s/.test(ch)) {
if (current) {
tokens.push(current);
current = "";
}
continue;
}
current += ch;
}
if (current) {
tokens.push(current);
}
return tokens;
}
function parseFrontMatter(markdown: string): {
frontMatter: Record<string, string>;
body: string;
} {
const lines = markdown.split("\n");
if (lines[0]?.trim() !== "---") {
return { frontMatter: {}, body: markdown };
}
let end = -1;
for (let i = 1; i < lines.length; i += 1) {
if (lines[i]?.trim() === "---") {
end = i;
break;
}
}
if (end === -1) {
return { frontMatter: {}, body: markdown };
}
const metaLines = lines.slice(1, end);
const body = lines.slice(end + 1).join("\n");
const frontMatter: Record<string, string> = {};
for (const line of metaLines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const idx = trimmed.indexOf(":");
if (idx <= 0) {
continue;
}
const key = trimmed.slice(0, idx).trim();
let value = trimmed.slice(idx + 1).trim();
value = value.replace(/^['"]/, "").replace(/['"]$/, "");
if (key && value) {
frontMatter[key] = value;
}
}
return { frontMatter, body };
}
async function listCodexCustomPrompts(): Promise<AgentSlashCommand[]> {
const codexHome = resolveCodexHomeDir();
const promptsDir = path.join(codexHome, "prompts");
let entries: Dirent[];
try {
entries = await fs.readdir(promptsDir, { withFileTypes: true });
} catch {
return [];
}
const commands: AgentSlashCommand[] = [];
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
if (!entry.name.endsWith(".md")) {
continue;
}
const name = entry.name.slice(0, -".md".length);
if (!name) {
continue;
}
const fullPath = path.join(promptsDir, entry.name);
let content: string;
try {
content = await fs.readFile(fullPath, "utf8");
} catch {
continue;
}
const parsed = parseFrontMatter(content);
const description =
parsed.frontMatter["description"] ?? "Custom prompt";
const argumentHint =
parsed.frontMatter["argument-hint"] ??
parsed.frontMatter["argument_hint"] ??
"";
commands.push({
name: `prompts:${name}`,
description,
argumentHint,
});
}
return commands.sort((a, b) => a.name.localeCompare(b.name));
}
async function listCodexSkills(cwd: string): Promise<AgentSlashCommand[]> {
const candidates: string[] = [];
candidates.push(path.join(cwd, ".codex", "skills"));
const repoRoot = (() => {
try {
const output = execSync("git rev-parse --show-toplevel", {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
const trimmed = output.trim();
return trimmed ? trimmed : null;
} catch {
return null;
}
})();
if (repoRoot) {
candidates.push(path.join(path.dirname(cwd), ".codex", "skills"));
candidates.push(path.join(repoRoot, ".codex", "skills"));
}
candidates.push(path.join(resolveCodexHomeDir(), "skills"));
const commandsByName = new Map<string, AgentSlashCommand>();
for (const dir of candidates) {
let entries: Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
continue;
}
const skillDir = path.join(dir, entry.name);
const skillPath = path.join(skillDir, "SKILL.md");
let content: string;
try {
content = await fs.readFile(skillPath, "utf8");
} catch {
continue;
}
const { frontMatter } = parseFrontMatter(content);
const name = frontMatter["name"];
const description = frontMatter["description"];
if (!name || !description) {
continue;
}
if (!commandsByName.has(name)) {
commandsByName.set(name, {
name,
description,
argumentHint: "",
});
}
}
}
return Array.from(commandsByName.values()).sort((a, b) =>
a.name.localeCompare(b.name)
);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function expandCodexCustomPrompt(template: string, args: string | undefined): string {
const trimmedArgs = args ? args.trim() : "";
const tokens = trimmedArgs ? tokenizeCommandArgs(trimmedArgs) : [];
const named: Record<string, string> = {};
const positional: string[] = [];
for (const token of tokens) {
const idx = token.indexOf("=");
if (idx > 0) {
const key = token.slice(0, idx);
const value = token.slice(idx + 1);
if (key) {
named[key] = value;
continue;
}
}
positional.push(token);
}
const dollarPlaceholder = "__CODEX_DOLLAR_PLACEHOLDER__";
let out = template.split("$$").join(dollarPlaceholder);
out = out.split("$ARGUMENTS").join(trimmedArgs);
for (let i = 1; i <= 9; i += 1) {
const value = positional[i - 1] ?? "";
out = out.split(`$${i}`).join(value);
}
const namedKeys = Object.keys(named).sort((a, b) => b.length - a.length);
for (const key of namedKeys) {
const value = named[key] ?? "";
const re = new RegExp(`\\$${escapeRegExp(key)}\\b`, "g");
out = out.replace(re, value);
}
out = out.split(dollarPlaceholder).join("$");
return out;
}
function extractFileReadFromParsedCmd(parsedCmd: ParsedCmdItem[] | undefined): {
path: string;
name: string;
@@ -2598,14 +2843,21 @@ function isUnsupportedChatGptModelError(error: unknown): boolean {
return message.includes("model is not supported when using Codex with a ChatGPT account");
}
function isMissingSessionForConversationOrThread(message: string): boolean {
return (
message.includes("Session not found for conversation_id") ||
message.includes("Session not found for thread_id")
);
}
function isMissingConversationIdError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes("Session not found for conversation_id");
return isMissingSessionForConversationOrThread(message);
}
function isMissingConversationIdResponse(response: unknown): boolean {
const text = extractTextContent(response);
return !!text && text.includes("Session not found for conversation_id");
return !!text && isMissingSessionForConversationOrThread(text);
}
/**
@@ -2775,6 +3027,7 @@ class CodexMcpAgentSession implements AgentSession {
private lockConversationId = false;
private historyPending = false;
private persistedHistory: AgentTimelineItem[] = [];
private resumeContextHistory: AgentTimelineItem[] = [];
private pendingHistory: AgentTimelineItem[] = [];
private turnState: TurnState | null = null;
private pendingPatchChanges = new Map<string, PatchFileChange[]>();
@@ -2898,6 +3151,7 @@ class CodexMcpAgentSession implements AgentSession {
}, this.logger);
if (timeline.length > 0) {
this.persistedHistory = timeline;
this.resumeContextHistory = [...timeline];
this.historyPending = true;
}
}
@@ -3268,6 +3522,31 @@ class CodexMcpAgentSession implements AgentSession {
this.managedAgentId = agentId;
}
async listCommands(): Promise<AgentSlashCommand[]> {
const [skills, prompts] = await Promise.all([
listCodexSkills(this.config.cwd),
listCodexCustomPrompts(),
]);
return [...skills, ...prompts];
}
async executeCommand(commandName: string, args?: string): Promise<AgentCommandResult> {
if (commandName.startsWith("prompts:")) {
const promptName = commandName.slice("prompts:".length);
const codexHome = resolveCodexHomeDir();
const promptPath = path.join(codexHome, "prompts", `${promptName}.md`);
const raw = await fs.readFile(promptPath, "utf8");
const parsed = parseFrontMatter(raw);
const expanded = expandCodexCustomPrompt(parsed.body, args);
const result = await this.run(expanded);
return { text: result.finalText, timeline: result.timeline, usage: result.usage };
}
const skillPrompt = args ? `$${commandName} ${args}` : `$${commandName}`;
const result = await this.run(skillPrompt);
return { text: result.finalText, timeline: result.timeline, usage: result.usage };
}
private async forwardPrompt(
prompt: string,
_options: AgentRunOptions | undefined,
@@ -4145,7 +4424,9 @@ class CodexMcpAgentSession implements AgentSession {
private buildResumePrompt(prompt: string): string {
const historyLines: string[] = [];
for (const item of this.persistedHistory) {
// Use resumeContextHistory instead of persistedHistory because
// persistedHistory gets cleared by streamHistory() before the first message is sent
for (const item of this.resumeContextHistory) {
if (item.type === "user_message") {
historyLines.push(`User: ${item.text}`);
}
@@ -4329,6 +4610,14 @@ export class CodexMcpAgentClient implements AgentClient {
}
}
export const __test__ = {
tokenizeCommandArgs,
parseFrontMatter,
expandCodexCustomPrompt,
isMissingConversationIdError,
isMissingConversationIdResponse,
};
// ============================================================================
// Codex model listing helpers
// ============================================================================

View File

@@ -0,0 +1,244 @@
import { describe, test, expect, afterEach } from "vitest";
import { mkdtempSync, rmSync, existsSync } from "fs";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import path from "path";
import pino from "pino";
import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import type { PersistenceHandle } from "../../shared/messages.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
async function getAvailablePort(): Promise<number> {
const { createServer } = await import("net");
return new Promise((resolve, reject) => {
const server = createServer();
server.once("error", reject);
server.listen(0, () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire port")));
return;
}
server.close(() => resolve(address.port));
});
});
}
interface DaemonInstance {
daemon: Awaited<ReturnType<typeof createPaseoDaemon>>;
client: DaemonClient;
port: number;
paseoHome: string;
staticDir: string;
}
async function startDaemon(options: {
paseoHome: string;
staticDir?: string;
}): Promise<DaemonInstance> {
const port = await getAvailablePort();
const staticDir = options.staticDir ?? await mkdtemp(path.join(tmpdir(), "paseo-static-"));
const config: PaseoDaemonConfig = {
listen: `127.0.0.1:${port}`,
paseoHome: options.paseoHome,
corsAllowedOrigins: [],
agentMcpRoute: "/mcp/agents",
agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`],
staticDir,
mcpDebug: false,
agentClients: {},
agentRegistryPath: path.join(options.paseoHome, "agents.json"),
agentControlMcp: {
url: `http://127.0.0.1:${port}/mcp/agents`,
},
openai: process.env.OPENAI_API_KEY ? { apiKey: process.env.OPENAI_API_KEY } : undefined,
};
const logger = pino({ level: "silent" });
const daemon = await createPaseoDaemon(config, logger);
await daemon.start();
const client = new DaemonClient({
url: `ws://127.0.0.1:${port}/ws`,
});
await client.connect();
return {
daemon,
client,
port,
paseoHome: options.paseoHome,
staticDir,
};
}
async function stopDaemon(instance: DaemonInstance): Promise<void> {
await instance.client.close();
await instance.daemon.stop();
await new Promise((r) => setTimeout(r, 200));
}
describe("daemon restart and agent resume", () => {
let paseoHome: string | null = null;
let staticDir: string | null = null;
let cwd: string | null = null;
let currentDaemon: DaemonInstance | null = null;
afterEach(async () => {
if (currentDaemon) {
await stopDaemon(currentDaemon).catch(() => undefined);
currentDaemon = null;
}
if (paseoHome) {
await rm(paseoHome, { recursive: true, force: true }).catch(() => undefined);
paseoHome = null;
}
if (staticDir) {
await rm(staticDir, { recursive: true, force: true }).catch(() => undefined);
staticDir = null;
}
if (cwd) {
rmSync(cwd, { recursive: true, force: true });
cwd = null;
}
}, 60000);
test(
"Codex agent survives daemon kill and restart, preserving conversation context",
async () => {
// Create isolated directories that persist across daemon restarts
// NOTE: We use the default CODEX_HOME (~/.codex) for sessions because
// Codex CLI needs its config for API authentication
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-home-restart-"));
staticDir = await mkdtemp(path.join(tmpdir(), "paseo-static-restart-"));
cwd = tmpCwd();
// Use a unique secret that we'll verify after restart
const secretPhrase = `DAEMON_RESTART_SECRET_${Date.now()}`;
// === PHASE 1: Start daemon and create Codex agent with secret ===
currentDaemon = await startDaemon({ paseoHome, staticDir });
const agent = await currentDaemon.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Daemon Restart Test Agent",
modeId: "full-access",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Ask the agent to remember the secret
await currentDaemon.client.sendMessage(
agent.id,
`Remember this secret phrase: "${secretPhrase}". Just confirm you've remembered it with a short reply.`
);
const afterRemember = await currentDaemon.client.waitForAgentIdle(agent.id, 120000);
expect(afterRemember.status).toBe("idle");
expect(afterRemember.lastError).toBeUndefined();
// Verify we got a confirmation and capture persistence handle
const queue = currentDaemon.client.getMessageQueue();
const confirmationMessages: string[] = [];
for (const m of queue) {
if (
m.type === "agent_stream" &&
m.payload.agentId === agent.id &&
m.payload.event.type === "timeline"
) {
const item = m.payload.event.item;
if (item.type === "assistant_message" && item.text) {
confirmationMessages.push(item.text);
}
}
}
expect(confirmationMessages.join("").length).toBeGreaterThan(0);
// Get persistence handle for resuming after restart
expect(afterRemember.persistence).toBeTruthy();
const persistence = afterRemember.persistence as PersistenceHandle;
expect(persistence.provider).toBe("codex");
expect(persistence.sessionId).toBeTruthy();
// Verify persistence metadata has conversationId
const metadata = persistence.metadata as Record<string, unknown>;
expect(metadata.conversationId).toBeTruthy();
// Wait briefly to ensure Codex has flushed session files to disk
await new Promise((r) => setTimeout(r, 500));
// === PHASE 2: Kill the daemon (simulating crash/restart) ===
await stopDaemon(currentDaemon);
currentDaemon = null;
// Verify agents.json was persisted
const agentsJsonPath = path.join(paseoHome, "agents.json");
expect(existsSync(agentsJsonPath)).toBe(true);
// === PHASE 3: Start a NEW daemon with the SAME paseoHome ===
currentDaemon = await startDaemon({ paseoHome, staticDir });
// === PHASE 4: Resume the agent using the persistence handle ===
const resumedAgent = await currentDaemon.client.resumeAgent(persistence);
expect(resumedAgent.id).toBeTruthy();
expect(resumedAgent.status).toBe("idle");
expect(resumedAgent.provider).toBe("codex");
expect(resumedAgent.cwd).toBe(cwd);
// Wait a moment for history to load
await new Promise((r) => setTimeout(r, 1000));
// === PHASE 5: Ask about the secret to verify conversation context is preserved ===
// This is the CRITICAL test: after daemon restart, the model should remember
// the secret from the previous conversation. If it doesn't, the resume is broken.
currentDaemon.client.clearMessageQueue();
await currentDaemon.client.sendMessage(
resumedAgent.id,
"What was the secret phrase I asked you to remember earlier? Just reply with the exact phrase."
);
const afterMessage = await currentDaemon.client.waitForAgentIdle(resumedAgent.id, 120000);
expect(afterMessage.status).toBe("idle");
expect(afterMessage.lastError).toBeUndefined();
// === PHASE 6: Verify the agent remembers the secret (proves context is preserved) ===
const responseQueue = currentDaemon.client.getMessageQueue();
const responseMessages: string[] = [];
for (const m of responseQueue) {
if (
m.type === "agent_stream" &&
m.payload.agentId === resumedAgent.id &&
m.payload.event.type === "timeline"
) {
const item = m.payload.event.item;
if (item.type === "assistant_message" && item.text) {
responseMessages.push(item.text);
}
}
}
const fullResponse = responseMessages.join("");
// CRITICAL ASSERTION: The agent should remember the secret phrase from before daemon restart
// This proves conversation context was properly restored via buildResumePrompt
expect(fullResponse).toContain(secretPhrase);
// Cleanup
await currentDaemon.client.deleteAgent(resumedAgent.id);
},
300000 // 5 minute timeout
);
});

View File

@@ -864,6 +864,15 @@ export class Session {
await this.handleListCommandsRequest(msg.agentId, msg.requestId);
break;
case "execute_command_request":
await this.handleExecuteCommandRequest(
msg.agentId,
msg.commandName,
msg.args,
msg.requestId
);
break;
case "register_push_token":
this.handleRegisterPushToken(msg.token);
break;
@@ -1963,6 +1972,79 @@ export class Session {
}
}
/**
* Handle execute command request for an agent
*/
private async handleExecuteCommandRequest(
agentId: string,
commandName: string,
args: string | undefined,
requestId: string
): Promise<void> {
this.sessionLogger.debug(
{ agentId, commandName },
`Handling execute command request for agent ${agentId}`
);
try {
const agents = this.agentManager.listAgents();
const agent = agents.find((a) => a.id === agentId);
if (!agent) {
this.emit({
type: "execute_command_response",
payload: {
agentId,
result: null,
error: `Agent not found: ${agentId}`,
requestId,
},
});
return;
}
const session = agent.session;
if (!session || !session.executeCommand) {
this.emit({
type: "execute_command_response",
payload: {
agentId,
result: null,
error: `Agent does not support executing commands`,
requestId,
},
});
return;
}
const result = await session.executeCommand(commandName, args);
this.emit({
type: "execute_command_response",
payload: {
agentId,
result,
error: null,
requestId,
},
});
} catch (error: any) {
this.sessionLogger.error(
{ err: error, agentId, commandName },
"Failed to execute command"
);
this.emit({
type: "execute_command_response",
payload: {
agentId,
result: null,
error: error.message,
requestId,
},
});
}
}
/**
* Handle agent permission response from user
*/

View File

@@ -1,25 +1,14 @@
import { WebSocketServer, WebSocket } from "ws";
import { Server as HTTPServer } from "http";
import type { IncomingMessage } from "http";
import { parse as parseUrl } from "url";
import {
WSInboundMessageSchema,
type WSOutboundMessage,
extractSessionMessage,
wrapSessionMessage,
} from "./messages.js";
import { Session } from "./session.js";
import { loadConversation } from "./persistence.js";
import { AgentManager } from "./agent/agent-manager.js";
import { AgentRegistry } from "./agent/agent-registry.js";
import type { AgentProvider } from "./agent/agent-sdk-types.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import { PushTokenStore } from "./push/token-store.js";
import { PushService } from "./push/push-service.js";
import { WebSocketServer } from "ws";
import type { Server as HTTPServer } from "http";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentRegistry } from "./agent/agent-registry.js";
import type { DownloadTokenStore } from "./file-download/token-store.js";
import type { OpenAISTT } from "./agent/stt-openai.js";
import type { OpenAITTS } from "./agent/tts-openai.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type pino from "pino";
import type { WSOutboundMessage } from "./messages.js";
import { WebSocketSessionBridge } from "./websocket-session-bridge.js";
type AgentMcpClientConfig = {
agentMcpUrl: string;
@@ -31,24 +20,12 @@ type WebSocketServerConfig = {
};
/**
* WebSocket server that routes messages between clients and their sessions.
* This is a thin transport layer with no business logic.
* WebSocket server that only accepts sockets + parses/forwards messages to the session layer.
*/
export class VoiceAssistantWebSocketServer {
private readonly logger: pino.Logger;
private wss: WebSocketServer;
private sessions: Map<WebSocket, Session> = new Map();
private conversationIdToWs: Map<string, WebSocket> = new Map();
private clientIdCounter: number = 0;
private agentManager: AgentManager;
private agentRegistry: AgentRegistry;
private downloadTokenStore: DownloadTokenStore;
private pushTokenStore: PushTokenStore;
private pushService: PushService;
private readonly agentMcpConfig: AgentMcpClientConfig;
private readonly stt: OpenAISTT | null;
private readonly tts: OpenAITTS | null;
private readonly terminalManager: TerminalManager | null;
private readonly wss: WebSocketServer;
private readonly bridge: WebSocketSessionBridge;
constructor(
server: HTTPServer,
@@ -62,17 +39,15 @@ export class VoiceAssistantWebSocketServer {
terminalManager?: TerminalManager | null
) {
this.logger = logger.child({ module: "websocket-server" });
this.agentManager = agentManager;
this.agentRegistry = agentRegistry;
this.downloadTokenStore = downloadTokenStore;
this.stt = speech?.stt ?? null;
this.tts = speech?.tts ?? null;
this.terminalManager = terminalManager ?? null;
const pushLogger = this.logger.child({ module: "push" });
this.pushTokenStore = new PushTokenStore(pushLogger);
this.pushService = new PushService(pushLogger, this.pushTokenStore);
this.agentMcpConfig = agentMcpConfig;
this.bridge = new WebSocketSessionBridge(
this.logger,
agentManager,
agentRegistry,
downloadTokenStore,
agentMcpConfig,
speech,
terminalManager
);
const { allowedOrigins } = wsConfig;
this.wss = new WebSocketServer({
@@ -80,8 +55,6 @@ export class VoiceAssistantWebSocketServer {
path: "/ws",
verifyClient: ({ req }, callback) => {
const origin = req.headers.origin;
// Allow connections with no origin (native apps, curl, etc.)
// or from allowed origins (browsers)
if (!origin || allowedOrigins.has(origin)) {
callback(true);
} else {
@@ -92,454 +65,19 @@ export class VoiceAssistantWebSocketServer {
});
this.wss.on("connection", (ws, request) => {
this.handleConnection(ws, request);
});
this.agentManager.setAgentAttentionCallback((params) => {
this.broadcastAgentAttention(params);
void this.bridge.attach(ws, request);
});
this.logger.info("WebSocket server initialized on /ws");
}
/**
* Handle new WebSocket connection
*/
private async handleConnection(ws: WebSocket, request: IncomingMessage): Promise<void> {
// Generate unique client ID
const clientId = `client-${++this.clientIdCounter}`;
const connectionLogger = this.logger.child({ clientId });
// Extract conversation ID from URL query parameter if present
const url = parseUrl(request.url || "", true);
const conversationId = url.query.conversationId as string | undefined;
// Load conversation if ID provided
let initialMessages = null;
if (conversationId) {
connectionLogger.debug({ conversationId }, "Client requesting conversation");
initialMessages = await loadConversation(connectionLogger, conversationId);
if (initialMessages) {
connectionLogger.debug(
{ conversationId, messageCount: initialMessages.length },
"Loaded conversation"
);
} else {
connectionLogger.debug({ conversationId }, "Conversation not found, starting fresh");
}
}
// Create session with message emission callback
const session = new Session(
clientId,
(msg) => {
this.sendToClient(ws, wrapSessionMessage(msg));
},
connectionLogger.child({ module: "session" }),
this.downloadTokenStore,
this.pushTokenStore,
this.agentManager,
this.agentRegistry,
this.agentMcpConfig,
this.stt,
this.tts,
this.terminalManager,
{
conversationId,
initialMessages: initialMessages || undefined,
}
);
// Store session and reverse mapping
this.sessions.set(ws, session);
this.conversationIdToWs.set(session.getConversationId(), ws);
connectionLogger.info(
{ clientId, conversationId: session.getConversationId(), totalSessions: this.sessions.size },
"Client connected"
);
// Don't send initial state here - client will request it via load_conversation_request
// This avoids race condition where message arrives before client sets up listeners
// Set up message handler
ws.on("message", (data) => {
this.handleMessage(ws, data);
});
// Set up close handler
ws.on("close", async () => {
const session = this.sessions.get(ws);
if (!session) return;
connectionLogger.info(
{ clientId, totalSessions: this.sessions.size - 1 },
"Client disconnected"
);
// Clean up session
await session.cleanup();
// Remove from maps
this.sessions.delete(ws);
this.conversationIdToWs.delete(session.getConversationId());
connectionLogger.debug({ conversationId: session.getConversationId() }, "Conversation deleted");
});
// Set up error handler
ws.on("error", async (error) => {
const err = error instanceof Error ? error : new Error(String(error));
connectionLogger.error({ err }, "Client error");
const session = this.sessions.get(ws);
if (!session) return;
// Clean up session
await session.cleanup();
// Remove from maps
this.sessions.delete(ws);
this.conversationIdToWs.delete(session.getConversationId());
});
}
/**
* Handle incoming WebSocket message
*/
private async handleMessage(
ws: WebSocket,
data: Buffer | ArrayBuffer | Buffer[]
): Promise<void> {
try {
// Parse message
const parsed = JSON.parse(data.toString());
// Validate with Zod
const message = WSInboundMessageSchema.parse(parsed);
// Safe logging that handles large objects without crashing
const messageSummary = {
type: message.type,
...(message.type === "session" && message.message ? {
sessionMessageType: message.message.type,
} : {}),
};
this.logger.debug(messageSummary, "Received message");
// Handle WebSocket-level messages
switch (message.type) {
case "ping":
this.sendToClient(ws, { type: "pong" });
return;
case "recording_state":
this.logger.debug({ isRecording: message.isRecording }, "Recording state");
return;
case "session":
// Extract and forward session message
const sessionMessage = extractSessionMessage(message);
if (sessionMessage) {
// Debug: Log create_agent_request details
if (sessionMessage.type === "create_agent_request") {
this.logger.debug({
cwd: sessionMessage.config.cwd,
initialMode: sessionMessage.config.modeId,
worktreeName: sessionMessage.worktreeName,
requestId: sessionMessage.requestId,
}, "create_agent_request details");
}
const session = this.sessions.get(ws);
if (session) {
await session.handleMessage(sessionMessage);
} else {
this.logger.error("No session found for client");
}
}
return;
}
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
let rawPayload: string | null = null;
let parsedPayload: unknown = null;
try {
const buffer = Array.isArray(data)
? Buffer.concat(
data.map((item) =>
Buffer.isBuffer(item)
? item
: Buffer.from(item as ArrayBuffer)
)
)
: Buffer.isBuffer(data)
? data
: Buffer.from(data as ArrayBuffer);
rawPayload = buffer.toString();
parsedPayload = JSON.parse(rawPayload);
} catch (payloadError) {
rawPayload = rawPayload ?? "<unreadable>";
parsedPayload = parsedPayload ?? rawPayload;
const payloadErr = payloadError instanceof Error ? payloadError : new Error(String(payloadError));
this.logger.error({ err: payloadErr }, "Failed to decode raw payload");
}
const trimmedRawPayload =
typeof rawPayload === "string" && rawPayload.length > 2000
? `${rawPayload.slice(0, 2000)}... (truncated)`
: rawPayload;
this.logger.error({
err,
rawPayload: trimmedRawPayload,
parsedPayload,
}, "Failed to parse/handle message");
// Send error to client
this.sendToClient(
ws,
wrapSessionMessage({
type: "status",
payload: {
status: "error",
message: `Invalid message: ${err.message}`,
},
})
);
}
}
/**
* Send message to specific client
*/
private sendToClient(ws: WebSocket, message: WSOutboundMessage): void {
if (ws.readyState === 1) {
// WebSocket.OPEN = 1
ws.send(JSON.stringify(message));
}
}
/**
* Broadcast message to all connected clients
*/
public broadcast(message: WSOutboundMessage): void {
const payload = JSON.stringify(message);
this.sessions.forEach((_session, client) => {
if (client.readyState === 1) {
// WebSocket.OPEN = 1
client.send(payload);
}
});
this.bridge.broadcast(message);
}
/**
* Close the WebSocket server
*/
public async close(): Promise<void> {
const cleanupPromises: Promise<void>[] = [];
this.sessions.forEach((session, ws) => {
cleanupPromises.push(session.cleanup());
// Wait for WebSocket to actually close before resolving
cleanupPromises.push(
new Promise<void>((resolve) => {
if (ws.readyState === WebSocket.CLOSED) {
resolve();
return;
}
ws.once("close", () => resolve());
ws.close();
})
);
});
await Promise.all(cleanupPromises);
await this.bridge.closeAll();
this.wss.close();
}
private readonly ACTIVITY_THRESHOLD_MS = 120_000; // 2 minutes
/**
* Get client activity state with computed staleness
*/
private getClientActivityState(session: Session): {
deviceType: "web" | "mobile" | null;
focusedAgentId: string | null;
isStale: boolean;
appVisible: boolean;
} {
const activity = session.getClientActivity();
if (!activity) {
this.logger.debug("getClientActivityState: no activity for session");
return { deviceType: null, focusedAgentId: null, isStale: true, appVisible: false };
}
const now = Date.now();
const ageMs = now - activity.lastActivityAt.getTime();
const isStale = ageMs >= this.ACTIVITY_THRESHOLD_MS;
this.logger.debug({
deviceType: activity.deviceType,
focusedAgentId: activity.focusedAgentId,
lastActivityAt: activity.lastActivityAt.toISOString(),
ageMs,
isStale,
appVisible: activity.appVisible,
}, "getClientActivityState");
return {
deviceType: activity.deviceType,
focusedAgentId: activity.focusedAgentId,
isStale,
appVisible: activity.appVisible,
};
}
/**
* Compute shouldNotify for a specific client given all clients' states
*
* UX Rules:
* 1. If ANY client is actively watching the agent (focused + visible + not stale) → no notifications
* 2. If THIS client is not stale and focused elsewhere → notify THIS client (user is at computer)
* 3. If THIS client is stale → only notify if mobile, or if no mobile available
* 4. Don't notify non-stale clients that aren't focused on anything (just switched tabs)
*/
private computeShouldNotifyForClient(
clientState: {
deviceType: "web" | "mobile" | null;
focusedAgentId: string | null;
isStale: boolean;
appVisible: boolean;
},
allClientStates: Array<{
deviceType: "web" | "mobile" | null;
focusedAgentId: string | null;
isStale: boolean;
appVisible: boolean;
}>,
agentId: string
): boolean {
// Rule 1: If any client is actively watching the agent, no one needs notification
const isAnyoneActiveOnAgent = allClientStates.some(
(state) =>
state.focusedAgentId === agentId &&
state.appVisible &&
!state.isStale
);
if (isAnyoneActiveOnAgent) {
return false;
}
// No heartbeat (legacy client or just connected) → notify
if (clientState.deviceType === null) {
return true;
}
// Rule 2: If THIS client is not stale and actively looking at a different agent → notify them
if (!clientState.isStale && clientState.appVisible && clientState.focusedAgentId !== null) {
return true;
}
// Rule 3: If THIS client is not stale but just switched tabs (not focused on anything) → no notification
// User is present at the computer, they'll come back
if (!clientState.isStale) {
return false;
}
// Rule 4: THIS client is stale - check if another client will handle it
const hasActiveWebClient = allClientStates.some(
(state) => state.deviceType === "web" && !state.isStale
);
if (clientState.deviceType === "mobile") {
// Mobile only notifies if web is also stale (user truly away)
// If web is active, they'll see it there
return !hasActiveWebClient;
}
if (clientState.deviceType === "web") {
// Stale web: notify only if no other client can handle it
// Other client = mobile or unknown (no heartbeat)
const hasOtherClient = allClientStates.some(
(state) => state !== clientState && (state.deviceType === "mobile" || state.deviceType === null)
);
return !hasOtherClient;
}
// Fallback: notify
return true;
}
/**
* Broadcast an attention_required event to all clients with per-client shouldNotify
*/
private broadcastAgentAttention(params: {
agentId: string;
provider: AgentProvider;
reason: "finished" | "error" | "permission";
}): void {
// Collect all client states first
const clientEntries: Array<{
ws: WebSocket;
state: {
deviceType: "web" | "mobile" | null;
focusedAgentId: string | null;
isStale: boolean;
appVisible: boolean;
};
}> = [];
for (const [ws, session] of this.sessions) {
clientEntries.push({
ws,
state: this.getClientActivityState(session),
});
}
const allStates = clientEntries.map((e) => e.state);
this.logger.debug({
agentId: params.agentId,
reason: params.reason,
clientCount: clientEntries.length,
allStates,
}, "broadcastAgentAttention");
// Check if all clients are stale - if so, send push notification
const allClientsStale = allStates.every((state) => state.isStale);
this.logger.debug({ allClientsStale }, "Client staleness check");
if (allClientsStale) {
const tokens = this.pushTokenStore.getAllTokens();
this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
if (tokens.length > 0) {
void this.pushService.sendPush(tokens, {
title: "Agent needs attention",
body: `Reason: ${params.reason}`,
data: { agentId: params.agentId },
});
}
}
// Send to each client with their specific shouldNotify value
for (const { ws, state } of clientEntries) {
const shouldNotify = this.computeShouldNotifyForClient(
state,
allStates,
params.agentId
);
const message = wrapSessionMessage({
type: "agent_stream",
payload: {
agentId: params.agentId,
event: {
type: "attention_required",
provider: params.provider,
reason: params.reason,
timestamp: new Date().toISOString(),
shouldNotify,
},
timestamp: new Date().toISOString(),
},
});
this.sendToClient(ws, message);
}
}
}

View File

@@ -0,0 +1,434 @@
import type { IncomingMessage } from "http";
import { parse as parseUrl } from "url";
import type { WebSocket } from "ws";
import {
WSInboundMessageSchema,
type WSOutboundMessage,
wrapSessionMessage,
} from "./messages.js";
import { Session } from "./session.js";
import { loadConversation } from "./persistence.js";
import { AgentManager } from "./agent/agent-manager.js";
import { AgentRegistry } from "./agent/agent-registry.js";
import type { AgentProvider } from "./agent/agent-sdk-types.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import { PushTokenStore } from "./push/token-store.js";
import { PushService } from "./push/push-service.js";
import type { OpenAISTT } from "./agent/stt-openai.js";
import type { OpenAITTS } from "./agent/tts-openai.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type pino from "pino";
type AgentMcpClientConfig = {
agentMcpUrl: string;
agentMcpHeaders?: Record<string, string>;
};
export class WebSocketSessionBridge {
private readonly logger: pino.Logger;
private readonly sessions: Map<WebSocket, Session> = new Map();
private clientIdCounter = 0;
private readonly agentManager: AgentManager;
private readonly agentRegistry: AgentRegistry;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly pushTokenStore: PushTokenStore;
private readonly pushService: PushService;
private readonly agentMcpConfig: AgentMcpClientConfig;
private readonly stt: OpenAISTT | null;
private readonly tts: OpenAITTS | null;
private readonly terminalManager: TerminalManager | null;
constructor(
logger: pino.Logger,
agentManager: AgentManager,
agentRegistry: AgentRegistry,
downloadTokenStore: DownloadTokenStore,
agentMcpConfig: AgentMcpClientConfig,
speech?: { stt: OpenAISTT | null; tts: OpenAITTS | null },
terminalManager?: TerminalManager | null
) {
this.logger = logger.child({ module: "websocket-session-bridge" });
this.agentManager = agentManager;
this.agentRegistry = agentRegistry;
this.downloadTokenStore = downloadTokenStore;
this.agentMcpConfig = agentMcpConfig;
this.stt = speech?.stt ?? null;
this.tts = speech?.tts ?? null;
this.terminalManager = terminalManager ?? null;
const pushLogger = this.logger.child({ module: "push" });
this.pushTokenStore = new PushTokenStore(pushLogger);
this.pushService = new PushService(pushLogger, this.pushTokenStore);
this.agentManager.setAgentAttentionCallback((params) => {
this.broadcastAgentAttention(params);
});
}
public getSessionCount(): number {
return this.sessions.size;
}
public async attach(ws: WebSocket, request: IncomingMessage): Promise<void> {
const clientId = `client-${++this.clientIdCounter}`;
const connectionLogger = this.logger.child({ clientId });
const url = parseUrl(request.url || "", true);
const conversationId = url.query.conversationId as string | undefined;
let initialMessages = null;
if (conversationId) {
connectionLogger.debug({ conversationId }, "Client requesting conversation");
initialMessages = await loadConversation(connectionLogger, conversationId);
if (initialMessages) {
connectionLogger.debug(
{ conversationId, messageCount: initialMessages.length },
"Loaded conversation"
);
} else {
connectionLogger.debug({ conversationId }, "Conversation not found, starting fresh");
}
}
const session = new Session(
clientId,
(msg) => {
this.sendToClient(ws, wrapSessionMessage(msg));
},
connectionLogger.child({ module: "session" }),
this.downloadTokenStore,
this.pushTokenStore,
this.agentManager,
this.agentRegistry,
this.agentMcpConfig,
this.stt,
this.tts,
this.terminalManager,
{
conversationId,
initialMessages: initialMessages || undefined,
}
);
this.sessions.set(ws, session);
connectionLogger.info(
{ clientId, conversationId: session.getConversationId(), totalSessions: this.sessions.size },
"Client connected"
);
ws.on("message", (data) => {
void this.handleRawMessage(ws, data);
});
ws.on("close", async () => {
await this.detach(ws, connectionLogger, clientId);
});
ws.on("error", async (error) => {
const err = error instanceof Error ? error : new Error(String(error));
connectionLogger.error({ err }, "Client error");
await this.detach(ws, connectionLogger, clientId);
});
}
private async detach(ws: WebSocket, connectionLogger: pino.Logger, clientId: string): Promise<void> {
const session = this.sessions.get(ws);
if (!session) return;
connectionLogger.info(
{ clientId, totalSessions: this.sessions.size - 1 },
"Client disconnected"
);
await session.cleanup();
this.sessions.delete(ws);
}
private async handleRawMessage(ws: WebSocket, data: Buffer | ArrayBuffer | Buffer[]): Promise<void> {
try {
const parsed = JSON.parse(data.toString());
const message = WSInboundMessageSchema.parse(parsed);
const messageSummary = {
type: message.type,
...(message.type === "session" && message.message
? { sessionMessageType: message.message.type }
: {}),
};
this.logger.debug(messageSummary, "Received message");
if (message.type === "ping") {
this.sendToClient(ws, { type: "pong" });
return;
}
if (message.type === "recording_state") {
this.logger.debug({ isRecording: message.isRecording }, "Recording state");
return;
}
const session = this.sessions.get(ws);
if (!session) {
this.logger.error("No session found for client");
return;
}
if (message.type === "session") {
if (message.message.type === "create_agent_request") {
this.logger.debug(
{
cwd: message.message.config.cwd,
initialMode: message.message.config.modeId,
worktreeName: message.message.worktreeName,
requestId: message.message.requestId,
},
"create_agent_request details"
);
}
await session.handleMessage(message.message);
}
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
let rawPayload: string | null = null;
let parsedPayload: unknown = null;
try {
const buffer = Array.isArray(data)
? Buffer.concat(
data.map((item) => (Buffer.isBuffer(item) ? item : Buffer.from(item as ArrayBuffer)))
)
: Buffer.isBuffer(data)
? data
: Buffer.from(data as ArrayBuffer);
rawPayload = buffer.toString();
parsedPayload = JSON.parse(rawPayload);
} catch (payloadError) {
rawPayload = rawPayload ?? "<unreadable>";
parsedPayload = parsedPayload ?? rawPayload;
const payloadErr =
payloadError instanceof Error ? payloadError : new Error(String(payloadError));
this.logger.error({ err: payloadErr }, "Failed to decode raw payload");
}
const trimmedRawPayload =
typeof rawPayload === "string" && rawPayload.length > 2000
? `${rawPayload.slice(0, 2000)}... (truncated)`
: rawPayload;
this.logger.error(
{
err,
rawPayload: trimmedRawPayload,
parsedPayload,
},
"Failed to parse/handle message"
);
this.sendToClient(
ws,
wrapSessionMessage({
type: "status",
payload: {
status: "error",
message: `Invalid message: ${err.message}`,
},
})
);
}
}
private sendToClient(ws: WebSocket, message: WSOutboundMessage): void {
// WebSocket.OPEN = 1
if (ws.readyState === 1) {
ws.send(JSON.stringify(message));
}
}
public broadcast(message: WSOutboundMessage): void {
const payload = JSON.stringify(message);
for (const ws of this.sessions.keys()) {
if (ws.readyState === 1) {
ws.send(payload);
}
}
}
public async closeAll(): Promise<void> {
const cleanupPromises: Promise<void>[] = [];
for (const [ws, session] of this.sessions) {
cleanupPromises.push(session.cleanup());
cleanupPromises.push(
new Promise<void>((resolve) => {
// WebSocket.CLOSED = 3
if (ws.readyState === 3) {
resolve();
return;
}
ws.once("close", () => resolve());
ws.close();
})
);
}
await Promise.all(cleanupPromises);
this.sessions.clear();
}
private readonly ACTIVITY_THRESHOLD_MS = 120_000;
private getClientActivityState(session: Session): {
deviceType: "web" | "mobile" | null;
focusedAgentId: string | null;
isStale: boolean;
appVisible: boolean;
} {
const activity = session.getClientActivity();
if (!activity) {
this.logger.debug("getClientActivityState: no activity for session");
return { deviceType: null, focusedAgentId: null, isStale: true, appVisible: false };
}
const now = Date.now();
const ageMs = now - activity.lastActivityAt.getTime();
const isStale = ageMs >= this.ACTIVITY_THRESHOLD_MS;
this.logger.debug(
{
deviceType: activity.deviceType,
focusedAgentId: activity.focusedAgentId,
lastActivityAt: activity.lastActivityAt.toISOString(),
ageMs,
isStale,
appVisible: activity.appVisible,
},
"getClientActivityState"
);
return {
deviceType: activity.deviceType,
focusedAgentId: activity.focusedAgentId,
isStale,
appVisible: activity.appVisible,
};
}
private computeShouldNotifyForClient(
clientState: {
deviceType: "web" | "mobile" | null;
focusedAgentId: string | null;
isStale: boolean;
appVisible: boolean;
},
allClientStates: Array<{
deviceType: "web" | "mobile" | null;
focusedAgentId: string | null;
isStale: boolean;
appVisible: boolean;
}>,
agentId: string
): boolean {
const isAnyoneActiveOnAgent = allClientStates.some(
(state) => state.focusedAgentId === agentId && state.appVisible && !state.isStale
);
if (isAnyoneActiveOnAgent) {
return false;
}
if (clientState.deviceType === null) {
return true;
}
if (!clientState.isStale && clientState.appVisible && clientState.focusedAgentId !== null) {
return true;
}
if (!clientState.isStale) {
return false;
}
const hasActiveWebClient = allClientStates.some(
(state) => state.deviceType === "web" && !state.isStale
);
if (clientState.deviceType === "mobile") {
return !hasActiveWebClient;
}
if (clientState.deviceType === "web") {
const hasOtherClient = allClientStates.some(
(state) => state !== clientState && (state.deviceType === "mobile" || state.deviceType === null)
);
return !hasOtherClient;
}
return true;
}
private broadcastAgentAttention(params: {
agentId: string;
provider: AgentProvider;
reason: "finished" | "error" | "permission";
}): void {
const clientEntries: Array<{
ws: WebSocket;
state: {
deviceType: "web" | "mobile" | null;
focusedAgentId: string | null;
isStale: boolean;
appVisible: boolean;
};
}> = [];
for (const [ws, session] of this.sessions) {
clientEntries.push({
ws,
state: this.getClientActivityState(session),
});
}
const allStates = clientEntries.map((e) => e.state);
this.logger.debug(
{
agentId: params.agentId,
reason: params.reason,
clientCount: clientEntries.length,
allStates,
},
"broadcastAgentAttention"
);
const allClientsStale = allStates.every((state) => state.isStale);
this.logger.debug({ allClientsStale }, "Client staleness check");
if (allClientsStale) {
const tokens = this.pushTokenStore.getAllTokens();
this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
if (tokens.length > 0) {
void this.pushService.sendPush(tokens, {
title: "Agent needs attention",
body: `Reason: ${params.reason}`,
data: { agentId: params.agentId },
});
}
}
for (const { ws, state } of clientEntries) {
const shouldNotify = this.computeShouldNotifyForClient(state, allStates, params.agentId);
const message = wrapSessionMessage({
type: "agent_stream",
payload: {
agentId: params.agentId,
event: {
type: "attention_required",
provider: params.provider,
reason: params.reason,
timestamp: new Date().toISOString(),
shouldNotify,
},
timestamp: new Date().toISOString(),
},
});
this.sendToClient(ws, message);
}
}
}

View File

@@ -518,6 +518,14 @@ export const ListCommandsRequestSchema = z.object({
requestId: z.string(),
});
export const ExecuteCommandRequestSchema = z.object({
type: z.literal("execute_command_request"),
agentId: z.string(),
commandName: z.string(),
args: z.string().optional(),
requestId: z.string(),
});
export const RegisterPushTokenMessageSchema = z.object({
type: z.literal("register_push_token"),
token: z.string(),
@@ -604,6 +612,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ClearAgentAttentionMessageSchema,
ClientHeartbeatMessageSchema,
ListCommandsRequestSchema,
ExecuteCommandRequestSchema,
RegisterPushTokenMessageSchema,
ListTerminalsRequestSchema,
CreateTerminalRequestSchema,
@@ -937,6 +946,22 @@ export const ListCommandsResponseSchema = z.object({
}),
});
const AgentCommandResultSchema = z.object({
text: z.string(),
timeline: z.array(AgentTimelineItemPayloadSchema),
usage: AgentUsageSchema.optional(),
});
export const ExecuteCommandResponseSchema = z.object({
type: z.literal("execute_command_response"),
payload: z.object({
agentId: z.string(),
result: AgentCommandResultSchema.nullable(),
error: z.string().nullable(),
requestId: z.string(),
}),
});
// ============================================================================
// Terminal Outbound Messages
// ============================================================================
@@ -1037,6 +1062,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
GitRepoInfoResponseSchema,
ListProviderModelsResponseMessageSchema,
ListCommandsResponseSchema,
ExecuteCommandResponseSchema,
ListTerminalsResponseSchema,
CreateTerminalResponseSchema,
SubscribeTerminalResponseSchema,
@@ -1104,6 +1130,8 @@ export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessa
export type ClientHeartbeatMessage = z.infer<typeof ClientHeartbeatMessageSchema>;
export type ListCommandsRequest = z.infer<typeof ListCommandsRequestSchema>;
export type ListCommandsResponse = z.infer<typeof ListCommandsResponseSchema>;
export type ExecuteCommandRequest = z.infer<typeof ExecuteCommandRequestSchema>;
export type ExecuteCommandResponse = z.infer<typeof ExecuteCommandResponseSchema>;
export type RegisterPushTokenMessage = z.infer<typeof RegisterPushTokenMessageSchema>;
// Terminal message types