mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 18:35:17 +00:00
Remove the "agent can be a terminal" branching from the entire codebase. An agent is now always a session-backed chat agent. Standalone terminal infrastructure (terminal component, ANSI handling, terminal-stream-protocol) is preserved. Server: delete ManagedTerminalAgent, AgentKind, TerminalExitDetails, launchTerminalAgent, registerTerminalAgent, handleTerminalAgentExited, supportsTerminalMode capability, buildTerminalCreate/ResumeCommand from all providers, terminal agent persistence/projections. App: delete terminal-agent-panel.tsx, terminal-agent-reopen-store.ts, terminal/terminalExit fields on agent state, "Terminal Agents" launcher section, terminal-agent workspace setup flow, terminal badge in agent list. CLI: remove terminal column from ls, terminal-agent error from send. 60 files changed, -3592 lines
3631 lines
110 KiB
TypeScript
3631 lines
110 KiB
TypeScript
import type {
|
|
AgentCapabilityFlags,
|
|
AgentClient,
|
|
AgentLaunchContext,
|
|
AgentMode,
|
|
AgentModelDefinition,
|
|
McpServerConfig,
|
|
AgentPermissionRequest,
|
|
AgentPermissionResponse,
|
|
AgentPromptContentBlock,
|
|
AgentPromptInput,
|
|
AgentRunOptions,
|
|
AgentRunResult,
|
|
AgentRuntimeInfo,
|
|
AgentSession,
|
|
AgentSessionConfig,
|
|
AgentSlashCommand,
|
|
AgentStreamEvent,
|
|
AgentTimelineItem,
|
|
ToolCallTimelineItem,
|
|
AgentUsage,
|
|
ListModelsOptions,
|
|
ListPersistedAgentsOptions,
|
|
PersistedAgentDescriptor,
|
|
} from "../agent-sdk-types.js";
|
|
import type { Logger } from "pino";
|
|
|
|
import { execSync, spawn } from "node:child_process";
|
|
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import { existsSync, Dirent } from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import readline from "node:readline";
|
|
import { z } from "zod";
|
|
import { loadCodexPersistedTimeline } from "./codex-rollout-timeline.js";
|
|
import {
|
|
mapCodexRolloutToolCall,
|
|
mapCodexToolCallFromThreadItem,
|
|
} from "./codex/tool-call-mapper.js";
|
|
import {
|
|
applyProviderEnv,
|
|
findExecutable,
|
|
resolveProviderCommandPrefix,
|
|
type ProviderRuntimeSettings,
|
|
} from "../provider-launch-config.js";
|
|
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
|
|
|
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
|
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
|
const CODEX_PROVIDER = "codex" as const;
|
|
const CODEX_IMAGE_ATTACHMENT_DIR = "paseo-attachments";
|
|
|
|
const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: false,
|
|
supportsMcpServers: true,
|
|
supportsReasoningStream: true,
|
|
supportsToolInvocations: true,
|
|
};
|
|
|
|
const CODEX_MODES: AgentMode[] = [
|
|
{
|
|
id: "read-only",
|
|
label: "Read Only",
|
|
description:
|
|
"Read files and answer questions. Manual approval required for edits, commands, or network ops.",
|
|
},
|
|
{
|
|
id: "auto",
|
|
label: "Auto",
|
|
description: "Edit files and run commands but still request approval before escalating scope.",
|
|
},
|
|
{
|
|
id: "full-access",
|
|
label: "Full Access",
|
|
description: "Edit files, run commands, and access the network without additional prompts.",
|
|
},
|
|
];
|
|
|
|
const DEFAULT_CODEX_MODE_ID = "auto";
|
|
|
|
const MODE_PRESETS: Record<
|
|
string,
|
|
{ approvalPolicy: string; sandbox: string; networkAccess?: boolean }
|
|
> = {
|
|
"read-only": {
|
|
approvalPolicy: "on-request",
|
|
sandbox: "read-only",
|
|
},
|
|
auto: {
|
|
approvalPolicy: "on-request",
|
|
sandbox: "workspace-write",
|
|
},
|
|
"full-access": {
|
|
approvalPolicy: "never",
|
|
sandbox: "danger-full-access",
|
|
networkAccess: true,
|
|
},
|
|
};
|
|
|
|
function validateCodexMode(modeId: string): void {
|
|
if (!(modeId in MODE_PRESETS)) {
|
|
const validModes = Object.keys(MODE_PRESETS).join(", ");
|
|
throw new Error(`Invalid Codex mode "${modeId}". Valid modes are: ${validModes}`);
|
|
}
|
|
}
|
|
|
|
function normalizeCodexThinkingOptionId(
|
|
thinkingOptionId: string | null | undefined,
|
|
): string | undefined {
|
|
if (typeof thinkingOptionId !== "string") {
|
|
return undefined;
|
|
}
|
|
const normalized = thinkingOptionId.trim();
|
|
if (!normalized || normalized === "default") {
|
|
return undefined;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeCodexModelId(modelId: string | null | undefined): string | undefined {
|
|
if (typeof modelId !== "string") {
|
|
return undefined;
|
|
}
|
|
const normalized = modelId.trim();
|
|
if (!normalized) {
|
|
return undefined;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeCodexModelLabel(displayName: string): string {
|
|
return displayName.replace(/\bgpt\b/gi, "GPT");
|
|
}
|
|
|
|
type CodexConfiguredDefaults = {
|
|
model?: string;
|
|
thinkingOptionId?: string;
|
|
};
|
|
|
|
function mergeCodexConfiguredDefaults(
|
|
primary: CodexConfiguredDefaults,
|
|
fallback: CodexConfiguredDefaults,
|
|
): CodexConfiguredDefaults {
|
|
return {
|
|
model: primary.model ?? fallback.model,
|
|
thinkingOptionId: primary.thinkingOptionId ?? fallback.thinkingOptionId,
|
|
};
|
|
}
|
|
|
|
function resolveCodexBinary(): string {
|
|
const found = findExecutable("codex");
|
|
if (found) {
|
|
return found;
|
|
}
|
|
throw new Error(
|
|
"Codex binary not found. Install the Codex CLI (https://github.com/openai/codex) and ensure it is available in your shell PATH.",
|
|
);
|
|
}
|
|
|
|
function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSettings): {
|
|
command: string;
|
|
args: string[];
|
|
} {
|
|
return resolveProviderCommandPrefix(runtimeSettings?.command, resolveCodexBinary);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
interface CodexMcpServerConfig {
|
|
url?: string;
|
|
http_headers?: Record<string, string>;
|
|
command?: string;
|
|
args?: string[];
|
|
env?: Record<string, string>;
|
|
tool_timeout_sec?: number;
|
|
}
|
|
|
|
function toCodexMcpConfig(config: McpServerConfig): CodexMcpServerConfig {
|
|
switch (config.type) {
|
|
case "stdio":
|
|
return {
|
|
command: config.command,
|
|
args: config.args,
|
|
env: config.env,
|
|
};
|
|
case "http":
|
|
return {
|
|
url: config.url,
|
|
http_headers: config.headers,
|
|
};
|
|
case "sse":
|
|
return {
|
|
url: config.url,
|
|
http_headers: config.headers,
|
|
};
|
|
}
|
|
}
|
|
type JsonRpcRequest = {
|
|
id: number;
|
|
method: string;
|
|
params?: unknown;
|
|
};
|
|
|
|
type JsonRpcResponse = {
|
|
id: number;
|
|
result?: unknown;
|
|
error?: { code?: number; message: string };
|
|
};
|
|
|
|
type JsonRpcNotification = {
|
|
method: string;
|
|
params?: unknown;
|
|
};
|
|
|
|
type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification;
|
|
|
|
type PendingRequest = {
|
|
resolve: (value: unknown) => void;
|
|
reject: (error: Error) => void;
|
|
timer: ReturnType<typeof setTimeout>;
|
|
};
|
|
|
|
type RequestHandler = (params: unknown) => Promise<unknown> | unknown;
|
|
|
|
type NotificationHandler = (method: string, params: unknown) => void;
|
|
|
|
// Codex app-server API response types
|
|
interface CodexModel {
|
|
id: string;
|
|
displayName?: string;
|
|
description?: string;
|
|
isDefault?: boolean;
|
|
model?: string;
|
|
defaultReasoningEffort?: string;
|
|
supportedReasoningEfforts?: string[];
|
|
}
|
|
|
|
interface CodexModelListResponse {
|
|
data?: CodexModel[];
|
|
}
|
|
|
|
interface CodexThreadStartResponse {
|
|
thread?: { id?: string };
|
|
}
|
|
|
|
class CodexAppServerClient {
|
|
private readonly rl: readline.Interface;
|
|
private readonly pending = new Map<number, PendingRequest>();
|
|
private readonly requestHandlers = new Map<string, RequestHandler>();
|
|
private notificationHandler: NotificationHandler | null = null;
|
|
private nextId = 1;
|
|
private disposed = false;
|
|
private stderrBuffer = "";
|
|
private readonly exitPromise: Promise<void>;
|
|
private resolveExitPromise: (() => void) | null = null;
|
|
|
|
constructor(
|
|
private readonly child: ChildProcessWithoutNullStreams,
|
|
private readonly logger: Logger,
|
|
) {
|
|
this.rl = readline.createInterface({ input: child.stdout });
|
|
this.exitPromise = new Promise<void>((resolve) => {
|
|
this.resolveExitPromise = resolve;
|
|
});
|
|
this.rl.on("line", (line) => this.handleLine(line));
|
|
|
|
child.stderr.on("data", (chunk) => {
|
|
this.stderrBuffer += chunk.toString();
|
|
if (this.stderrBuffer.length > 8192) {
|
|
this.stderrBuffer = this.stderrBuffer.slice(-8192);
|
|
}
|
|
});
|
|
|
|
child.on("error", (err) => {
|
|
this.logger.error({ err }, "Codex app-server child process error");
|
|
for (const pending of this.pending.values()) {
|
|
clearTimeout(pending.timer);
|
|
pending.reject(err);
|
|
}
|
|
this.pending.clear();
|
|
this.disposed = true;
|
|
this.resolveExitPromise?.();
|
|
this.resolveExitPromise = null;
|
|
});
|
|
|
|
child.on("exit", (code, signal) => {
|
|
const message =
|
|
code === 0 && !signal
|
|
? "Codex app-server exited"
|
|
: `Codex app-server exited with code ${code ?? "null"} and signal ${signal ?? "null"}`;
|
|
const error = new Error(`${message}\n${this.stderrBuffer}`.trim());
|
|
for (const pending of this.pending.values()) {
|
|
clearTimeout(pending.timer);
|
|
pending.reject(error);
|
|
}
|
|
this.pending.clear();
|
|
this.disposed = true;
|
|
this.resolveExitPromise?.();
|
|
this.resolveExitPromise = null;
|
|
});
|
|
}
|
|
|
|
setNotificationHandler(handler: NotificationHandler): void {
|
|
this.notificationHandler = handler;
|
|
}
|
|
|
|
setRequestHandler(method: string, handler: RequestHandler): void {
|
|
this.requestHandlers.set(method, handler);
|
|
}
|
|
|
|
request(method: string, params?: unknown, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
|
|
if (this.disposed) {
|
|
return Promise.reject(new Error("Codex app-server client is closed"));
|
|
}
|
|
const id = this.nextId++;
|
|
const payload: JsonRpcRequest = { id, method, params };
|
|
const serialized = JSON.stringify(payload);
|
|
this.child.stdin.write(`${serialized}\n`);
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
this.pending.delete(id);
|
|
reject(new Error(`Codex app-server request timed out for ${method}`));
|
|
}, timeoutMs);
|
|
this.pending.set(id, { resolve, reject, timer });
|
|
});
|
|
}
|
|
|
|
notify(method: string, params?: unknown): void {
|
|
if (this.disposed) {
|
|
return;
|
|
}
|
|
const payload: JsonRpcNotification = { method, params };
|
|
this.child.stdin.write(`${JSON.stringify(payload)}\n`);
|
|
}
|
|
|
|
async dispose(): Promise<void> {
|
|
if (this.disposed) return;
|
|
this.disposed = true;
|
|
this.rl.close();
|
|
try {
|
|
this.child.stdin.end();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
terminateChildProcessTree(this.child);
|
|
await this.exitPromise;
|
|
}
|
|
|
|
private async handleLine(line: string): Promise<void> {
|
|
if (!line.trim()) return;
|
|
let msg: JsonRpcMessage;
|
|
try {
|
|
msg = JSON.parse(line);
|
|
} catch (error) {
|
|
this.logger.warn({ error, line }, "Failed to parse Codex app-server JSON");
|
|
return;
|
|
}
|
|
|
|
if (typeof (msg as JsonRpcResponse).id === "number") {
|
|
const id = (msg as JsonRpcResponse).id;
|
|
if ((msg as JsonRpcResponse).result !== undefined || (msg as JsonRpcResponse).error) {
|
|
const pending = this.pending.get(id);
|
|
if (!pending) return;
|
|
clearTimeout(pending.timer);
|
|
this.pending.delete(id);
|
|
if ((msg as JsonRpcResponse).error) {
|
|
pending.reject(new Error((msg as JsonRpcResponse).error?.message ?? "Unknown error"));
|
|
} else {
|
|
pending.resolve((msg as JsonRpcResponse).result);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Server-initiated request
|
|
if (typeof (msg as JsonRpcRequest).method === "string") {
|
|
const request = msg as JsonRpcRequest;
|
|
const handler = this.requestHandlers.get(request.method);
|
|
try {
|
|
const result = handler ? await handler(request.params) : {};
|
|
const response: JsonRpcResponse = { id: request.id, result };
|
|
this.child.stdin.write(`${JSON.stringify(response)}\n`);
|
|
} catch (error) {
|
|
const response: JsonRpcResponse = {
|
|
id: request.id,
|
|
error: { message: error instanceof Error ? error.message : String(error) },
|
|
};
|
|
this.child.stdin.write(`${JSON.stringify(response)}\n`);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (typeof (msg as JsonRpcNotification).method === "string") {
|
|
const notification = msg as JsonRpcNotification;
|
|
this.notificationHandler?.(notification.method, notification.params);
|
|
}
|
|
}
|
|
}
|
|
|
|
function terminateChildProcessTree(child: ChildProcessWithoutNullStreams): void {
|
|
if (child.killed) {
|
|
return;
|
|
}
|
|
|
|
if (process.platform !== "win32" && typeof child.pid === "number" && child.pid > 0) {
|
|
try {
|
|
process.kill(-child.pid, "SIGTERM");
|
|
return;
|
|
} catch {
|
|
// Fall back to the direct child when no separate process group exists.
|
|
}
|
|
}
|
|
|
|
try {
|
|
child.kill("SIGTERM");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
|
|
if (!tokenUsage || typeof tokenUsage !== "object") return undefined;
|
|
const usage = tokenUsage as {
|
|
last?: { inputTokens?: number; cachedInputTokens?: number; outputTokens?: number };
|
|
};
|
|
return {
|
|
inputTokens: usage.last?.inputTokens,
|
|
cachedInputTokens: usage.last?.cachedInputTokens,
|
|
outputTokens: usage.last?.outputTokens,
|
|
};
|
|
}
|
|
|
|
function extractUserText(content: unknown): string | null {
|
|
if (!Array.isArray(content)) return null;
|
|
const parts: string[] = [];
|
|
for (const item of content) {
|
|
if (item && typeof item === "object") {
|
|
const obj = item as { type?: string; text?: string };
|
|
if (obj.type === "text" && typeof obj.text === "string") {
|
|
parts.push(obj.text);
|
|
}
|
|
}
|
|
}
|
|
return parts.length > 0 ? parts.join("\n") : null;
|
|
}
|
|
|
|
function parsePlanTextToTodoItems(text: string): { text: string; completed: boolean }[] {
|
|
const lines = text
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0);
|
|
if (lines.length === 0) {
|
|
return [{ text, completed: false }];
|
|
}
|
|
return lines.map((line) => ({
|
|
text: line.replace(/^[-*]\s+/, ""),
|
|
completed: false,
|
|
}));
|
|
}
|
|
|
|
function planStepsToTodoItems(steps: Array<{ step: string; status: string }>): {
|
|
text: string;
|
|
completed: boolean;
|
|
}[] {
|
|
return steps.map((entry) => ({
|
|
text: entry.step,
|
|
completed: entry.status === "completed",
|
|
}));
|
|
}
|
|
|
|
type CodexPatchFileChange = {
|
|
path: string;
|
|
kind?: string;
|
|
content?: string;
|
|
};
|
|
|
|
function extractPatchLikeText(value: unknown): string | undefined {
|
|
if (!value || typeof value !== "object") {
|
|
return undefined;
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
const candidates = [
|
|
record.diff,
|
|
record.patch,
|
|
record.unified_diff,
|
|
record.unifiedDiff,
|
|
record.content,
|
|
record.newString,
|
|
];
|
|
for (const candidate of candidates) {
|
|
if (typeof candidate === "string" && candidate.length > 0) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function normalizeCodexThreadItemType(rawType: string | undefined): string | undefined {
|
|
if (!rawType) {
|
|
return rawType;
|
|
}
|
|
switch (rawType) {
|
|
case "UserMessage":
|
|
return "userMessage";
|
|
case "AgentMessage":
|
|
return "agentMessage";
|
|
case "Reasoning":
|
|
return "reasoning";
|
|
case "Plan":
|
|
return "plan";
|
|
case "CommandExecution":
|
|
return "commandExecution";
|
|
case "FileChange":
|
|
return "fileChange";
|
|
case "McpToolCall":
|
|
return "mcpToolCall";
|
|
case "WebSearch":
|
|
return "webSearch";
|
|
default:
|
|
return rawType;
|
|
}
|
|
}
|
|
|
|
function normalizeCodexCommandValue(value: unknown): string | string[] | null {
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
if (!trimmed.length) {
|
|
return null;
|
|
}
|
|
const wrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/);
|
|
if (!wrapperMatch) {
|
|
return trimmed;
|
|
}
|
|
const candidate = wrapperMatch[1]?.trim() ?? "";
|
|
if (!candidate.length) {
|
|
return trimmed;
|
|
}
|
|
if (
|
|
(candidate.startsWith('"') && candidate.endsWith('"')) ||
|
|
(candidate.startsWith("'") && candidate.endsWith("'"))
|
|
) {
|
|
return candidate.slice(1, -1);
|
|
}
|
|
return candidate;
|
|
}
|
|
if (!Array.isArray(value)) {
|
|
return null;
|
|
}
|
|
const parts = value
|
|
.filter((entry): entry is string => typeof entry === "string")
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0);
|
|
if (parts.length === 0) {
|
|
return null;
|
|
}
|
|
if (parts.length >= 3 && (parts[1] === "-lc" || parts[1] === "-c")) {
|
|
return parts[2] ?? parts;
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] {
|
|
const resolvePathFromRecord = (record: Record<string, unknown>): string => {
|
|
const directPath =
|
|
(typeof record.path === "string" && record.path.trim().length > 0
|
|
? record.path.trim()
|
|
: "") ||
|
|
(typeof record.file_path === "string" && record.file_path.trim().length > 0
|
|
? record.file_path.trim()
|
|
: "") ||
|
|
(typeof record.filePath === "string" && record.filePath.trim().length > 0
|
|
? record.filePath.trim()
|
|
: "");
|
|
return directPath;
|
|
};
|
|
|
|
if (!changes || typeof changes !== "object") {
|
|
return [];
|
|
}
|
|
|
|
if (Array.isArray(changes)) {
|
|
return changes
|
|
.map((entry): CodexPatchFileChange | null => {
|
|
if (!entry || typeof entry !== "object") {
|
|
return null;
|
|
}
|
|
const record = entry as Record<string, unknown>;
|
|
const pathValue = resolvePathFromRecord(record);
|
|
if (!pathValue) {
|
|
return null;
|
|
}
|
|
return {
|
|
path: pathValue,
|
|
kind:
|
|
(typeof record.kind === "string" && record.kind) ||
|
|
(typeof record.type === "string" && record.type) ||
|
|
undefined,
|
|
content: extractPatchLikeText(record),
|
|
};
|
|
})
|
|
.filter((entry): entry is CodexPatchFileChange => entry !== null);
|
|
}
|
|
|
|
const recordChanges = changes as Record<string, unknown>;
|
|
const directPathValue = resolvePathFromRecord(recordChanges);
|
|
if (directPathValue) {
|
|
return [
|
|
{
|
|
path: directPathValue,
|
|
kind:
|
|
(typeof recordChanges.kind === "string" && recordChanges.kind) ||
|
|
(typeof recordChanges.type === "string" && recordChanges.type) ||
|
|
undefined,
|
|
content: extractPatchLikeText(recordChanges),
|
|
},
|
|
];
|
|
}
|
|
|
|
return Object.entries(recordChanges)
|
|
.map(([path, value]): CodexPatchFileChange | null => {
|
|
const normalizedPath = path.trim();
|
|
if (!normalizedPath) {
|
|
return null;
|
|
}
|
|
return {
|
|
path: normalizedPath,
|
|
kind:
|
|
value &&
|
|
typeof value === "object" &&
|
|
typeof (value as { type?: unknown }).type === "string"
|
|
? ((value as { type?: string }).type ?? undefined)
|
|
: undefined,
|
|
content: extractPatchLikeText(value),
|
|
};
|
|
})
|
|
.filter((entry): entry is CodexPatchFileChange => entry !== null);
|
|
}
|
|
|
|
function codexPatchTextFields(text: string | null | undefined): {
|
|
patch?: string;
|
|
content?: string;
|
|
} {
|
|
if (typeof text !== "string") {
|
|
return {};
|
|
}
|
|
const normalized = text.trimStart();
|
|
const looksLikeUnifiedDiff =
|
|
normalized.startsWith("diff --git") ||
|
|
normalized.startsWith("@@") ||
|
|
normalized.startsWith("--- ") ||
|
|
normalized.startsWith("+++ ");
|
|
return looksLikeUnifiedDiff ? { patch: text } : { content: text };
|
|
}
|
|
|
|
function toRunningToolCall(item: ToolCallTimelineItem): ToolCallTimelineItem {
|
|
return {
|
|
...item,
|
|
status: "running",
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
function isEditToolCallWithoutContent(item: ToolCallTimelineItem): boolean {
|
|
if (item.type !== "tool_call") {
|
|
return false;
|
|
}
|
|
if (item.detail.type !== "edit") {
|
|
return false;
|
|
}
|
|
const hasDiff =
|
|
typeof item.detail.unifiedDiff === "string" && item.detail.unifiedDiff.trim().length > 0;
|
|
const hasNewString =
|
|
typeof item.detail.newString === "string" && item.detail.newString.trim().length > 0;
|
|
return !hasDiff && !hasNewString;
|
|
}
|
|
|
|
function decodeCodexOutputDeltaChunk(chunk: string): string {
|
|
const trimmed = chunk.trim();
|
|
if (trimmed.length === 0) {
|
|
return chunk;
|
|
}
|
|
if (!/^[A-Za-z0-9+/=]+$/.test(trimmed) || trimmed.length % 4 !== 0) {
|
|
return chunk;
|
|
}
|
|
|
|
try {
|
|
const decoded = Buffer.from(trimmed, "base64").toString("utf8");
|
|
if (decoded.length === 0) {
|
|
return chunk;
|
|
}
|
|
const normalizedInput = trimmed.replace(/=+$/, "");
|
|
const normalizedRoundTrip = Buffer.from(decoded, "utf8").toString("base64").replace(/=+$/, "");
|
|
return normalizedRoundTrip === normalizedInput ? decoded : chunk;
|
|
} catch {
|
|
return chunk;
|
|
}
|
|
}
|
|
|
|
function mapCodexExecNotificationToToolCall(params: {
|
|
callId?: string | null;
|
|
command: unknown;
|
|
cwd?: string | null;
|
|
output?: string | null;
|
|
exitCode?: number | null;
|
|
success?: boolean | null;
|
|
stderr?: string | null;
|
|
running: boolean;
|
|
}): ToolCallTimelineItem | null {
|
|
const command = normalizeCodexCommandValue(params.command);
|
|
if (!command) {
|
|
return null;
|
|
}
|
|
const isFailure = params.running
|
|
? false
|
|
: params.success === false || (typeof params.exitCode === "number" && params.exitCode !== 0);
|
|
const output = params.running
|
|
? null
|
|
: {
|
|
command,
|
|
...(params.output !== null && params.output !== undefined ? { output: params.output } : {}),
|
|
...(params.exitCode !== null && params.exitCode !== undefined
|
|
? { exitCode: params.exitCode }
|
|
: {}),
|
|
};
|
|
const mapped = mapCodexRolloutToolCall({
|
|
callId: params.callId ?? null,
|
|
name: "shell",
|
|
input: {
|
|
command,
|
|
...(params.cwd ? { cwd: params.cwd } : {}),
|
|
},
|
|
output,
|
|
error: isFailure ? { message: params.stderr?.trim() || "Command failed" } : null,
|
|
cwd: params.cwd ?? null,
|
|
});
|
|
if (!mapped) {
|
|
return null;
|
|
}
|
|
return params.running ? toRunningToolCall(mapped) : mapped;
|
|
}
|
|
|
|
function mapCodexPatchNotificationToToolCall(params: {
|
|
callId?: string | null;
|
|
changes: unknown;
|
|
cwd?: string | null;
|
|
stdout?: string | null;
|
|
stderr?: string | null;
|
|
success?: boolean | null;
|
|
running: boolean;
|
|
}): ToolCallTimelineItem | null {
|
|
const files = parseCodexPatchChanges(params.changes);
|
|
const firstPath = files[0]?.path;
|
|
const firstPatchText = files
|
|
.map((file) => file.content?.trim())
|
|
.find((value): value is string => typeof value === "string" && value.length > 0);
|
|
const patchText = firstPatchText;
|
|
const patchFields = codexPatchTextFields(patchText);
|
|
const mapped = mapCodexRolloutToolCall({
|
|
callId: params.callId ?? null,
|
|
name: "apply_patch",
|
|
input: firstPath
|
|
? {
|
|
path: firstPath,
|
|
...patchFields,
|
|
files: files.map((file) => ({ path: file.path, kind: file.kind })),
|
|
}
|
|
: {
|
|
changes: params.changes ?? null,
|
|
...patchFields,
|
|
},
|
|
output: params.running
|
|
? null
|
|
: {
|
|
...(files.length > 0
|
|
? {
|
|
files: files.map((file) => ({
|
|
path: file.path,
|
|
...(file.kind ? { kind: file.kind } : {}),
|
|
...codexPatchTextFields(file.content ?? patchText),
|
|
})),
|
|
}
|
|
: {}),
|
|
...(params.stdout ? { stdout: params.stdout } : {}),
|
|
...(params.stderr ? { stderr: params.stderr } : {}),
|
|
...(params.success !== null && params.success !== undefined
|
|
? { success: params.success }
|
|
: {}),
|
|
},
|
|
error:
|
|
params.running || params.success !== false
|
|
? null
|
|
: { message: params.stderr?.trim() || "Patch apply failed" },
|
|
cwd: params.cwd ?? null,
|
|
});
|
|
if (!mapped) {
|
|
return null;
|
|
}
|
|
return params.running ? toRunningToolCall(mapped) : mapped;
|
|
}
|
|
|
|
function mapCodexTerminalInteractionToToolCall(params: {
|
|
processId?: string | null;
|
|
fallbackCallId?: string | null;
|
|
command?: string | null;
|
|
}): ToolCallTimelineItem {
|
|
const processId = nonEmptyString(params.processId ?? undefined);
|
|
const callId = processId
|
|
? `terminal-session-${processId}`
|
|
: (nonEmptyString(params.fallbackCallId ?? undefined) ?? "terminal-interaction");
|
|
const label = nonEmptyString(params.command ?? undefined);
|
|
return {
|
|
type: "tool_call",
|
|
callId,
|
|
name: "terminal",
|
|
status: "completed",
|
|
error: null,
|
|
detail: {
|
|
type: "plain_text",
|
|
...(label ? { label } : {}),
|
|
icon: "square_terminal",
|
|
},
|
|
...(processId ? { metadata: { processId } } : {}),
|
|
};
|
|
}
|
|
|
|
function threadItemToTimeline(
|
|
item: any,
|
|
options?: { includeUserMessage?: boolean; cwd?: string | null },
|
|
): AgentTimelineItem | null {
|
|
if (!item || typeof item !== "object") return null;
|
|
const includeUserMessage = options?.includeUserMessage ?? true;
|
|
const cwd = options?.cwd ?? null;
|
|
const normalizedType = normalizeCodexThreadItemType(
|
|
typeof item.type === "string" ? item.type : undefined,
|
|
);
|
|
const normalizedItem =
|
|
normalizedType && normalizedType !== item.type
|
|
? ({ ...item, type: normalizedType } as typeof item)
|
|
: item;
|
|
|
|
switch (normalizedType) {
|
|
case "userMessage": {
|
|
if (!includeUserMessage) {
|
|
return null;
|
|
}
|
|
const text = extractUserText(normalizedItem.content) ?? "";
|
|
return { type: "user_message", text };
|
|
}
|
|
case "agentMessage": {
|
|
return { type: "assistant_message", text: normalizedItem.text ?? "" };
|
|
}
|
|
case "plan": {
|
|
const text = normalizedItem.text ?? "";
|
|
const items = parsePlanTextToTodoItems(text);
|
|
return { type: "todo", items };
|
|
}
|
|
case "reasoning": {
|
|
const summary = Array.isArray(normalizedItem.summary)
|
|
? normalizedItem.summary.join("\n")
|
|
: "";
|
|
const content = Array.isArray(normalizedItem.content)
|
|
? normalizedItem.content.join("\n")
|
|
: "";
|
|
const text = summary || content;
|
|
return text ? { type: "reasoning", text } : null;
|
|
}
|
|
case "commandExecution":
|
|
case "fileChange":
|
|
case "mcpToolCall":
|
|
case "webSearch":
|
|
return mapCodexToolCallFromThreadItem(normalizedItem, { cwd });
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function toSandboxPolicy(type: string, networkAccess?: boolean): Record<string, unknown> {
|
|
switch (type) {
|
|
case "read-only":
|
|
return { type: "readOnly" };
|
|
case "workspace-write":
|
|
return { type: "workspaceWrite", networkAccess: networkAccess ?? false };
|
|
case "danger-full-access":
|
|
return { type: "dangerFullAccess" };
|
|
default:
|
|
return { type: "workspaceWrite", networkAccess: networkAccess ?? false };
|
|
}
|
|
}
|
|
|
|
function getImageExtension(mimeType: string): string {
|
|
switch (mimeType) {
|
|
case "image/jpeg":
|
|
return "jpg";
|
|
case "image/png":
|
|
return "png";
|
|
case "image/webp":
|
|
return "webp";
|
|
case "image/gif":
|
|
return "gif";
|
|
case "image/bmp":
|
|
return "bmp";
|
|
case "image/tiff":
|
|
return "tiff";
|
|
default:
|
|
return "bin";
|
|
}
|
|
}
|
|
|
|
type ImageDataPayload = { mimeType: string; data: string };
|
|
|
|
function normalizeImageData(mimeType: string, data: string): ImageDataPayload {
|
|
if (data.startsWith("data:")) {
|
|
const match = data.match(/^data:([^;]+);base64,(.*)$/);
|
|
if (match) {
|
|
return { mimeType: match[1], data: match[2] };
|
|
}
|
|
}
|
|
return { mimeType, data };
|
|
}
|
|
|
|
const ThreadStartedNotificationSchema = z
|
|
.object({
|
|
thread: z.object({ id: z.string() }).passthrough(),
|
|
})
|
|
.passthrough();
|
|
|
|
const TurnStartedNotificationSchema = z
|
|
.object({
|
|
turn: z.object({ id: z.string() }).passthrough(),
|
|
})
|
|
.passthrough();
|
|
|
|
const TurnCompletedNotificationSchema = z
|
|
.object({
|
|
turn: z
|
|
.object({
|
|
status: z.string(),
|
|
error: z
|
|
.object({
|
|
message: z.string().optional(),
|
|
})
|
|
.passthrough()
|
|
.nullable()
|
|
.optional(),
|
|
})
|
|
.passthrough(),
|
|
})
|
|
.passthrough();
|
|
|
|
const TurnPlanUpdatedNotificationSchema = z
|
|
.object({
|
|
plan: z.array(
|
|
z
|
|
.object({
|
|
step: z.string().optional(),
|
|
status: z.string().optional(),
|
|
})
|
|
.passthrough(),
|
|
),
|
|
})
|
|
.passthrough();
|
|
|
|
const TurnDiffUpdatedNotificationSchema = z
|
|
.object({
|
|
diff: z.string(),
|
|
})
|
|
.passthrough();
|
|
|
|
const ThreadTokenUsageUpdatedNotificationSchema = z
|
|
.object({
|
|
tokenUsage: z.unknown(),
|
|
})
|
|
.passthrough();
|
|
|
|
const ItemTextDeltaNotificationSchema = z
|
|
.object({
|
|
itemId: z.string(),
|
|
delta: z.string(),
|
|
})
|
|
.passthrough();
|
|
|
|
const ItemLifecycleNotificationSchema = z
|
|
.object({
|
|
item: z
|
|
.object({
|
|
id: z.string().optional(),
|
|
type: z.string().optional(),
|
|
})
|
|
.passthrough(),
|
|
})
|
|
.passthrough();
|
|
|
|
const CodexEventTurnAbortedNotificationSchema = z
|
|
.object({
|
|
msg: z
|
|
.object({
|
|
type: z.literal("turn_aborted"),
|
|
reason: z.string().optional(),
|
|
})
|
|
.passthrough(),
|
|
})
|
|
.passthrough();
|
|
|
|
const CodexEventTaskCompleteNotificationSchema = z
|
|
.object({
|
|
msg: z
|
|
.object({
|
|
type: z.literal("task_complete"),
|
|
})
|
|
.passthrough(),
|
|
})
|
|
.passthrough();
|
|
|
|
const CodexEventItemLifecycleNotificationSchema = z
|
|
.object({
|
|
msg: z
|
|
.object({
|
|
type: z.enum(["item_started", "item_completed"]),
|
|
item: z
|
|
.object({
|
|
id: z.string().optional(),
|
|
type: z.string().optional(),
|
|
})
|
|
.passthrough(),
|
|
})
|
|
.passthrough(),
|
|
})
|
|
.passthrough();
|
|
|
|
const CodexEventExecCommandBeginNotificationSchema = z
|
|
.object({
|
|
msg: z
|
|
.object({
|
|
type: z.literal("exec_command_begin"),
|
|
call_id: z.string().optional(),
|
|
command: z.unknown().optional(),
|
|
cwd: z.string().optional(),
|
|
})
|
|
.passthrough(),
|
|
})
|
|
.passthrough();
|
|
|
|
const CodexEventExecCommandEndNotificationSchema = z
|
|
.object({
|
|
msg: z
|
|
.object({
|
|
type: z.literal("exec_command_end"),
|
|
call_id: z.string().optional(),
|
|
command: z.unknown().optional(),
|
|
cwd: z.string().optional(),
|
|
stdout: z.string().optional(),
|
|
stderr: z.string().optional(),
|
|
aggregated_output: z.string().optional(),
|
|
aggregatedOutput: z.string().optional(),
|
|
formatted_output: z.string().optional(),
|
|
exit_code: z.number().nullable().optional(),
|
|
exitCode: z.number().nullable().optional(),
|
|
success: z.boolean().optional(),
|
|
})
|
|
.passthrough(),
|
|
})
|
|
.passthrough();
|
|
|
|
const CodexEventExecCommandOutputDeltaNotificationSchema = z
|
|
.object({
|
|
msg: z
|
|
.object({
|
|
type: z.literal("exec_command_output_delta"),
|
|
call_id: z.string().optional(),
|
|
stream: z.string().optional(),
|
|
chunk: z.string().optional(),
|
|
delta: z.string().optional(),
|
|
})
|
|
.passthrough(),
|
|
})
|
|
.passthrough();
|
|
|
|
const CodexEventTerminalInteractionNotificationSchema = z
|
|
.object({
|
|
msg: z
|
|
.object({
|
|
type: z.literal("terminal_interaction"),
|
|
call_id: z.string().optional(),
|
|
process_id: z.union([z.string(), z.number()]).optional(),
|
|
stdin: z.string().optional(),
|
|
})
|
|
.passthrough(),
|
|
})
|
|
.passthrough();
|
|
|
|
const ItemCommandExecutionTerminalInteractionNotificationSchema = z
|
|
.object({
|
|
itemId: z.string().optional(),
|
|
processId: z.union([z.string(), z.number()]).optional(),
|
|
stdin: z.string().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const CodexEventPatchApplyBeginNotificationSchema = z
|
|
.object({
|
|
msg: z
|
|
.object({
|
|
type: z.literal("patch_apply_begin"),
|
|
call_id: z.string().optional(),
|
|
changes: z.unknown().optional(),
|
|
})
|
|
.passthrough(),
|
|
})
|
|
.passthrough();
|
|
|
|
const CodexEventPatchApplyEndNotificationSchema = z
|
|
.object({
|
|
msg: z
|
|
.object({
|
|
type: z.literal("patch_apply_end"),
|
|
call_id: z.string().optional(),
|
|
changes: z.unknown().optional(),
|
|
stdout: z.string().optional(),
|
|
stderr: z.string().optional(),
|
|
success: z.boolean().optional(),
|
|
})
|
|
.passthrough(),
|
|
})
|
|
.passthrough();
|
|
|
|
const ItemFileChangeOutputDeltaNotificationSchema = z
|
|
.object({
|
|
itemId: z.string(),
|
|
delta: z.string().optional(),
|
|
chunk: z.string().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const CodexEventTurnDiffNotificationSchema = z
|
|
.object({
|
|
msg: z
|
|
.object({
|
|
type: z.literal("turn_diff"),
|
|
unified_diff: z.string().optional(),
|
|
diff: z.string().optional(),
|
|
})
|
|
.passthrough(),
|
|
})
|
|
.passthrough();
|
|
|
|
type ParsedCodexNotification =
|
|
| { kind: "thread_started"; threadId: string }
|
|
| { kind: "turn_started"; turnId: string }
|
|
| { kind: "turn_completed"; status: string; errorMessage: string | null }
|
|
| { kind: "plan_updated"; plan: Array<{ step: string | null; status: string | null }> }
|
|
| { kind: "diff_updated"; diff: string }
|
|
| { kind: "token_usage_updated"; tokenUsage: unknown }
|
|
| { kind: "agent_message_delta"; itemId: string; delta: string }
|
|
| { kind: "reasoning_delta"; itemId: string; delta: string }
|
|
| {
|
|
kind: "item_completed";
|
|
source: "item" | "codex_event";
|
|
item: { id?: string; type?: string; [key: string]: unknown };
|
|
}
|
|
| {
|
|
kind: "item_started";
|
|
source: "item" | "codex_event";
|
|
item: { id?: string; type?: string; [key: string]: unknown };
|
|
}
|
|
| {
|
|
kind: "exec_command_started";
|
|
callId: string | null;
|
|
command: unknown;
|
|
cwd: string | null;
|
|
}
|
|
| {
|
|
kind: "exec_command_completed";
|
|
callId: string | null;
|
|
command: unknown;
|
|
cwd: string | null;
|
|
output: string | null;
|
|
exitCode: number | null;
|
|
success: boolean | null;
|
|
stderr: string | null;
|
|
}
|
|
| {
|
|
kind: "exec_command_output_delta";
|
|
callId: string | null;
|
|
stream: string | null;
|
|
chunk: string | null;
|
|
}
|
|
| {
|
|
kind: "terminal_interaction";
|
|
source: "item" | "codex_event";
|
|
callId: string | null;
|
|
processId: string | null;
|
|
stdin: string | null;
|
|
}
|
|
| {
|
|
kind: "patch_apply_started";
|
|
callId: string | null;
|
|
changes: unknown;
|
|
}
|
|
| {
|
|
kind: "patch_apply_completed";
|
|
callId: string | null;
|
|
changes: unknown;
|
|
stdout: string | null;
|
|
stderr: string | null;
|
|
success: boolean | null;
|
|
}
|
|
| {
|
|
kind: "file_change_output_delta";
|
|
itemId: string;
|
|
delta: string | null;
|
|
}
|
|
| { kind: "invalid_payload"; method: string; params: unknown }
|
|
| { kind: "unknown_method"; method: string; params: unknown };
|
|
|
|
const CodexNotificationSchema = z.union([
|
|
z
|
|
.object({ method: z.literal("thread/started"), params: ThreadStartedNotificationSchema })
|
|
.transform(
|
|
({ params }): ParsedCodexNotification => ({
|
|
kind: "thread_started",
|
|
threadId: params.thread.id,
|
|
}),
|
|
),
|
|
z.object({ method: z.literal("thread/started"), params: z.unknown() }).transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({ method: z.literal("turn/started"), params: TurnStartedNotificationSchema })
|
|
.transform(
|
|
({ params }): ParsedCodexNotification => ({ kind: "turn_started", turnId: params.turn.id }),
|
|
),
|
|
z.object({ method: z.literal("turn/started"), params: z.unknown() }).transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({ method: z.literal("turn/completed"), params: TurnCompletedNotificationSchema })
|
|
.transform(
|
|
({ params }): ParsedCodexNotification => ({
|
|
kind: "turn_completed",
|
|
status: params.turn.status,
|
|
errorMessage: params.turn.error?.message ?? null,
|
|
}),
|
|
),
|
|
z.object({ method: z.literal("turn/completed"), params: z.unknown() }).transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({ method: z.literal("turn/plan/updated"), params: TurnPlanUpdatedNotificationSchema })
|
|
.transform(
|
|
({ params }): ParsedCodexNotification => ({
|
|
kind: "plan_updated",
|
|
plan: params.plan.map((entry) => ({
|
|
step: entry.step ?? null,
|
|
status: entry.status ?? null,
|
|
})),
|
|
}),
|
|
),
|
|
z.object({ method: z.literal("turn/plan/updated"), params: z.unknown() }).transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({ method: z.literal("turn/diff/updated"), params: TurnDiffUpdatedNotificationSchema })
|
|
.transform(
|
|
({ params }): ParsedCodexNotification => ({ kind: "diff_updated", diff: params.diff }),
|
|
),
|
|
z.object({ method: z.literal("turn/diff/updated"), params: z.unknown() }).transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({
|
|
method: z.literal("thread/tokenUsage/updated"),
|
|
params: ThreadTokenUsageUpdatedNotificationSchema,
|
|
})
|
|
.transform(
|
|
({ params }): ParsedCodexNotification => ({
|
|
kind: "token_usage_updated",
|
|
tokenUsage: params.tokenUsage,
|
|
}),
|
|
),
|
|
z.object({ method: z.literal("thread/tokenUsage/updated"), params: z.unknown() }).transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({
|
|
method: z.literal("item/agentMessage/delta"),
|
|
params: ItemTextDeltaNotificationSchema,
|
|
})
|
|
.transform(
|
|
({ params }): ParsedCodexNotification => ({
|
|
kind: "agent_message_delta",
|
|
itemId: params.itemId,
|
|
delta: params.delta,
|
|
}),
|
|
),
|
|
z.object({ method: z.literal("item/agentMessage/delta"), params: z.unknown() }).transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({
|
|
method: z.literal("item/reasoning/summaryTextDelta"),
|
|
params: ItemTextDeltaNotificationSchema,
|
|
})
|
|
.transform(
|
|
({ params }): ParsedCodexNotification => ({
|
|
kind: "reasoning_delta",
|
|
itemId: params.itemId,
|
|
delta: params.delta,
|
|
}),
|
|
),
|
|
z.object({ method: z.literal("item/reasoning/summaryTextDelta"), params: z.unknown() }).transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({ method: z.literal("item/completed"), params: ItemLifecycleNotificationSchema })
|
|
.transform(
|
|
({ params }): ParsedCodexNotification => ({
|
|
kind: "item_completed",
|
|
source: "item",
|
|
item: params.item,
|
|
}),
|
|
),
|
|
z.object({ method: z.literal("item/completed"), params: z.unknown() }).transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({ method: z.literal("item/started"), params: ItemLifecycleNotificationSchema })
|
|
.transform(
|
|
({ params }): ParsedCodexNotification => ({
|
|
kind: "item_started",
|
|
source: "item",
|
|
item: params.item,
|
|
}),
|
|
),
|
|
z.object({ method: z.literal("item/started"), params: z.unknown() }).transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({
|
|
method: z.literal("codex/event/item_started"),
|
|
params: CodexEventItemLifecycleNotificationSchema,
|
|
})
|
|
.transform(
|
|
({ params }): ParsedCodexNotification => ({
|
|
kind: "item_started",
|
|
source: "codex_event",
|
|
item: params.msg.item,
|
|
}),
|
|
),
|
|
z.object({ method: z.literal("codex/event/item_started"), params: z.unknown() }).transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({
|
|
method: z.literal("codex/event/item_completed"),
|
|
params: CodexEventItemLifecycleNotificationSchema,
|
|
})
|
|
.transform(
|
|
({ params }): ParsedCodexNotification => ({
|
|
kind: "item_completed",
|
|
source: "codex_event",
|
|
item: params.msg.item,
|
|
}),
|
|
),
|
|
z.object({ method: z.literal("codex/event/item_completed"), params: z.unknown() }).transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({
|
|
method: z.literal("codex/event/exec_command_begin"),
|
|
params: CodexEventExecCommandBeginNotificationSchema,
|
|
})
|
|
.transform(
|
|
({ params }): ParsedCodexNotification => ({
|
|
kind: "exec_command_started",
|
|
callId: params.msg.call_id ?? null,
|
|
command: params.msg.command ?? null,
|
|
cwd: params.msg.cwd ?? null,
|
|
}),
|
|
),
|
|
z.object({ method: z.literal("codex/event/exec_command_begin"), params: z.unknown() }).transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({
|
|
method: z.literal("codex/event/exec_command_end"),
|
|
params: CodexEventExecCommandEndNotificationSchema,
|
|
})
|
|
.transform(
|
|
({ params }): ParsedCodexNotification => ({
|
|
kind: "exec_command_completed",
|
|
callId: params.msg.call_id ?? null,
|
|
command: params.msg.command ?? null,
|
|
cwd: params.msg.cwd ?? null,
|
|
output:
|
|
params.msg.aggregated_output ??
|
|
params.msg.aggregatedOutput ??
|
|
params.msg.formatted_output ??
|
|
params.msg.stdout ??
|
|
null,
|
|
exitCode: params.msg.exit_code ?? params.msg.exitCode ?? null,
|
|
success: params.msg.success ?? null,
|
|
stderr: params.msg.stderr ?? null,
|
|
}),
|
|
),
|
|
z.object({ method: z.literal("codex/event/exec_command_end"), params: z.unknown() }).transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({
|
|
method: z.literal("codex/event/exec_command_output_delta"),
|
|
params: CodexEventExecCommandOutputDeltaNotificationSchema,
|
|
})
|
|
.transform(
|
|
({ params }): ParsedCodexNotification => ({
|
|
kind: "exec_command_output_delta",
|
|
callId: params.msg.call_id ?? null,
|
|
stream: params.msg.stream ?? null,
|
|
chunk: params.msg.chunk ?? params.msg.delta ?? null,
|
|
}),
|
|
),
|
|
z
|
|
.object({
|
|
method: z.literal("codex/event/exec_command_output_delta"),
|
|
params: z.unknown(),
|
|
})
|
|
.transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({
|
|
method: z.literal("codex/event/terminal_interaction"),
|
|
params: CodexEventTerminalInteractionNotificationSchema,
|
|
})
|
|
.transform(
|
|
({ params }): ParsedCodexNotification => ({
|
|
kind: "terminal_interaction",
|
|
source: "codex_event",
|
|
callId: params.msg.call_id ?? null,
|
|
processId:
|
|
typeof params.msg.process_id === "number"
|
|
? String(params.msg.process_id)
|
|
: (params.msg.process_id ?? null),
|
|
stdin: params.msg.stdin ?? null,
|
|
}),
|
|
),
|
|
z
|
|
.object({ method: z.literal("codex/event/terminal_interaction"), params: z.unknown() })
|
|
.transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({
|
|
method: z.literal("item/commandExecution/terminalInteraction"),
|
|
params: ItemCommandExecutionTerminalInteractionNotificationSchema,
|
|
})
|
|
.transform(
|
|
({ params }): ParsedCodexNotification => ({
|
|
kind: "terminal_interaction",
|
|
source: "item",
|
|
callId: params.itemId ?? null,
|
|
processId:
|
|
typeof params.processId === "number"
|
|
? String(params.processId)
|
|
: (params.processId ?? null),
|
|
stdin: params.stdin ?? null,
|
|
}),
|
|
),
|
|
z
|
|
.object({
|
|
method: z.literal("item/commandExecution/terminalInteraction"),
|
|
params: z.unknown(),
|
|
})
|
|
.transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({
|
|
method: z.literal("codex/event/patch_apply_begin"),
|
|
params: CodexEventPatchApplyBeginNotificationSchema,
|
|
})
|
|
.transform(
|
|
({ params }): ParsedCodexNotification => ({
|
|
kind: "patch_apply_started",
|
|
callId: params.msg.call_id ?? null,
|
|
changes: params.msg.changes ?? null,
|
|
}),
|
|
),
|
|
z.object({ method: z.literal("codex/event/patch_apply_begin"), params: z.unknown() }).transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({
|
|
method: z.literal("codex/event/patch_apply_end"),
|
|
params: CodexEventPatchApplyEndNotificationSchema,
|
|
})
|
|
.transform(
|
|
({ params }): ParsedCodexNotification => ({
|
|
kind: "patch_apply_completed",
|
|
callId: params.msg.call_id ?? null,
|
|
changes: params.msg.changes ?? null,
|
|
stdout: params.msg.stdout ?? null,
|
|
stderr: params.msg.stderr ?? null,
|
|
success: params.msg.success ?? null,
|
|
}),
|
|
),
|
|
z.object({ method: z.literal("codex/event/patch_apply_end"), params: z.unknown() }).transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({
|
|
method: z.literal("item/fileChange/outputDelta"),
|
|
params: ItemFileChangeOutputDeltaNotificationSchema,
|
|
})
|
|
.transform(
|
|
({ params }): ParsedCodexNotification => ({
|
|
kind: "file_change_output_delta",
|
|
itemId: params.itemId,
|
|
delta: params.delta ?? params.chunk ?? null,
|
|
}),
|
|
),
|
|
z.object({ method: z.literal("item/fileChange/outputDelta"), params: z.unknown() }).transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({
|
|
method: z.literal("codex/event/turn_diff"),
|
|
params: CodexEventTurnDiffNotificationSchema,
|
|
})
|
|
.transform(
|
|
({ params }): ParsedCodexNotification => ({
|
|
kind: "diff_updated",
|
|
diff: params.msg.unified_diff ?? params.msg.diff ?? "",
|
|
}),
|
|
),
|
|
z.object({ method: z.literal("codex/event/turn_diff"), params: z.unknown() }).transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({
|
|
method: z.literal("codex/event/turn_aborted"),
|
|
params: CodexEventTurnAbortedNotificationSchema,
|
|
})
|
|
.transform(
|
|
(): ParsedCodexNotification => ({
|
|
kind: "turn_completed",
|
|
status: "interrupted",
|
|
errorMessage: null,
|
|
}),
|
|
),
|
|
z.object({ method: z.literal("codex/event/turn_aborted"), params: z.unknown() }).transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({
|
|
method: z.literal("codex/event/task_complete"),
|
|
params: CodexEventTaskCompleteNotificationSchema,
|
|
})
|
|
.transform(
|
|
(): ParsedCodexNotification => ({
|
|
kind: "turn_completed",
|
|
status: "completed",
|
|
errorMessage: null,
|
|
}),
|
|
),
|
|
z.object({ method: z.literal("codex/event/task_complete"), params: z.unknown() }).transform(
|
|
({ method, params }): ParsedCodexNotification => ({
|
|
kind: "invalid_payload",
|
|
method,
|
|
params,
|
|
}),
|
|
),
|
|
z
|
|
.object({ method: z.string(), params: z.unknown() })
|
|
.transform(
|
|
({ method, params }): ParsedCodexNotification => ({ kind: "unknown_method", method, params }),
|
|
),
|
|
]);
|
|
|
|
async function writeImageAttachment(mimeType: string, data: string): Promise<string> {
|
|
const attachmentsDir = path.join(os.tmpdir(), CODEX_IMAGE_ATTACHMENT_DIR);
|
|
await fs.mkdir(attachmentsDir, { recursive: true });
|
|
const normalized = normalizeImageData(mimeType, data);
|
|
const extension = getImageExtension(normalized.mimeType);
|
|
const filename = `${randomUUID()}.${extension}`;
|
|
const filePath = path.join(attachmentsDir, filename);
|
|
await fs.writeFile(filePath, Buffer.from(normalized.data, "base64"));
|
|
return filePath;
|
|
}
|
|
|
|
async function readCodexConfiguredDefaults(
|
|
client: CodexAppServerClient,
|
|
logger: Logger,
|
|
): Promise<CodexConfiguredDefaults> {
|
|
let savedConfigDefaults: CodexConfiguredDefaults = {};
|
|
try {
|
|
const response = (await client.request("getUserSavedConfig", {})) as {
|
|
config?: {
|
|
model?: string | null;
|
|
modelReasoningEffort?: string | null;
|
|
};
|
|
};
|
|
savedConfigDefaults = {
|
|
model: normalizeCodexModelId(response?.config?.model),
|
|
thinkingOptionId: normalizeCodexThinkingOptionId(
|
|
response?.config?.modelReasoningEffort ?? null,
|
|
),
|
|
};
|
|
} catch (error) {
|
|
logger.debug({ error }, "Failed to read Codex saved config defaults");
|
|
}
|
|
|
|
if (savedConfigDefaults.model && savedConfigDefaults.thinkingOptionId) {
|
|
return savedConfigDefaults;
|
|
}
|
|
|
|
let configReadDefaults: CodexConfiguredDefaults = {};
|
|
try {
|
|
const response = (await client.request("config/read", {})) as {
|
|
config?: {
|
|
model?: string | null;
|
|
model_reasoning_effort?: string | null;
|
|
};
|
|
};
|
|
configReadDefaults = {
|
|
model: normalizeCodexModelId(response?.config?.model),
|
|
thinkingOptionId: normalizeCodexThinkingOptionId(
|
|
response?.config?.model_reasoning_effort ?? null,
|
|
),
|
|
};
|
|
} catch (error) {
|
|
logger.debug({ error }, "Failed to read Codex config defaults");
|
|
}
|
|
|
|
return mergeCodexConfiguredDefaults(savedConfigDefaults, configReadDefaults);
|
|
}
|
|
|
|
export async function codexAppServerTurnInputFromPrompt(
|
|
prompt: AgentPromptInput,
|
|
logger: Logger,
|
|
): Promise<unknown[]> {
|
|
if (typeof prompt === "string") {
|
|
return [{ type: "text", text: prompt }];
|
|
}
|
|
|
|
const blocks = prompt as Array<unknown>;
|
|
const output: unknown[] = [];
|
|
for (const block of blocks) {
|
|
if (!block || typeof block !== "object") {
|
|
output.push(block);
|
|
continue;
|
|
}
|
|
const record = block as { type?: unknown; mimeType?: unknown; data?: unknown };
|
|
if (
|
|
record.type === "image" &&
|
|
typeof record.mimeType === "string" &&
|
|
typeof record.data === "string"
|
|
) {
|
|
try {
|
|
const filePath = await writeImageAttachment(record.mimeType, record.data);
|
|
output.push({ type: "localImage", path: filePath });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
logger.warn({ message }, "Failed to write Codex image attachment");
|
|
output.push({
|
|
type: "text",
|
|
text: `User attached image (failed to write temp file): ${message}`,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
output.push(block);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function buildCodexAppServerEnv(
|
|
runtimeSettings?: ProviderRuntimeSettings,
|
|
launchEnv?: Record<string, string>,
|
|
): Record<string, string | undefined> {
|
|
const env = applyProviderEnv(process.env, runtimeSettings);
|
|
if (!launchEnv) {
|
|
return env;
|
|
}
|
|
return {
|
|
...env,
|
|
...launchEnv,
|
|
};
|
|
}
|
|
|
|
export const __codexAppServerInternals = {
|
|
buildCodexAppServerEnv,
|
|
mapCodexPatchNotificationToToolCall,
|
|
};
|
|
|
|
class CodexAppServerAgentSession implements AgentSession {
|
|
readonly provider = CODEX_PROVIDER;
|
|
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
|
|
|
|
private readonly logger: Logger;
|
|
private readonly config: AgentSessionConfig;
|
|
private currentMode: string;
|
|
private currentThreadId: string | null = null;
|
|
private currentTurnId: string | null = null;
|
|
private client: CodexAppServerClient | null = null;
|
|
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
|
private nextTurnOrdinal = 0;
|
|
private activeForegroundTurnId: string | null = null;
|
|
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
|
private historyPending = false;
|
|
private persistedHistory: AgentTimelineItem[] = [];
|
|
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
|
private pendingPermissionHandlers = new Map<
|
|
string,
|
|
{
|
|
resolve: (value: unknown) => void;
|
|
kind: "command" | "file" | "tool";
|
|
questions?: Array<{ id: string; options?: Array<{ label?: string; value?: string }> }>;
|
|
}
|
|
>();
|
|
private resolvedPermissionRequests = new Set<string>();
|
|
private pendingAgentMessages = new Map<string, string>();
|
|
private pendingReasoning = new Map<string, string[]>();
|
|
private pendingCommandOutputDeltas = new Map<string, string[]>();
|
|
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
|
|
private terminalCommandByProcessId = new Map<string, string>();
|
|
private pendingUnlabeledTerminalInteractions = new Set<string>();
|
|
private emittedTerminalInteractionKeys = new Set<string>();
|
|
private emittedExecCommandStartedCallIds = new Set<string>();
|
|
private emittedExecCommandCompletedCallIds = new Set<string>();
|
|
private emittedItemStartedIds = new Set<string>();
|
|
private emittedItemCompletedIds = new Set<string>();
|
|
private warnedUnknownNotificationMethods = new Set<string>();
|
|
private warnedInvalidNotificationPayloads = new Set<string>();
|
|
private warnedIncompleteEditToolCallIds = new Set<string>();
|
|
private latestUsage: AgentUsage | undefined;
|
|
private connected = false;
|
|
private collaborationModes: Array<{
|
|
name: string;
|
|
mode?: string | null;
|
|
model?: string | null;
|
|
reasoning_effort?: string | null;
|
|
developer_instructions?: string | null;
|
|
}> = [];
|
|
private resolvedCollaborationMode: {
|
|
mode: string;
|
|
settings: Record<string, unknown>;
|
|
name: string;
|
|
} | null = null;
|
|
private cachedSkills: Array<{ name: string; description: string; path: string }> = [];
|
|
|
|
constructor(
|
|
config: AgentSessionConfig,
|
|
private readonly resumeHandle: { sessionId: string; metadata?: Record<string, unknown> } | null,
|
|
logger: Logger,
|
|
private readonly spawnAppServer: () => ChildProcessWithoutNullStreams,
|
|
) {
|
|
this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER });
|
|
if (config.modeId === undefined) {
|
|
throw new Error("Codex agent requires modeId to be specified");
|
|
}
|
|
validateCodexMode(config.modeId);
|
|
this.currentMode = config.modeId;
|
|
this.config = config;
|
|
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
|
|
|
if (this.resumeHandle?.sessionId) {
|
|
this.currentThreadId = this.resumeHandle.sessionId;
|
|
this.historyPending = true;
|
|
}
|
|
}
|
|
|
|
get id(): string | null {
|
|
return this.currentThreadId;
|
|
}
|
|
|
|
async connect(): Promise<void> {
|
|
if (this.connected) return;
|
|
const child = this.spawnAppServer();
|
|
this.client = new CodexAppServerClient(child, this.logger);
|
|
this.client.setNotificationHandler((method, params) => this.handleNotification(method, params));
|
|
this.registerRequestHandlers();
|
|
|
|
await this.client.request("initialize", {
|
|
clientInfo: {
|
|
name: "paseo",
|
|
title: "Paseo",
|
|
version: "0.0.0",
|
|
},
|
|
});
|
|
this.client.notify("initialized", {});
|
|
|
|
await this.loadCollaborationModes();
|
|
await this.loadSkills();
|
|
|
|
if (this.currentThreadId) {
|
|
await this.loadPersistedHistory();
|
|
await this.ensureThreadLoaded();
|
|
}
|
|
|
|
this.connected = true;
|
|
}
|
|
|
|
private async loadCollaborationModes(): Promise<void> {
|
|
if (!this.client) return;
|
|
try {
|
|
const response = (await this.client.request("collaborationMode/list", {})) as {
|
|
data?: Array<any>;
|
|
};
|
|
const data = Array.isArray(response?.data) ? response.data : [];
|
|
this.collaborationModes = data.map((entry) => ({
|
|
name: String(entry.name ?? ""),
|
|
mode: entry.mode ?? null,
|
|
model: entry.model ?? null,
|
|
reasoning_effort: entry.reasoning_effort ?? null,
|
|
developer_instructions: entry.developer_instructions ?? null,
|
|
}));
|
|
} catch (error) {
|
|
this.logger.trace({ error }, "Failed to load collaboration modes");
|
|
this.collaborationModes = [];
|
|
}
|
|
this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode);
|
|
}
|
|
|
|
private async loadSkills(): Promise<void> {
|
|
if (!this.client) return;
|
|
try {
|
|
const response = (await this.client.request("skills/list", {
|
|
cwd: [this.config.cwd],
|
|
})) as { data?: Array<any> };
|
|
const entries = Array.isArray(response?.data) ? response.data : [];
|
|
const skills: Array<{ name: string; description: string; path: string }> = [];
|
|
for (const entry of entries) {
|
|
const list = Array.isArray(entry.skills) ? entry.skills : [];
|
|
for (const skill of list) {
|
|
if (!skill?.name || !skill?.path) continue;
|
|
skills.push({
|
|
name: skill.name,
|
|
description: skill.description ?? skill.shortDescription ?? "Skill",
|
|
path: skill.path,
|
|
});
|
|
}
|
|
}
|
|
this.cachedSkills = skills;
|
|
} catch (error) {
|
|
this.logger.trace({ error }, "Failed to load skills list");
|
|
this.cachedSkills = [];
|
|
}
|
|
}
|
|
|
|
private resolveCollaborationMode(
|
|
modeId: string,
|
|
): { mode: string; settings: Record<string, unknown>; name: string } | null {
|
|
if (this.collaborationModes.length === 0) return null;
|
|
const normalized = modeId.toLowerCase();
|
|
const findByName = (predicate: (name: string) => boolean) =>
|
|
this.collaborationModes.find((entry) => predicate(entry.name.toLowerCase()));
|
|
let match =
|
|
normalized === "read-only"
|
|
? findByName((name) => name.includes("read") || name.includes("plan"))
|
|
: normalized === "full-access"
|
|
? findByName((name) => name.includes("full") || name.includes("exec"))
|
|
: findByName((name) => name.includes("auto") || name.includes("code"));
|
|
if (!match) {
|
|
match = this.collaborationModes[0] ?? null;
|
|
}
|
|
if (!match) return null;
|
|
const settings: Record<string, unknown> = {};
|
|
if (match.model) settings.model = match.model;
|
|
if (match.reasoning_effort) settings.reasoning_effort = match.reasoning_effort;
|
|
const developerInstructions = [
|
|
match.developer_instructions?.trim(),
|
|
this.config.systemPrompt?.trim(),
|
|
]
|
|
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
|
|
.join("\n\n");
|
|
if (developerInstructions) settings.developer_instructions = developerInstructions;
|
|
if (this.config.model) settings.model = this.config.model;
|
|
const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
|
if (thinkingOptionId) settings.reasoning_effort = thinkingOptionId;
|
|
return { mode: match.mode ?? "code", settings, name: match.name };
|
|
}
|
|
|
|
private registerRequestHandlers(): void {
|
|
if (!this.client) return;
|
|
|
|
this.client.setRequestHandler("item/commandExecution/requestApproval", (params) =>
|
|
this.handleCommandApprovalRequest(params),
|
|
);
|
|
this.client.setRequestHandler("item/fileChange/requestApproval", (params) =>
|
|
this.handleFileChangeApprovalRequest(params),
|
|
);
|
|
this.client.setRequestHandler("tool/requestUserInput", (params) =>
|
|
this.handleToolApprovalRequest(params),
|
|
);
|
|
}
|
|
|
|
private async loadPersistedHistory(): Promise<void> {
|
|
if (!this.client || !this.currentThreadId) return;
|
|
try {
|
|
let rolloutTimeline: AgentTimelineItem[] = [];
|
|
try {
|
|
rolloutTimeline = await loadCodexPersistedTimeline(
|
|
this.currentThreadId,
|
|
undefined,
|
|
this.logger,
|
|
);
|
|
} catch {
|
|
rolloutTimeline = [];
|
|
}
|
|
|
|
const response = (await this.client.request("thread/read", {
|
|
threadId: this.currentThreadId,
|
|
includeTurns: true,
|
|
})) as { thread?: { turns?: Array<{ items?: any[] }> } };
|
|
const thread = response?.thread;
|
|
const threadTimeline: AgentTimelineItem[] = [];
|
|
if (thread && Array.isArray(thread.turns)) {
|
|
for (const turn of thread.turns) {
|
|
const items = Array.isArray(turn.items) ? turn.items : [];
|
|
for (const item of items) {
|
|
const timelineItem = threadItemToTimeline(item, {
|
|
cwd: this.config.cwd ?? null,
|
|
});
|
|
if (timelineItem) {
|
|
if (timelineItem.type === "tool_call") {
|
|
this.warnOnIncompleteEditToolCall(timelineItem, "thread_read", item);
|
|
}
|
|
threadTimeline.push(timelineItem);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const timeline = rolloutTimeline.length > 0 ? rolloutTimeline : threadTimeline;
|
|
|
|
if (timeline.length > 0) {
|
|
this.persistedHistory = timeline;
|
|
this.historyPending = true;
|
|
}
|
|
} catch (error) {
|
|
this.logger.warn({ error }, "Failed to load Codex thread history");
|
|
}
|
|
}
|
|
|
|
private async ensureThreadLoaded(): Promise<void> {
|
|
if (!this.client || !this.currentThreadId) return;
|
|
try {
|
|
const loaded = (await this.client.request("thread/loaded/list", {})) as { data?: string[] };
|
|
const ids = Array.isArray(loaded?.data) ? loaded.data : [];
|
|
if (ids.includes(this.currentThreadId)) {
|
|
return;
|
|
}
|
|
const params: Record<string, unknown> = { threadId: this.currentThreadId };
|
|
if (this.config.systemPrompt?.trim()) {
|
|
params.developerInstructions = this.config.systemPrompt.trim();
|
|
}
|
|
const codexConfig = this.buildCodexInnerConfig();
|
|
if (codexConfig) {
|
|
params.config = codexConfig;
|
|
}
|
|
await this.client.request("thread/resume", params);
|
|
} catch (error) {
|
|
this.logger.warn({ error }, "Failed to resume Codex thread, starting new thread");
|
|
this.currentThreadId = null;
|
|
await this.ensureThread();
|
|
}
|
|
}
|
|
|
|
private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null {
|
|
const trimmed = text.trim();
|
|
if (!trimmed.startsWith("/") || trimmed.length <= 1) {
|
|
return null;
|
|
}
|
|
const withoutPrefix = trimmed.slice(1);
|
|
const firstWhitespaceIdx = withoutPrefix.search(/\s/);
|
|
const commandName =
|
|
firstWhitespaceIdx === -1 ? withoutPrefix : withoutPrefix.slice(0, firstWhitespaceIdx);
|
|
if (!commandName || commandName.includes("/")) {
|
|
return null;
|
|
}
|
|
const rawArgs =
|
|
firstWhitespaceIdx === -1 ? "" : withoutPrefix.slice(firstWhitespaceIdx + 1).trim();
|
|
return rawArgs.length > 0 ? { commandName, args: rawArgs } : { commandName };
|
|
}
|
|
|
|
private async resolveSlashCommandInvocation(
|
|
prompt: AgentPromptInput,
|
|
): Promise<{ commandName: string; args?: string } | null> {
|
|
if (typeof prompt !== "string") {
|
|
return null;
|
|
}
|
|
const parsed = this.parseSlashCommandInput(prompt);
|
|
if (!parsed) {
|
|
return null;
|
|
}
|
|
try {
|
|
const commands = await this.listCommands();
|
|
return commands.some((command) => command.name === parsed.commandName) ? parsed : null;
|
|
} catch (error) {
|
|
this.logger.warn(
|
|
{ err: error, commandName: parsed.commandName },
|
|
"Failed to resolve slash command; falling back to plain prompt input",
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async buildCommandPromptInput(
|
|
commandName: string,
|
|
args?: string,
|
|
): Promise<AgentPromptInput> {
|
|
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);
|
|
return expandCodexCustomPrompt(parsed.body, args);
|
|
}
|
|
|
|
if (!this.connected) {
|
|
await this.connect();
|
|
} else {
|
|
await this.loadSkills();
|
|
}
|
|
const skill = this.cachedSkills.find((entry) => entry.name === commandName);
|
|
if (skill) {
|
|
const input = [
|
|
{ type: "skill", name: skill.name, path: skill.path },
|
|
] as unknown as AgentPromptContentBlock[];
|
|
if (args && args.trim().length > 0) {
|
|
input.push({ type: "text", text: args.trim() });
|
|
} else {
|
|
input.push({ type: "text", text: `$${skill.name}` });
|
|
}
|
|
return input;
|
|
}
|
|
|
|
return args ? `$${commandName} ${args}` : `$${commandName}`;
|
|
}
|
|
|
|
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
|
const timeline: AgentTimelineItem[] = [];
|
|
let finalText = "";
|
|
let usage: AgentUsage | undefined;
|
|
let turnId: string | null = null;
|
|
const bufferedEvents: AgentStreamEvent[] = [];
|
|
let settled = false;
|
|
let resolveCompletion!: () => void;
|
|
let rejectCompletion!: (error: Error) => void;
|
|
|
|
const processEvent = (event: AgentStreamEvent) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
const eventTurnId = (event as { turnId?: string }).turnId;
|
|
if (turnId && eventTurnId && eventTurnId !== turnId) {
|
|
return;
|
|
}
|
|
if (event.type === "timeline") {
|
|
timeline.push(event.item);
|
|
if (event.item.type === "assistant_message") {
|
|
finalText = event.item.text;
|
|
}
|
|
return;
|
|
}
|
|
if (event.type === "turn_completed") {
|
|
usage = event.usage;
|
|
settled = true;
|
|
resolveCompletion();
|
|
return;
|
|
}
|
|
if (event.type === "turn_failed") {
|
|
settled = true;
|
|
rejectCompletion(new Error(event.error));
|
|
return;
|
|
}
|
|
if (event.type === "turn_canceled") {
|
|
settled = true;
|
|
resolveCompletion();
|
|
}
|
|
};
|
|
|
|
const completion = new Promise<void>((resolve, reject) => {
|
|
resolveCompletion = resolve;
|
|
rejectCompletion = reject;
|
|
});
|
|
const unsubscribe = this.subscribe((event) => {
|
|
if (!turnId) {
|
|
bufferedEvents.push(event);
|
|
return;
|
|
}
|
|
processEvent(event);
|
|
});
|
|
|
|
try {
|
|
const result = await this.startTurn(prompt, options);
|
|
turnId = result.turnId;
|
|
for (const event of bufferedEvents) {
|
|
processEvent(event);
|
|
}
|
|
if (!settled) {
|
|
await completion;
|
|
}
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
|
|
const info = await this.getRuntimeInfo();
|
|
return {
|
|
sessionId: info.sessionId ?? "",
|
|
finalText,
|
|
usage,
|
|
timeline,
|
|
};
|
|
}
|
|
|
|
async startTurn(
|
|
prompt: AgentPromptInput,
|
|
options?: AgentRunOptions,
|
|
): Promise<{ turnId: string }> {
|
|
if (this.activeForegroundTurnId) {
|
|
throw new Error("A foreground turn is already active");
|
|
}
|
|
|
|
await this.connect();
|
|
if (!this.client) {
|
|
throw new Error("Codex client not initialized");
|
|
}
|
|
|
|
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
|
|
const effectivePrompt = slashCommand
|
|
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
|
|
: prompt;
|
|
|
|
if (this.currentThreadId) {
|
|
await this.ensureThreadLoaded();
|
|
} else {
|
|
await this.ensureThread();
|
|
}
|
|
|
|
const input = await this.buildUserInput(effectivePrompt);
|
|
const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
|
|
const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
|
|
const sandboxPolicyType = this.config.sandboxMode ?? preset.sandbox;
|
|
|
|
const params: Record<string, unknown> = {
|
|
threadId: this.currentThreadId,
|
|
input,
|
|
approvalPolicy,
|
|
sandboxPolicy: toSandboxPolicy(
|
|
sandboxPolicyType,
|
|
typeof this.config.networkAccess === "boolean"
|
|
? this.config.networkAccess
|
|
: preset.networkAccess,
|
|
),
|
|
};
|
|
|
|
if (this.config.model) {
|
|
params.model = this.config.model;
|
|
}
|
|
const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
|
if (thinkingOptionId) {
|
|
params.effort = thinkingOptionId;
|
|
}
|
|
if (this.resolvedCollaborationMode) {
|
|
params.collaborationMode = {
|
|
mode: this.resolvedCollaborationMode.mode,
|
|
settings: this.resolvedCollaborationMode.settings,
|
|
};
|
|
}
|
|
if (this.config.cwd) {
|
|
params.cwd = this.config.cwd;
|
|
}
|
|
if (options?.outputSchema) {
|
|
params.outputSchema = options.outputSchema;
|
|
}
|
|
if (this.config.systemPrompt?.trim()) {
|
|
params.developerInstructions = this.config.systemPrompt.trim();
|
|
}
|
|
const codexConfig = this.buildCodexInnerConfig();
|
|
if (codexConfig) {
|
|
params.config = codexConfig;
|
|
}
|
|
|
|
const turnId = this.createTurnId();
|
|
this.activeForegroundTurnId = turnId;
|
|
|
|
try {
|
|
await this.client.request("turn/start", params, TURN_START_TIMEOUT_MS);
|
|
} catch (error) {
|
|
this.activeForegroundTurnId = null;
|
|
throw error;
|
|
}
|
|
|
|
return { turnId };
|
|
}
|
|
|
|
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
|
this.subscribers.add(callback);
|
|
return () => {
|
|
this.subscribers.delete(callback);
|
|
};
|
|
}
|
|
|
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
|
if (!this.historyPending || this.persistedHistory.length === 0) {
|
|
return;
|
|
}
|
|
const history = this.persistedHistory;
|
|
this.persistedHistory = [];
|
|
this.historyPending = false;
|
|
for (const item of history) {
|
|
yield { type: "timeline", provider: CODEX_PROVIDER, item };
|
|
}
|
|
}
|
|
|
|
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
|
if (this.cachedRuntimeInfo) return { ...this.cachedRuntimeInfo };
|
|
if (!this.connected) {
|
|
await this.connect();
|
|
}
|
|
if (!this.currentThreadId) {
|
|
await this.ensureThread();
|
|
}
|
|
const info: AgentRuntimeInfo = {
|
|
provider: CODEX_PROVIDER,
|
|
sessionId: this.currentThreadId,
|
|
model: this.config.model ?? null,
|
|
thinkingOptionId: normalizeCodexThinkingOptionId(this.config.thinkingOptionId) ?? null,
|
|
modeId: this.currentMode ?? null,
|
|
extra: this.resolvedCollaborationMode
|
|
? { collaborationMode: this.resolvedCollaborationMode.name }
|
|
: undefined,
|
|
};
|
|
this.cachedRuntimeInfo = info;
|
|
return { ...info };
|
|
}
|
|
|
|
async getAvailableModes(): Promise<AgentMode[]> {
|
|
return CODEX_MODES;
|
|
}
|
|
|
|
async getCurrentMode(): Promise<string | null> {
|
|
return this.currentMode ?? null;
|
|
}
|
|
|
|
async setMode(modeId: string): Promise<void> {
|
|
validateCodexMode(modeId);
|
|
this.currentMode = modeId;
|
|
this.resolvedCollaborationMode = this.resolveCollaborationMode(modeId);
|
|
this.cachedRuntimeInfo = null;
|
|
}
|
|
|
|
async setModel(modelId: string | null): Promise<void> {
|
|
this.config.model = modelId ?? undefined;
|
|
this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode);
|
|
this.cachedRuntimeInfo = null;
|
|
}
|
|
|
|
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
|
|
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(thinkingOptionId);
|
|
this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode);
|
|
this.cachedRuntimeInfo = null;
|
|
}
|
|
|
|
getPendingPermissions(): AgentPermissionRequest[] {
|
|
return Array.from(this.pendingPermissions.values());
|
|
}
|
|
|
|
async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void> {
|
|
const pending = this.pendingPermissionHandlers.get(requestId);
|
|
if (!pending) {
|
|
throw new Error(`No pending Codex app-server permission request with id '${requestId}'`);
|
|
}
|
|
const pendingRequest = this.pendingPermissions.get(requestId) ?? null;
|
|
this.pendingPermissionHandlers.delete(requestId);
|
|
this.pendingPermissions.delete(requestId);
|
|
this.resolvedPermissionRequests.add(requestId);
|
|
|
|
if (response.behavior === "deny" && pendingRequest?.kind === "tool") {
|
|
const fallbackName =
|
|
pendingRequest.name === "CodexBash"
|
|
? "shell"
|
|
: pendingRequest.name === "CodexFileChange"
|
|
? "apply_patch"
|
|
: pendingRequest.name;
|
|
this.emitEvent({
|
|
type: "timeline",
|
|
provider: CODEX_PROVIDER,
|
|
item: {
|
|
type: "tool_call",
|
|
callId: requestId,
|
|
name: fallbackName,
|
|
status: "failed",
|
|
error: { message: response.message ?? "Permission denied" },
|
|
detail: pendingRequest.detail ?? {
|
|
type: "unknown",
|
|
input: pendingRequest.input ?? null,
|
|
output: null,
|
|
},
|
|
metadata: {
|
|
permissionRequestId: requestId,
|
|
denied: true,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
this.emitEvent({
|
|
type: "permission_resolved",
|
|
provider: CODEX_PROVIDER,
|
|
requestId,
|
|
resolution: response,
|
|
});
|
|
|
|
if (pending.kind === "command") {
|
|
const decision =
|
|
response.behavior === "allow" ? "accept" : response.interrupt ? "cancel" : "decline";
|
|
pending.resolve({ decision });
|
|
return;
|
|
}
|
|
|
|
if (pending.kind === "file") {
|
|
const decision =
|
|
response.behavior === "allow" ? "accept" : response.interrupt ? "cancel" : "decline";
|
|
pending.resolve({ decision });
|
|
return;
|
|
}
|
|
|
|
// tool/requestUserInput
|
|
const answers: Record<string, { answers: string[] }> = {};
|
|
const questions = pending.questions ?? [];
|
|
const decision =
|
|
response.behavior === "allow" ? "accept" : response.interrupt ? "cancel" : "decline";
|
|
for (const question of questions) {
|
|
let picked = decision;
|
|
const options = question.options ?? [];
|
|
if (options.length > 0) {
|
|
const byLabel = options.find((opt) => (opt.label ?? "").toLowerCase().includes(decision));
|
|
const byValue = options.find((opt) => (opt.value ?? "").toLowerCase().includes(decision));
|
|
const option = byLabel ?? byValue ?? options[0]!;
|
|
picked = option.value ?? option.label ?? decision;
|
|
}
|
|
answers[question.id] = { answers: [picked] };
|
|
}
|
|
if (questions.length === 0) {
|
|
answers["default"] = { answers: [decision] };
|
|
}
|
|
pending.resolve({ answers });
|
|
}
|
|
|
|
describePersistence(): {
|
|
provider: typeof CODEX_PROVIDER;
|
|
sessionId: string;
|
|
nativeHandle: string;
|
|
metadata: Record<string, unknown>;
|
|
} | null {
|
|
if (!this.currentThreadId) return null;
|
|
const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId) ?? null;
|
|
return {
|
|
provider: CODEX_PROVIDER,
|
|
sessionId: this.currentThreadId,
|
|
nativeHandle: this.currentThreadId,
|
|
metadata: {
|
|
provider: CODEX_PROVIDER,
|
|
cwd: this.config.cwd,
|
|
title: this.config.title ?? null,
|
|
threadId: this.currentThreadId,
|
|
modeId: this.currentMode,
|
|
model: this.config.model ?? null,
|
|
thinkingOptionId,
|
|
extra: this.config.extra,
|
|
systemPrompt: this.config.systemPrompt,
|
|
mcpServers: this.config.mcpServers,
|
|
},
|
|
};
|
|
}
|
|
|
|
async interrupt(): Promise<void> {
|
|
if (!this.client || !this.currentThreadId || !this.currentTurnId) return;
|
|
try {
|
|
await this.client.request("turn/interrupt", {
|
|
threadId: this.currentThreadId,
|
|
turnId: this.currentTurnId,
|
|
});
|
|
} catch (error) {
|
|
this.logger.warn({ error }, "Failed to interrupt Codex turn");
|
|
}
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
for (const pending of this.pendingPermissionHandlers.values()) {
|
|
pending.resolve({ decision: "cancel" });
|
|
}
|
|
this.pendingPermissionHandlers.clear();
|
|
this.pendingPermissions.clear();
|
|
this.resolvedPermissionRequests.clear();
|
|
this.subscribers.clear();
|
|
this.activeForegroundTurnId = null;
|
|
if (this.client) {
|
|
await this.client.dispose();
|
|
}
|
|
this.client = null;
|
|
this.connected = false;
|
|
this.currentThreadId = null;
|
|
this.currentTurnId = null;
|
|
}
|
|
|
|
async listCommands(): Promise<AgentSlashCommand[]> {
|
|
const prompts = await listCodexCustomPrompts();
|
|
if (!this.connected) {
|
|
await this.connect();
|
|
} else {
|
|
await this.loadSkills();
|
|
}
|
|
const appServerSkills = this.cachedSkills.map((skill) => ({
|
|
name: skill.name,
|
|
description: skill.description,
|
|
argumentHint: "",
|
|
}));
|
|
const fallbackSkills =
|
|
appServerSkills.length === 0 ? await listCodexSkills(this.config.cwd) : [];
|
|
return [...appServerSkills, ...fallbackSkills, ...prompts].sort((a, b) =>
|
|
a.name.localeCompare(b.name),
|
|
);
|
|
}
|
|
|
|
private async ensureThread(): Promise<void> {
|
|
if (!this.client) return;
|
|
if (this.currentThreadId) return;
|
|
|
|
// Resolve model + thinking defaults when omitted.
|
|
let configuredDefaults: CodexConfiguredDefaults = {};
|
|
let model = this.config.model;
|
|
let thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
|
if (!model || !thinkingOptionId) {
|
|
configuredDefaults = await readCodexConfiguredDefaults(this.client, this.logger);
|
|
}
|
|
if (!model) {
|
|
model = configuredDefaults.model;
|
|
}
|
|
if (!thinkingOptionId) {
|
|
thinkingOptionId = configuredDefaults.thinkingOptionId;
|
|
}
|
|
|
|
if (!model || !thinkingOptionId) {
|
|
const modelResponse = (await this.client.request("model/list", {})) as CodexModelListResponse;
|
|
const models = modelResponse?.data ?? [];
|
|
const defaultModel = models.find((m) => m.isDefault) ?? models[0];
|
|
if (!defaultModel) {
|
|
throw new Error("No models available from Codex app-server");
|
|
}
|
|
const selectedModel =
|
|
(model ? models.find((candidate) => candidate.id === model) : undefined) ?? defaultModel;
|
|
if (!model) {
|
|
model = selectedModel.id;
|
|
}
|
|
if (!thinkingOptionId) {
|
|
thinkingOptionId = normalizeCodexThinkingOptionId(selectedModel.defaultReasoningEffort);
|
|
}
|
|
}
|
|
|
|
this.config.model = model;
|
|
this.config.thinkingOptionId = thinkingOptionId;
|
|
|
|
const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
|
|
const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
|
|
const sandbox = this.config.sandboxMode ?? preset.sandbox;
|
|
const innerConfig = this.buildCodexInnerConfig();
|
|
const response = (await this.client.request("thread/start", {
|
|
model,
|
|
cwd: this.config.cwd ?? null,
|
|
approvalPolicy,
|
|
sandbox,
|
|
...(this.config.systemPrompt?.trim()
|
|
? { developerInstructions: this.config.systemPrompt.trim() }
|
|
: {}),
|
|
...(innerConfig ? { config: innerConfig } : {}),
|
|
})) as CodexThreadStartResponse;
|
|
const threadId = response?.thread?.id;
|
|
if (!threadId) {
|
|
throw new Error("Codex app-server did not return thread id");
|
|
}
|
|
this.currentThreadId = threadId;
|
|
}
|
|
|
|
private buildCodexInnerConfig(): Record<string, unknown> | null {
|
|
const innerConfig: Record<string, unknown> = {};
|
|
if (this.config.mcpServers) {
|
|
const mcpServers: Record<string, CodexMcpServerConfig> = {};
|
|
for (const [name, serverConfig] of Object.entries(this.config.mcpServers)) {
|
|
mcpServers[name] = toCodexMcpConfig(serverConfig);
|
|
}
|
|
innerConfig.mcp_servers = mcpServers;
|
|
}
|
|
if (this.config.extra?.codex) {
|
|
Object.assign(innerConfig, this.config.extra.codex);
|
|
}
|
|
return Object.keys(innerConfig).length > 0 ? innerConfig : null;
|
|
}
|
|
|
|
private async buildUserInput(prompt: AgentPromptInput): Promise<unknown[]> {
|
|
if (typeof prompt === "string") {
|
|
return [{ type: "text", text: prompt }];
|
|
}
|
|
const blocks = prompt as AgentPromptContentBlock[];
|
|
return await codexAppServerTurnInputFromPrompt(blocks, this.logger);
|
|
}
|
|
|
|
private emitEvent(event: AgentStreamEvent): void {
|
|
if (event.type === "timeline") {
|
|
if (event.item.type === "assistant_message") {
|
|
this.pendingAgentMessages.clear();
|
|
}
|
|
}
|
|
this.notifySubscribers(event);
|
|
}
|
|
|
|
private notifySubscribers(event: AgentStreamEvent): void {
|
|
const turnId = this.activeForegroundTurnId;
|
|
const tagged = turnId ? { ...event, turnId } : event;
|
|
for (const callback of this.subscribers) {
|
|
try {
|
|
callback(tagged);
|
|
} catch (error) {
|
|
this.logger.warn({ err: error }, "Subscriber callback threw");
|
|
}
|
|
}
|
|
}
|
|
|
|
private createTurnId(): string {
|
|
return `codex-turn-${this.nextTurnOrdinal++}`;
|
|
}
|
|
|
|
private handleNotification(method: string, params: unknown): void {
|
|
const parsed = CodexNotificationSchema.parse({ method, params });
|
|
|
|
if (parsed.kind === "thread_started") {
|
|
this.currentThreadId = parsed.threadId;
|
|
this.emitEvent({
|
|
type: "thread_started",
|
|
provider: CODEX_PROVIDER,
|
|
sessionId: parsed.threadId,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "turn_started") {
|
|
this.currentTurnId = parsed.turnId;
|
|
this.emittedItemStartedIds.clear();
|
|
this.emittedItemCompletedIds.clear();
|
|
this.emittedExecCommandStartedCallIds.clear();
|
|
this.emittedExecCommandCompletedCallIds.clear();
|
|
this.pendingCommandOutputDeltas.clear();
|
|
this.pendingFileChangeOutputDeltas.clear();
|
|
this.warnedIncompleteEditToolCallIds.clear();
|
|
this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER });
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "turn_completed") {
|
|
if (parsed.status === "failed") {
|
|
this.emitEvent({
|
|
type: "turn_failed",
|
|
provider: CODEX_PROVIDER,
|
|
error: parsed.errorMessage ?? "Codex turn failed",
|
|
});
|
|
} else if (parsed.status === "interrupted") {
|
|
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
|
|
} else {
|
|
this.emitEvent({
|
|
type: "turn_completed",
|
|
provider: CODEX_PROVIDER,
|
|
usage: this.latestUsage,
|
|
});
|
|
}
|
|
this.activeForegroundTurnId = null;
|
|
this.emittedItemStartedIds.clear();
|
|
this.emittedItemCompletedIds.clear();
|
|
this.emittedExecCommandStartedCallIds.clear();
|
|
this.emittedExecCommandCompletedCallIds.clear();
|
|
this.pendingCommandOutputDeltas.clear();
|
|
this.pendingFileChangeOutputDeltas.clear();
|
|
this.warnedIncompleteEditToolCallIds.clear();
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "plan_updated") {
|
|
const items = planStepsToTodoItems(
|
|
parsed.plan.map((entry) => ({
|
|
step: entry.step ?? "",
|
|
status: entry.status ?? "pending",
|
|
})),
|
|
);
|
|
this.emitEvent({
|
|
type: "timeline",
|
|
provider: CODEX_PROVIDER,
|
|
item: { type: "todo", items },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "diff_updated") {
|
|
// NOTE: Codex app-server emits frequent `turn/diff/updated` notifications
|
|
// containing a full accumulated unified diff for the *entire turn*.
|
|
// This is not a concrete file-change tool call; it is progress telemetry.
|
|
// We intentionally do NOT store every diff update in the timeline.
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "token_usage_updated") {
|
|
this.latestUsage = toAgentUsage(parsed.tokenUsage);
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "agent_message_delta") {
|
|
const prev = this.pendingAgentMessages.get(parsed.itemId) ?? "";
|
|
this.pendingAgentMessages.set(parsed.itemId, prev + parsed.delta);
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "reasoning_delta") {
|
|
const prev = this.pendingReasoning.get(parsed.itemId) ?? [];
|
|
prev.push(parsed.delta);
|
|
this.pendingReasoning.set(parsed.itemId, prev);
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "exec_command_output_delta") {
|
|
this.appendOutputDeltaChunk(this.pendingCommandOutputDeltas, parsed.callId, parsed.chunk, {
|
|
decodeBase64: true,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "file_change_output_delta") {
|
|
this.appendOutputDeltaChunk(this.pendingFileChangeOutputDeltas, parsed.itemId, parsed.delta);
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "exec_command_started") {
|
|
if (parsed.callId) {
|
|
this.emittedExecCommandStartedCallIds.add(parsed.callId);
|
|
this.pendingCommandOutputDeltas.delete(parsed.callId);
|
|
}
|
|
const timelineItem = mapCodexExecNotificationToToolCall({
|
|
callId: parsed.callId,
|
|
command: parsed.command,
|
|
cwd: parsed.cwd ?? this.config.cwd ?? null,
|
|
running: true,
|
|
});
|
|
if (timelineItem) {
|
|
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "exec_command_completed") {
|
|
const bufferedOutput = this.consumeOutputDelta(
|
|
this.pendingCommandOutputDeltas,
|
|
parsed.callId,
|
|
);
|
|
const resolvedOutput = parsed.output ?? bufferedOutput;
|
|
this.rememberTerminalProcessForCommand(parsed.command, resolvedOutput);
|
|
const timelineItem = mapCodexExecNotificationToToolCall({
|
|
callId: parsed.callId,
|
|
command: parsed.command,
|
|
cwd: parsed.cwd ?? this.config.cwd ?? null,
|
|
output: resolvedOutput,
|
|
exitCode: parsed.exitCode,
|
|
success: parsed.success,
|
|
stderr: parsed.stderr,
|
|
running: false,
|
|
});
|
|
if (timelineItem) {
|
|
this.emittedExecCommandCompletedCallIds.add(timelineItem.callId);
|
|
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "terminal_interaction") {
|
|
const interactionKey = [parsed.processId ?? "", parsed.stdin ?? ""].join("\u0000");
|
|
if (!this.shouldEmitTerminalInteractionKey(interactionKey)) {
|
|
return;
|
|
}
|
|
const command =
|
|
(parsed.processId ? this.terminalCommandByProcessId.get(parsed.processId) : undefined) ??
|
|
null;
|
|
if (!command && parsed.processId) {
|
|
this.pendingUnlabeledTerminalInteractions.add(parsed.processId);
|
|
}
|
|
const timelineItem = mapCodexTerminalInteractionToToolCall({
|
|
processId: parsed.processId,
|
|
fallbackCallId: parsed.callId,
|
|
command,
|
|
});
|
|
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "patch_apply_started") {
|
|
if (parsed.callId) {
|
|
this.pendingFileChangeOutputDeltas.delete(parsed.callId);
|
|
}
|
|
const timelineItem = mapCodexPatchNotificationToToolCall({
|
|
callId: parsed.callId,
|
|
changes: parsed.changes,
|
|
cwd: this.config.cwd ?? null,
|
|
running: true,
|
|
});
|
|
if (timelineItem) {
|
|
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_started", {
|
|
callId: parsed.callId,
|
|
changes: parsed.changes,
|
|
});
|
|
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "patch_apply_completed") {
|
|
const bufferedOutput = this.consumeOutputDelta(
|
|
this.pendingFileChangeOutputDeltas,
|
|
parsed.callId,
|
|
);
|
|
const timelineItem = mapCodexPatchNotificationToToolCall({
|
|
callId: parsed.callId,
|
|
changes: parsed.changes,
|
|
cwd: this.config.cwd ?? null,
|
|
stdout: parsed.stdout ?? bufferedOutput,
|
|
stderr: parsed.stderr,
|
|
success: parsed.success,
|
|
running: false,
|
|
});
|
|
if (timelineItem) {
|
|
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_completed", {
|
|
callId: parsed.callId,
|
|
changes: parsed.changes,
|
|
stdout: parsed.stdout,
|
|
});
|
|
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "item_completed") {
|
|
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
|
|
// and canonical `item/*`. We render only the canonical channel to avoid
|
|
// duplicated assistant/reasoning rows.
|
|
if (parsed.source === "codex_event") {
|
|
return;
|
|
}
|
|
const timelineItem = threadItemToTimeline(parsed.item, {
|
|
includeUserMessage: false,
|
|
cwd: this.config.cwd ?? null,
|
|
});
|
|
if (timelineItem) {
|
|
const normalizedItemType = normalizeCodexThreadItemType(
|
|
typeof parsed.item.type === "string" ? parsed.item.type : undefined,
|
|
);
|
|
const itemId = parsed.item.id;
|
|
// For commandExecution items, codex/event/exec_command_* is authoritative.
|
|
// Keep item/completed as fallback only when no exec_command completion was seen.
|
|
if (timelineItem.type === "tool_call" && normalizedItemType === "commandExecution") {
|
|
const callId = timelineItem.callId || itemId;
|
|
if (callId && this.emittedExecCommandCompletedCallIds.has(callId)) {
|
|
return;
|
|
}
|
|
}
|
|
if (itemId && this.emittedItemCompletedIds.has(itemId)) {
|
|
return;
|
|
}
|
|
if (timelineItem.type === "assistant_message" && itemId) {
|
|
const buffered = this.pendingAgentMessages.get(itemId);
|
|
if (buffered && buffered.length > 0) {
|
|
timelineItem.text = buffered;
|
|
}
|
|
}
|
|
if (timelineItem.type === "reasoning" && itemId) {
|
|
const buffered = this.pendingReasoning.get(itemId);
|
|
if (buffered && buffered.length > 0) {
|
|
timelineItem.text = buffered.join("");
|
|
}
|
|
}
|
|
if (timelineItem.type === "tool_call") {
|
|
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
|
|
}
|
|
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
|
if (itemId) {
|
|
this.emittedItemCompletedIds.add(itemId);
|
|
this.emittedItemStartedIds.delete(itemId);
|
|
this.pendingCommandOutputDeltas.delete(itemId);
|
|
this.pendingFileChangeOutputDeltas.delete(itemId);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "item_started") {
|
|
if (parsed.source === "codex_event") {
|
|
return;
|
|
}
|
|
const timelineItem = threadItemToTimeline(parsed.item, {
|
|
includeUserMessage: false,
|
|
cwd: this.config.cwd ?? null,
|
|
});
|
|
if (timelineItem && timelineItem.type === "tool_call") {
|
|
const normalizedItemType = normalizeCodexThreadItemType(
|
|
typeof parsed.item.type === "string" ? parsed.item.type : undefined,
|
|
);
|
|
const itemId = parsed.item.id;
|
|
if (normalizedItemType === "commandExecution") {
|
|
const callId = timelineItem.callId || itemId;
|
|
if (callId && this.emittedExecCommandStartedCallIds.has(callId)) {
|
|
return;
|
|
}
|
|
}
|
|
if (itemId && this.emittedItemStartedIds.has(itemId)) {
|
|
return;
|
|
}
|
|
this.warnOnIncompleteEditToolCall(timelineItem, "item_started", parsed.item);
|
|
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
|
if (itemId) {
|
|
this.emittedItemStartedIds.add(itemId);
|
|
this.pendingCommandOutputDeltas.delete(itemId);
|
|
this.pendingFileChangeOutputDeltas.delete(itemId);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "invalid_payload") {
|
|
this.warnInvalidNotificationPayload(parsed.method, parsed.params);
|
|
return;
|
|
}
|
|
|
|
this.warnUnknownNotificationMethod(parsed.method, parsed.params);
|
|
}
|
|
|
|
private warnUnknownNotificationMethod(method: string, params: unknown): void {
|
|
if (this.warnedUnknownNotificationMethods.has(method)) {
|
|
return;
|
|
}
|
|
this.warnedUnknownNotificationMethods.add(method);
|
|
this.logger.trace({ method, params }, "Unhandled Codex app-server notification method");
|
|
}
|
|
|
|
private warnInvalidNotificationPayload(method: string, params: unknown): void {
|
|
const key = method;
|
|
if (this.warnedInvalidNotificationPayloads.has(key)) {
|
|
return;
|
|
}
|
|
this.warnedInvalidNotificationPayloads.add(key);
|
|
this.logger.warn({ method, params }, "Invalid Codex app-server notification payload");
|
|
}
|
|
|
|
private appendOutputDeltaChunk(
|
|
store: Map<string, string[]>,
|
|
id: string | null | undefined,
|
|
chunk: string | null | undefined,
|
|
options?: { decodeBase64?: boolean },
|
|
): void {
|
|
if (!id || !chunk) {
|
|
return;
|
|
}
|
|
const normalized = options?.decodeBase64 ? decodeCodexOutputDeltaChunk(chunk) : chunk;
|
|
if (!normalized.length) {
|
|
return;
|
|
}
|
|
const prev = store.get(id) ?? [];
|
|
prev.push(normalized);
|
|
store.set(id, prev);
|
|
}
|
|
|
|
private consumeOutputDelta(
|
|
store: Map<string, string[]>,
|
|
id: string | null | undefined,
|
|
): string | null {
|
|
if (!id) {
|
|
return null;
|
|
}
|
|
const buffered = store.get(id);
|
|
if (!buffered || buffered.length === 0) {
|
|
return null;
|
|
}
|
|
store.delete(id);
|
|
return buffered.join("");
|
|
}
|
|
|
|
private rememberTerminalProcessForCommand(command: unknown, output: string | null): void {
|
|
const normalizedCommand = normalizeCodexCommandValue(command);
|
|
if (!normalizedCommand) {
|
|
return;
|
|
}
|
|
const displayCommand =
|
|
typeof normalizedCommand === "string"
|
|
? normalizedCommand
|
|
: normalizedCommand.join(" ").trim();
|
|
if (!displayCommand) {
|
|
return;
|
|
}
|
|
const processId = extractCodexTerminalSessionId(output ?? undefined);
|
|
if (!processId) {
|
|
return;
|
|
}
|
|
this.terminalCommandByProcessId.set(processId, displayCommand);
|
|
if (!this.pendingUnlabeledTerminalInteractions.has(processId)) {
|
|
return;
|
|
}
|
|
this.pendingUnlabeledTerminalInteractions.delete(processId);
|
|
this.emitEvent({
|
|
type: "timeline",
|
|
provider: CODEX_PROVIDER,
|
|
item: mapCodexTerminalInteractionToToolCall({
|
|
processId,
|
|
command: displayCommand,
|
|
}),
|
|
});
|
|
}
|
|
|
|
private shouldEmitTerminalInteractionKey(key: string): boolean {
|
|
if (this.emittedTerminalInteractionKeys.has(key)) {
|
|
return false;
|
|
}
|
|
this.emittedTerminalInteractionKeys.add(key);
|
|
return true;
|
|
}
|
|
|
|
private warnOnIncompleteEditToolCall(
|
|
item: ToolCallTimelineItem,
|
|
source: string,
|
|
payload: unknown,
|
|
): void {
|
|
if (!isEditToolCallWithoutContent(item)) {
|
|
return;
|
|
}
|
|
const warnKey = `${source}:${item.callId}`;
|
|
if (this.warnedIncompleteEditToolCallIds.has(warnKey)) {
|
|
return;
|
|
}
|
|
this.warnedIncompleteEditToolCallIds.add(warnKey);
|
|
this.logger.warn(
|
|
{
|
|
source,
|
|
callId: item.callId,
|
|
status: item.status,
|
|
name: item.name,
|
|
detail: item.detail,
|
|
payload,
|
|
},
|
|
"Codex edit tool call is missing diff/content fields",
|
|
);
|
|
}
|
|
|
|
private handleCommandApprovalRequest(params: unknown): Promise<unknown> {
|
|
const parsed = params as {
|
|
itemId: string;
|
|
threadId: string;
|
|
turnId: string;
|
|
command?: string | null;
|
|
cwd?: string | null;
|
|
reason?: string | null;
|
|
};
|
|
const commandPreview = mapCodexExecNotificationToToolCall({
|
|
callId: parsed.itemId,
|
|
command: parsed.command,
|
|
cwd: parsed.cwd ?? this.config.cwd ?? null,
|
|
running: true,
|
|
});
|
|
const requestId = `permission-${parsed.itemId}`;
|
|
const title = parsed.command ? `Run command: ${parsed.command}` : "Run command";
|
|
const request: AgentPermissionRequest = {
|
|
id: requestId,
|
|
provider: CODEX_PROVIDER,
|
|
name: "CodexBash",
|
|
kind: "tool",
|
|
title,
|
|
description: parsed.reason ?? undefined,
|
|
input: {
|
|
command: parsed.command ?? undefined,
|
|
cwd: parsed.cwd ?? undefined,
|
|
},
|
|
detail: commandPreview?.detail ?? {
|
|
type: "unknown",
|
|
input: {
|
|
command: parsed.command ?? null,
|
|
cwd: parsed.cwd ?? null,
|
|
},
|
|
output: null,
|
|
},
|
|
metadata: {
|
|
itemId: parsed.itemId,
|
|
threadId: parsed.threadId,
|
|
turnId: parsed.turnId,
|
|
},
|
|
};
|
|
this.pendingPermissions.set(requestId, request);
|
|
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
|
|
return new Promise((resolve) => {
|
|
this.pendingPermissionHandlers.set(requestId, { resolve, kind: "command" });
|
|
});
|
|
}
|
|
|
|
private handleFileChangeApprovalRequest(params: unknown): Promise<unknown> {
|
|
const parsed = params as {
|
|
itemId: string;
|
|
threadId: string;
|
|
turnId: string;
|
|
reason?: string | null;
|
|
};
|
|
const requestId = `permission-${parsed.itemId}`;
|
|
const request: AgentPermissionRequest = {
|
|
id: requestId,
|
|
provider: CODEX_PROVIDER,
|
|
name: "CodexFileChange",
|
|
kind: "tool",
|
|
title: "Apply file changes",
|
|
description: parsed.reason ?? undefined,
|
|
detail: {
|
|
type: "unknown",
|
|
input: {
|
|
reason: parsed.reason ?? null,
|
|
},
|
|
output: null,
|
|
},
|
|
metadata: {
|
|
itemId: parsed.itemId,
|
|
threadId: parsed.threadId,
|
|
turnId: parsed.turnId,
|
|
},
|
|
};
|
|
this.pendingPermissions.set(requestId, request);
|
|
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
|
|
return new Promise((resolve) => {
|
|
this.pendingPermissionHandlers.set(requestId, { resolve, kind: "file" });
|
|
});
|
|
}
|
|
|
|
private handleToolApprovalRequest(params: unknown): Promise<unknown> {
|
|
const parsed = params as { itemId: string; threadId: string; turnId: string; questions: any[] };
|
|
const requestId = `permission-${parsed.itemId}`;
|
|
const request: AgentPermissionRequest = {
|
|
id: requestId,
|
|
provider: CODEX_PROVIDER,
|
|
name: "CodexTool",
|
|
kind: "tool",
|
|
title: "Tool action requires approval",
|
|
description: undefined,
|
|
detail: {
|
|
type: "unknown",
|
|
input: {
|
|
questions: Array.isArray(parsed.questions) ? parsed.questions : [],
|
|
},
|
|
output: null,
|
|
},
|
|
metadata: {
|
|
itemId: parsed.itemId,
|
|
threadId: parsed.threadId,
|
|
turnId: parsed.turnId,
|
|
questions: parsed.questions,
|
|
},
|
|
};
|
|
this.pendingPermissions.set(requestId, request);
|
|
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
|
|
return new Promise((resolve) => {
|
|
this.pendingPermissionHandlers.set(requestId, {
|
|
resolve,
|
|
kind: "tool",
|
|
questions: Array.isArray(parsed.questions) ? parsed.questions : [],
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
export class CodexAppServerAgentClient implements AgentClient {
|
|
readonly provider = CODEX_PROVIDER;
|
|
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
|
|
|
|
constructor(
|
|
private readonly logger: Logger,
|
|
private readonly runtimeSettings?: ProviderRuntimeSettings,
|
|
) {}
|
|
|
|
private spawnAppServer(launchEnv?: Record<string, string>): ChildProcessWithoutNullStreams {
|
|
const launchPrefix = resolveCodexLaunchPrefix(this.runtimeSettings);
|
|
this.logger.trace(
|
|
{
|
|
launchPrefix,
|
|
},
|
|
"Spawning Codex app server",
|
|
);
|
|
return spawn(launchPrefix.command, [...launchPrefix.args, "app-server"], {
|
|
detached: process.platform !== "win32",
|
|
shell: process.platform === "win32",
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
env: buildCodexAppServerEnv(this.runtimeSettings, launchEnv),
|
|
});
|
|
}
|
|
|
|
async createSession(
|
|
config: AgentSessionConfig,
|
|
launchContext?: AgentLaunchContext,
|
|
): Promise<AgentSession> {
|
|
const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER };
|
|
const session = new CodexAppServerAgentSession(sessionConfig, null, this.logger, () =>
|
|
this.spawnAppServer(launchContext?.env),
|
|
);
|
|
await session.connect();
|
|
return session;
|
|
}
|
|
|
|
async resumeSession(
|
|
handle: { sessionId: string; metadata?: Record<string, unknown> },
|
|
overrides?: Partial<AgentSessionConfig>,
|
|
launchContext?: AgentLaunchContext,
|
|
): Promise<AgentSession> {
|
|
const storedConfig = (handle.metadata ?? {}) as AgentSessionConfig;
|
|
const merged: AgentSessionConfig = {
|
|
...storedConfig,
|
|
...overrides,
|
|
provider: CODEX_PROVIDER,
|
|
cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(),
|
|
};
|
|
const session = new CodexAppServerAgentSession(merged, handle, this.logger, () =>
|
|
this.spawnAppServer(launchContext?.env),
|
|
);
|
|
await session.connect();
|
|
return session;
|
|
}
|
|
|
|
async listPersistedAgents(
|
|
options?: ListPersistedAgentsOptions,
|
|
): Promise<PersistedAgentDescriptor[]> {
|
|
const child = this.spawnAppServer();
|
|
const client = new CodexAppServerClient(child, this.logger);
|
|
|
|
try {
|
|
await client.request("initialize", {
|
|
clientInfo: { name: "paseo", title: "Paseo", version: "0.0.0" },
|
|
});
|
|
client.notify("initialized", {});
|
|
|
|
const limit = options?.limit ?? 20;
|
|
const response = (await client.request("thread/list", { limit })) as {
|
|
data?: Array<any>;
|
|
};
|
|
const threads = Array.isArray(response?.data) ? response.data : [];
|
|
const descriptors: PersistedAgentDescriptor[] = [];
|
|
|
|
for (const thread of threads.slice(0, limit)) {
|
|
const threadId = thread.id;
|
|
const cwd = thread.cwd ?? process.cwd();
|
|
const title = thread.preview ?? null;
|
|
let timeline: AgentTimelineItem[] = [];
|
|
try {
|
|
const rolloutTimeline = await loadCodexPersistedTimeline(
|
|
threadId,
|
|
undefined,
|
|
this.logger,
|
|
);
|
|
const read = (await client.request("thread/read", {
|
|
threadId,
|
|
includeTurns: true,
|
|
})) as { thread?: { turns?: Array<{ items?: any[] }> } };
|
|
const turns = read.thread?.turns ?? [];
|
|
const itemsFromThreadRead: AgentTimelineItem[] = [];
|
|
for (const turn of turns) {
|
|
for (const item of turn.items ?? []) {
|
|
const timelineItem = threadItemToTimeline(item, { cwd });
|
|
if (timelineItem) itemsFromThreadRead.push(timelineItem);
|
|
}
|
|
}
|
|
timeline = rolloutTimeline.length > 0 ? rolloutTimeline : itemsFromThreadRead;
|
|
} catch {
|
|
timeline = [];
|
|
}
|
|
|
|
descriptors.push({
|
|
provider: CODEX_PROVIDER,
|
|
sessionId: threadId,
|
|
cwd,
|
|
title,
|
|
lastActivityAt: new Date((thread.updatedAt ?? thread.createdAt ?? 0) * 1000),
|
|
persistence: {
|
|
provider: CODEX_PROVIDER,
|
|
sessionId: threadId,
|
|
nativeHandle: threadId,
|
|
metadata: {
|
|
provider: CODEX_PROVIDER,
|
|
cwd,
|
|
title,
|
|
threadId,
|
|
},
|
|
},
|
|
timeline,
|
|
});
|
|
}
|
|
|
|
return descriptors;
|
|
} finally {
|
|
await client.dispose();
|
|
}
|
|
}
|
|
|
|
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
|
const child = this.spawnAppServer();
|
|
const client = new CodexAppServerClient(child, this.logger);
|
|
|
|
try {
|
|
await client.request("initialize", {
|
|
clientInfo: {
|
|
name: "paseo",
|
|
title: "Paseo",
|
|
version: "0.0.0",
|
|
},
|
|
});
|
|
client.notify("initialized", {});
|
|
|
|
const response = (await client.request("model/list", {})) as { data?: Array<any> };
|
|
const models = Array.isArray(response?.data) ? response.data : [];
|
|
const configuredDefaults = await readCodexConfiguredDefaults(client, this.logger);
|
|
const configuredDefaultModelId = configuredDefaults.model;
|
|
const configuredDefaultThinkingOptionId = configuredDefaults.thinkingOptionId;
|
|
const hasConfiguredDefaultModel =
|
|
typeof configuredDefaultModelId === "string"
|
|
? models.some((model) => model?.id === configuredDefaultModelId)
|
|
: false;
|
|
return models.map((model) => {
|
|
const defaultReasoningEffort = normalizeCodexThinkingOptionId(
|
|
typeof model.defaultReasoningEffort === "string" ? model.defaultReasoningEffort : null,
|
|
);
|
|
const resolvedDefaultReasoningEffort =
|
|
configuredDefaultThinkingOptionId ?? defaultReasoningEffort;
|
|
|
|
const thinkingById = new Map<string, { id: string; label: string; description?: string }>();
|
|
if (Array.isArray(model.supportedReasoningEfforts)) {
|
|
for (const entry of model.supportedReasoningEfforts) {
|
|
const id = normalizeCodexThinkingOptionId(
|
|
typeof entry?.reasoningEffort === "string" ? entry.reasoningEffort : null,
|
|
);
|
|
if (!id) continue;
|
|
const description =
|
|
typeof entry?.description === "string" && entry.description.trim().length > 0
|
|
? entry.description
|
|
: undefined;
|
|
thinkingById.set(id, { id, label: id, description });
|
|
}
|
|
}
|
|
|
|
if (resolvedDefaultReasoningEffort && !thinkingById.has(resolvedDefaultReasoningEffort)) {
|
|
thinkingById.set(resolvedDefaultReasoningEffort, {
|
|
id: resolvedDefaultReasoningEffort,
|
|
label: resolvedDefaultReasoningEffort,
|
|
description:
|
|
configuredDefaultThinkingOptionId === resolvedDefaultReasoningEffort
|
|
? "Configured default reasoning effort"
|
|
: "Model default reasoning effort",
|
|
});
|
|
}
|
|
|
|
const thinkingOptions = Array.from(thinkingById.values()).map((option) => ({
|
|
...option,
|
|
isDefault: option.id === resolvedDefaultReasoningEffort,
|
|
}));
|
|
const defaultThinkingOptionId =
|
|
resolvedDefaultReasoningEffort ??
|
|
thinkingOptions.find((option) => option.isDefault)?.id ??
|
|
thinkingOptions[0]?.id;
|
|
const isDefaultModel = hasConfiguredDefaultModel
|
|
? model.id === configuredDefaultModelId
|
|
: model.isDefault;
|
|
|
|
return {
|
|
provider: CODEX_PROVIDER,
|
|
id: model.id,
|
|
label: normalizeCodexModelLabel(model.displayName),
|
|
description: model.description,
|
|
isDefault: isDefaultModel,
|
|
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
|
|
defaultThinkingOptionId,
|
|
metadata: {
|
|
model: model.model,
|
|
defaultReasoningEffort: model.defaultReasoningEffort,
|
|
supportedReasoningEfforts: model.supportedReasoningEfforts,
|
|
},
|
|
};
|
|
});
|
|
} finally {
|
|
await client.dispose();
|
|
}
|
|
}
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
const command = this.runtimeSettings?.command;
|
|
if (command?.mode === "replace") {
|
|
return existsSync(command.argv[0]);
|
|
}
|
|
return true;
|
|
}
|
|
}
|