diff --git a/codex-resume-investigation.md b/codex-resume-investigation.md
new file mode 100644
index 000000000..9067b4426
--- /dev/null
+++ b/codex-resume-investigation.md
@@ -0,0 +1,29 @@
+# Codex ACP Session Persistence & Resume Investigation
+
+## Summary
+- PR [#66](https://github.com/zed-industries/codex-acp/pull/66) adds real session persistence to `@zed-industries/codex-acp`: CLI flags/env vars enable it, and each session is persisted in `${CODEX_HOME}/sessions/manifest.jsonl` with rollout paths, MCP servers, cwd, approval/mode settings, and timestamps. `loadSession` pulls that manifest entry, restores MCP config, and calls `ConversationManager::resume_conversation_from_rollout` with the saved rollout JSONL. Internal state resumes correctly.
+- The ACP protocol surface still only returns mode/model selectors during `loadSession` (`codex-acp/src/conversation.rs:1585-1650`). Historical chat updates are **not** replayed: `ConversationActor::handle_load` never emits prior `SessionUpdate`s, and the event dispatcher drops any replay stream because there’s no active submission entry to consume it (`codex-acp/src/conversation.rs:1830-1840`). Clients are expected to have stored the transcript themselves.
+- Our voice-dev server currently treats Codex as non-resumable (`supportsSessionPersistence: false` in `packages/server/src/server/acp/agent-types.ts`). `AgentManager` only attempts `loadSession` for Claude agents, so Codex sessions that the app starts get lost once the daemon dies. Agents launched outside the app aren’t tracked anywhere either.
+- For CloudCode we solved this by forking the ACP package to replay history and by persisting agent metadata (session ids, rollout paths) in `agents.json`. Codex’s manifest already contains the same data; we can read it after each `newSession` and store it alongside the agent entry. On restart we can resume by calling `/session/load` with that data and replaying the rollout ourselves to rebuild UI history.
+
+## Key Details
+- **CLI & Manifest (PR #66)**
+ - `codex-acp/src/cli.rs` parses `--session-persist`, `--session-persist=
`, `--no-session-persist`, and the env vars `CODEX_SESSION_PERSIST`/`CODEX_SESSION_DIR`.
+ - `SessionPersistCli` feeds into `SessionPersistenceSettings::resolve`, defaulting to `${CODEX_HOME}/sessions` (`codex-acp/src/session_store.rs:55-94`).
+ - `SessionRecord` captures session id, conversation id, rollout path, cwd, MCP server definitions (including HTTP headers), model/mode ids, approval/sandbox policies, reasoning effort, and timestamps (`codex-acp/src/session_store.rs:21-90`). `SessionStore` writes JSONL manifest entries and exposes `record_session`, `update_model`, `update_mode`, etc.
+- **Resume Flow**
+ - When a new session is created, Codex ACP records a manifest entry before returning (`codex-acp/src/codex_agent.rs:373-414`).
+ - On resume, Codex ACP reads the manifest, clones the saved MCP config/cwd, feeds it to `ConversationManager::resume_conversation_from_rollout`, and updates the manifest (`codex-acp/src/codex_agent.rs:415-498`).
+ - `ConversationManager` loads the rollout history via `RolloutRecorder::get_rollout_history` and spawns Codex with that history (`codex-rs/core/src/conversation_manager.rs:128-177`), so Codex internally has all previous turns.
+- **History Replay Gap**
+ - `ConversationActor::handle_load` simply returns `LoadSessionResponse { modes, models }`; no previous `SessionUpdate`s are sent (`codex-acp/src/conversation.rs:1585-1650`).
+ - The conversation event loop only routes events to active submissions stored in `self.submissions`; replaying the rollout would immediately warn “unknown submission ID” and drop everything (`codex-acp/src/conversation.rs:1830-1840`). Result: clients must retain chat logs themselves.
+- **Voice-dev State**
+ - `AgentManager` only considers Claude resumable because `supportsSessionPersistence` is false for Codex (`packages/server/src/server/acp/agent-types.ts:33-67`).
+ - `AgentPersistence` writes `agents.json` for app-started agents but currently stores only Claude’s session ids; Codex sessions disappear once the process exits (`packages/server/src/server/acp/agent-persistence.ts:1-89`).
+
+## Next Steps (not implemented yet)
+1. Install/run Codex ACP from PR #66 (or the author’s fork) with `--session-persist` so every session we start writes a manifest entry.
+2. Extend our agent persistence to capture Codex session metadata (session id + rollout path + cwd + MCP servers) by reading Codex’s manifest immediately after `newSession` succeeds.
+3. Flip Codex’s `supportsSessionPersistence` flag and teach `AgentManager` to call `/session/load` for persisted Codex agents, replaying history ourselves by parsing the saved rollout (mirroring our Claude fork).
+4. Expose resume UI/UX for Codex agents started from the app; later we can explore listing Codex’s manifest directly for sessions started outside voice-dev.
diff --git a/package-lock.json b/package-lock.json
index 616883a65..fb6889556 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,7 +13,9 @@
"packages/app"
],
"dependencies": {
- "@boudra/claude-code-acp": "^0.8.3"
+ "@anthropic-ai/claude-agent-sdk": "^0.1.37",
+ "@boudra/claude-code-acp": "^0.8.3",
+ "@openai/codex-sdk": "^0.58.0"
},
"devDependencies": {
"typescript": "^5.9.3"
@@ -105,9 +107,9 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
- "version": "0.1.23",
- "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.23.tgz",
- "integrity": "sha512-DktXOjzS2hOuuj2Zpo7fEooONfMa5bm09pt1/Vt4vn30YugELoezn/ZQ/TG5uSQ7+Zl/ElxAvi4vGDorj1Tirg==",
+ "version": "0.1.37",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.37.tgz",
+ "integrity": "sha512-LMfqMIPLTz0vRhpcO7hpPJ5L6Bg24y5/PaqZvwAUNZ/GR3OAl7xmJR7IryIR6m8Pyd/6Hs2yBU8j86Os+wHFQQ==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
@@ -1639,6 +1641,26 @@
"claude-code-acp": "dist/index.js"
}
},
+ "node_modules/@boudra/claude-code-acp/node_modules/@anthropic-ai/claude-agent-sdk": {
+ "version": "0.1.23",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.23.tgz",
+ "integrity": "sha512-DktXOjzS2hOuuj2Zpo7fEooONfMa5bm09pt1/Vt4vn30YugELoezn/ZQ/TG5uSQ7+Zl/ElxAvi4vGDorj1Tirg==",
+ "license": "SEE LICENSE IN README.md",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "^0.33.5",
+ "@img/sharp-darwin-x64": "^0.33.5",
+ "@img/sharp-linux-arm": "^0.33.5",
+ "@img/sharp-linux-arm64": "^0.33.5",
+ "@img/sharp-linux-x64": "^0.33.5",
+ "@img/sharp-win32-x64": "^0.33.5"
+ },
+ "peerDependencies": {
+ "zod": "^3.24.1"
+ }
+ },
"node_modules/@boudra/claude-code-acp/node_modules/@modelcontextprotocol/sdk": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz",
@@ -5917,6 +5939,15 @@
"node": ">=12.0.0"
}
},
+ "node_modules/@openai/codex-sdk": {
+ "version": "0.58.0",
+ "resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.58.0.tgz",
+ "integrity": "sha512-Z5vK294gUS9cn7bpU/lJtgzqJy1UqIGee7WMP+1Z4a6AxDcTxFCLZI4YkH9praJfrgoj5bFeu+3V9HIoBBTzcw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@openrouter/ai-sdk-provider": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-1.2.0.tgz",
@@ -8302,119 +8333,6 @@
"node": ">=10.0.0"
}
},
- "node_modules/@zed-industries/codex-acp": {
- "version": "0.3.12",
- "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.3.12.tgz",
- "integrity": "sha512-hX3N06QgGFM5mleB+L/+v8XTZ4iS4ScB5YXQXXbc20fYmkd5p6tEDG7m6p8sjtfDcdeKIZNtiiaEEHQqy2hVAA==",
- "license": "Apache-2.0",
- "bin": {
- "codex-acp": "bin/codex-acp.js"
- },
- "optionalDependencies": {
- "@zed-industries/codex-acp-darwin-arm64": "0.3.12",
- "@zed-industries/codex-acp-darwin-x64": "0.3.12",
- "@zed-industries/codex-acp-linux-arm64": "0.3.12",
- "@zed-industries/codex-acp-linux-x64": "0.3.12",
- "@zed-industries/codex-acp-windows-arm64": "0.3.12",
- "@zed-industries/codex-acp-windows-x64": "0.3.12"
- }
- },
- "node_modules/@zed-industries/codex-acp-darwin-arm64": {
- "version": "0.3.12",
- "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-darwin-arm64/-/codex-acp-darwin-arm64-0.3.12.tgz",
- "integrity": "sha512-ajG2aU9I4Hg57Havk+elLR47tXPmxSsnSvZE0yxITiHr59IQacVJL0z/7hDdY3S6H10K+zb/fGQAvOXhcSMEtQ==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "bin": {
- "codex-acp-darwin-arm64": "bin/codex-acp"
- }
- },
- "node_modules/@zed-industries/codex-acp-darwin-x64": {
- "version": "0.3.12",
- "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-darwin-x64/-/codex-acp-darwin-x64-0.3.12.tgz",
- "integrity": "sha512-YQMe+G6hiVLY4R/lbC2UGkPJjnR8viIjaHmSETTfuOHAeQF9p8Wvr0k1DbIvacdyPst6X7ItoIb40fvtgsmVdw==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "bin": {
- "codex-acp-darwin-x64": "bin/codex-acp"
- }
- },
- "node_modules/@zed-industries/codex-acp-linux-arm64": {
- "version": "0.3.12",
- "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-linux-arm64/-/codex-acp-linux-arm64-0.3.12.tgz",
- "integrity": "sha512-nrDFzs03Cm0/CB/sEvGPHJHl2Z0pMCSXEmpDVoUTKgZf2Iof23ZsV/VD+eHdLj98VEvmWiT2u7+kSBqBtpnMmA==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "bin": {
- "codex-acp-linux-arm64": "bin/codex-acp"
- }
- },
- "node_modules/@zed-industries/codex-acp-linux-x64": {
- "version": "0.3.12",
- "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-linux-x64/-/codex-acp-linux-x64-0.3.12.tgz",
- "integrity": "sha512-BWGzV8dmCVAG64FfER6xtBkWedo9VX6u9ZjxOxr7JA9FhQ7KSPycH9r0BV/Nym/6LlvS4hLMcgXRvSfup8gLMA==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "bin": {
- "codex-acp-linux-x64": "bin/codex-acp"
- }
- },
- "node_modules/@zed-industries/codex-acp-windows-arm64": {
- "version": "0.3.12",
- "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-windows-arm64/-/codex-acp-windows-arm64-0.3.12.tgz",
- "integrity": "sha512-iQgXFk3OH8WlJgxMMSc+nDcrVH6m0HpItYca07fbrD4LdR+0ByW7EEy7VvONcuYqkmKLOHT1ufuQZEEraZp1WQ==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "windows"
- ],
- "bin": {
- "codex-acp-windows-arm64": "bin/codex-acp.exe"
- }
- },
- "node_modules/@zed-industries/codex-acp-windows-x64": {
- "version": "0.3.12",
- "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-windows-x64/-/codex-acp-windows-x64-0.3.12.tgz",
- "integrity": "sha512-26fYm64qW87eRVC2yqAdeIVFdid+tpnfsNAn1Fc6zKcGpzgxfbYxfStjKUltoxI/X0ZHQI/4GvWpeNyshg5XGQ==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "windows"
- ],
- "bin": {
- "codex-acp-windows-x64": "bin/codex-acp.exe"
- }
- },
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -21362,7 +21280,7 @@
"@deepgram/sdk": "^3.4.0",
"@modelcontextprotocol/sdk": "^1.20.1",
"@openrouter/ai-sdk-provider": "^1.2.0",
- "@zed-industries/codex-acp": "^0.3.9",
+ "@zed-industries/codex-acp": "^0.4.0",
"ai": "^5.0.76",
"dotenv": "^17.2.3",
"express": "^4.18.2",
@@ -21386,6 +21304,26 @@
"vitest": "^3.2.4"
}
},
+ "packages/server/node_modules/@anthropic-ai/claude-agent-sdk": {
+ "version": "0.1.23",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.23.tgz",
+ "integrity": "sha512-DktXOjzS2hOuuj2Zpo7fEooONfMa5bm09pt1/Vt4vn30YugELoezn/ZQ/TG5uSQ7+Zl/ElxAvi4vGDorj1Tirg==",
+ "license": "SEE LICENSE IN README.md",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "^0.33.5",
+ "@img/sharp-darwin-x64": "^0.33.5",
+ "@img/sharp-linux-arm": "^0.33.5",
+ "@img/sharp-linux-arm64": "^0.33.5",
+ "@img/sharp-linux-x64": "^0.33.5",
+ "@img/sharp-win32-x64": "^0.33.5"
+ },
+ "peerDependencies": {
+ "zod": "^3.24.1"
+ }
+ },
"packages/server/node_modules/@boudra/claude-code-acp": {
"version": "0.8.5",
"resolved": "https://npm.pkg.github.com/download/@boudra/claude-code-acp/0.8.5/f4bc31a9496d5ec72b306e2f891eeaaef38aeaf5",
@@ -21524,6 +21462,119 @@
"url": "https://opencollective.com/express"
}
},
+ "packages/server/node_modules/@zed-industries/codex-acp": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.4.0.tgz",
+ "integrity": "sha512-1X121qtuxYeD/BBvxDkMEKJ5W67oE11mHUvMMEwlalrBQP2MlxkcDGpKoTNnjQIHTRnN+Gd4jN67bgw2P48tPg==",
+ "license": "Apache-2.0",
+ "bin": {
+ "codex-acp": "bin/codex-acp.js"
+ },
+ "optionalDependencies": {
+ "@zed-industries/codex-acp-darwin-arm64": "0.4.0",
+ "@zed-industries/codex-acp-darwin-x64": "0.4.0",
+ "@zed-industries/codex-acp-linux-arm64": "0.4.0",
+ "@zed-industries/codex-acp-linux-x64": "0.4.0",
+ "@zed-industries/codex-acp-windows-arm64": "0.4.0",
+ "@zed-industries/codex-acp-windows-x64": "0.4.0"
+ }
+ },
+ "packages/server/node_modules/@zed-industries/codex-acp-darwin-arm64": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-darwin-arm64/-/codex-acp-darwin-arm64-0.4.0.tgz",
+ "integrity": "sha512-SY54IJdDI1N2qoh0YM1WeH4j3IGEG7NHTZ6PI2Rj/znmB+Oh6bbxV3XemQbcKvqK2aSE+/kl5JKgvIIePEgdAA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "bin": {
+ "codex-acp-darwin-arm64": "bin/codex-acp"
+ }
+ },
+ "packages/server/node_modules/@zed-industries/codex-acp-darwin-x64": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-darwin-x64/-/codex-acp-darwin-x64-0.4.0.tgz",
+ "integrity": "sha512-zUV5qAgKPatGJmM1diZqNN5GvTl3pgF8cC5eVhGiwLgSMuw+eUsH9Ilwq5j+WRoTIRRfPnGF/QcGysGyBVvHOQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "bin": {
+ "codex-acp-darwin-x64": "bin/codex-acp"
+ }
+ },
+ "packages/server/node_modules/@zed-industries/codex-acp-linux-arm64": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-linux-arm64/-/codex-acp-linux-arm64-0.4.0.tgz",
+ "integrity": "sha512-kUAxIYIXAkzRrYN5+j+LI+ggNLA5WQbHM3kn3RRsXVWBEQj+viehQsxJYyVjL2SRYLnMFl52wfehkErwb+Xf1Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "bin": {
+ "codex-acp-linux-arm64": "bin/codex-acp"
+ }
+ },
+ "packages/server/node_modules/@zed-industries/codex-acp-linux-x64": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-linux-x64/-/codex-acp-linux-x64-0.4.0.tgz",
+ "integrity": "sha512-MmeXc5o7BbKih4MtbAxIiJ6pOnMCr+xk+7KDaFdodNxQ9jWRoHpL1qyc1vd8pVpUK9L1GKBqkm3vJn3QEwU5Sw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "bin": {
+ "codex-acp-linux-x64": "bin/codex-acp"
+ }
+ },
+ "packages/server/node_modules/@zed-industries/codex-acp-windows-arm64": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-windows-arm64/-/codex-acp-windows-arm64-0.4.0.tgz",
+ "integrity": "sha512-xf96JmssYnlh7m6vFcceBGVy11pBmWtPhPSjuZKyiPOR8dd5MGNeokILdJ9uJbDlJv/p5zBovXHX2m42xuIdhg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "windows"
+ ],
+ "bin": {
+ "codex-acp-windows-arm64": "bin/codex-acp.exe"
+ }
+ },
+ "packages/server/node_modules/@zed-industries/codex-acp-windows-x64": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-windows-x64/-/codex-acp-windows-x64-0.4.0.tgz",
+ "integrity": "sha512-dch3puZuS9g5XkeYRsCBH9v7jBVfhoCtkggY2oLs7sj9cFisgE3lebWfYhfB/0ibXoKSmePNoLwxoql8RiFpZg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "windows"
+ ],
+ "bin": {
+ "codex-acp-windows-x64": "bin/codex-acp.exe"
+ }
+ },
"packages/server/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
diff --git a/package.json b/package.json
index 2658364ee..502e178f9 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,8 @@
"lightningcss": "1.30.1"
},
"dependencies": {
- "@boudra/claude-code-acp": "^0.8.3"
+ "@anthropic-ai/claude-agent-sdk": "^0.1.37",
+ "@boudra/claude-code-acp": "^0.8.3",
+ "@openai/codex-sdk": "^0.58.0"
}
}
diff --git a/packages/app/src/app/file-explorer.tsx b/packages/app/src/app/file-explorer.tsx
index 11d5a444a..24746c0da 100644
--- a/packages/app/src/app/file-explorer.tsx
+++ b/packages/app/src/app/file-explorer.tsx
@@ -7,13 +7,24 @@ import {
Text,
View,
} from "react-native";
-import { StyleSheet } from "react-native-unistyles";
+import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useLocalSearchParams } from "expo-router";
+import * as Clipboard from "expo-clipboard";
+import { Copy, Check, ArrowLeft } from "lucide-react-native";
import { BackHeader } from "@/components/headers/back-header";
import { useSession, type ExplorerEntry } from "@/contexts/session-context";
export default function FileExplorerScreen() {
- const { agentId } = useLocalSearchParams<{ agentId: string }>();
+ const { theme } = useUnistyles();
+ const {
+ agentId,
+ path: pathParamRaw,
+ file: fileParamRaw,
+ } = useLocalSearchParams<{
+ agentId: string;
+ path?: string | string[];
+ file?: string | string[];
+ }>();
const {
agents,
fileExplorer,
@@ -21,6 +32,17 @@ export default function FileExplorerScreen() {
requestFilePreview,
} = useSession();
const [selectedEntryPath, setSelectedEntryPath] = useState(null);
+ const pendingPathParamRef = useRef(null);
+ const pendingFileParamRef = useRef(null);
+ const [copiedPath, setCopiedPath] = useState(null);
+ const copyTimeoutRef = useRef | null>(null);
+
+ const normalizedPathParam = normalizePathParam(getFirstParam(pathParamRaw));
+ const normalizedFileParam = normalizeFileParam(getFirstParam(fileParamRaw));
+ const derivedDirectoryFromFile = normalizedFileParam
+ ? deriveDirectoryFromFile(normalizedFileParam)
+ : null;
+ const initialTargetDirectory = normalizedPathParam ?? derivedDirectoryFromFile ?? ".";
const agent = agentId ? agents.get(agentId) : undefined;
const explorerState = agentId ? fileExplorer.get(agentId) : undefined;
@@ -34,27 +56,6 @@ export default function FileExplorerScreen() {
: null;
const shouldShowPreview = Boolean(selectedEntryPath);
- const initializedAgentRef = useRef(null);
-
- useEffect(() => {
- if (!agentId) {
- initializedAgentRef.current = null;
- return;
- }
-
- if (initializedAgentRef.current === agentId) {
- return;
- }
-
- initializedAgentRef.current = agentId;
- setSelectedEntryPath(null);
-
- const hasDirectory = explorerState?.directories.has(currentPath) ?? false;
- if (!hasDirectory) {
- requestDirectoryListing(agentId, currentPath);
- }
- }, [agentId, currentPath, explorerState, requestDirectoryListing]);
-
useEffect(() => {
setSelectedEntryPath(null);
}, [currentPath]);
@@ -69,6 +70,53 @@ export default function FileExplorerScreen() {
return nextPath.length === 0 ? "." : nextPath;
}, [currentPath]);
+ useEffect(() => {
+ setCopiedPath(null);
+ }, [currentPath]);
+
+ useEffect(() => {
+ if (!agentId || !initialTargetDirectory) {
+ pendingPathParamRef.current = null;
+ return;
+ }
+
+ if (pendingPathParamRef.current === initialTargetDirectory) {
+ return;
+ }
+
+ pendingPathParamRef.current = initialTargetDirectory;
+ requestDirectoryListing(agentId, initialTargetDirectory);
+ }, [agentId, initialTargetDirectory, requestDirectoryListing]);
+
+ useEffect(() => {
+ if (!agentId || !normalizedFileParam) {
+ pendingFileParamRef.current = null;
+ return;
+ }
+
+ pendingFileParamRef.current = normalizedFileParam;
+ requestFilePreview(agentId, normalizedFileParam);
+ }, [agentId, normalizedFileParam, requestFilePreview]);
+
+ useEffect(() => {
+ if (!agentId) {
+ return;
+ }
+
+ const targetFile = pendingFileParamRef.current;
+ if (!targetFile) {
+ return;
+ }
+
+ const hasEntry = entries.some((entry) => entry.path === targetFile);
+ if (!hasEntry) {
+ return;
+ }
+
+ setSelectedEntryPath(targetFile);
+ pendingFileParamRef.current = null;
+ }, [agentId, entries]);
+
const handleEntryPress = useCallback(
(entry: ExplorerEntry) => {
if (!agentId) {
@@ -95,6 +143,26 @@ export default function FileExplorerScreen() {
requestDirectoryListing(agentId, parentPath);
}, [agentId, parentPath, requestDirectoryListing]);
+ const handleCopyPath = useCallback(async (path: string) => {
+ await Clipboard.setStringAsync(path);
+ setCopiedPath(path);
+ if (copyTimeoutRef.current) {
+ clearTimeout(copyTimeoutRef.current);
+ }
+ copyTimeoutRef.current = setTimeout(() => {
+ setCopiedPath(null);
+ copyTimeoutRef.current = null;
+ }, 1500);
+ }, []);
+
+ useEffect(() => {
+ return () => {
+ if (copyTimeoutRef.current) {
+ clearTimeout(copyTimeoutRef.current);
+ }
+ };
+ }, []);
+
if (!agent) {
return (
@@ -108,110 +176,109 @@ export default function FileExplorerScreen() {
return (
-
-
-
- Path
-
- {currentPath}
-
-
- {parentPath && (
-
- Up
-
- )}
-
+
- {shouldShowPreview && (
-
- {isLoading && !preview ? (
+ {shouldShowPreview ? (
+
+ setSelectedEntryPath(null)} />
+
+ {isLoading && !preview ? (
+
+
+ Loading file...
+
+ ) : !preview ? (
+
+ No preview available yet
+
+ ) : preview.kind === "text" ? (
+
+
+ {preview.content}
+
+
+ ) : preview.kind === "image" && preview.content ? (
+
+
+
+ ) : (
+
+ Binary preview unavailable
+
+ {formatFileSize({ size: preview.size })}
+
+
+ )}
+
+
+ ) : (
+
+ {error ? (
+
+ {error}
+
+ ) : isLoading && entries.length === 0 ? (
- Loading file...
+ Loading...
- ) : !preview ? (
+ ) : entries.length === 0 ? (
- No preview available yet
-
- ) : preview.kind === "text" ? (
-
-
- {preview.content}
-
-
- ) : preview.kind === "image" && preview.content ? (
-
-
+ Directory is empty
) : (
-
- Binary preview unavailable
-
- {formatFileSize({ size: preview.size })}
-
-
+
+ {parentPath && }
+ {entries.map((entry) => (
+ handleEntryPress(entry)}
+ >
+
+ {entry.name}
+
+ {entry.kind.toUpperCase()} · {formatFileSize({ size: entry.size })} · {formatModifiedTime({ value: entry.modifiedAt })}
+
+
+ {
+ event.stopPropagation();
+ handleCopyPath(entry.path);
+ }}
+ hitSlop={8}
+ style={styles.copyButton}
+ >
+ {copiedPath === entry.path ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
)}
)}
-
-
- {error ? (
-
- {error}
-
- ) : isLoading && entries.length === 0 ? (
-
-
- Loading...
-
- ) : entries.length === 0 ? (
-
- Directory is empty
-
- ) : (
-
- {entries.map((entry) => (
- handleEntryPress(entry)}
- >
-
- {entry.name}
-
- {entry.kind.toUpperCase()} ·{" "}
- {formatFileSize({ size: entry.size })} ·{" "}
- {formatModifiedTime({ value: entry.modifiedAt })}
-
-
-
- {entry.kind === "directory" ? "Open" : "Preview"}
-
-
- ))}
-
- )}
-
);
@@ -235,54 +302,76 @@ function formatModifiedTime({ value }: { value: string }): string {
return date.toLocaleString();
}
+function getFirstParam(value?: string | string[]): string | null {
+ if (Array.isArray(value)) {
+ return value[0] ?? null;
+ }
+ return value ?? null;
+}
+
+function normalizePathParam(value: string | null): string | null {
+ if (value === null) {
+ return null;
+ }
+ const trimmed = value.trim();
+ if (!trimmed.length) {
+ return ".";
+ }
+ return trimmed.replace(/\\/g, "/");
+}
+
+function normalizeFileParam(value: string | null): string | null {
+ if (value === null) {
+ return null;
+ }
+ const trimmed = value.trim();
+ if (!trimmed.length) {
+ return null;
+ }
+ return trimmed.replace(/\\/g, "/");
+}
+
+function deriveDirectoryFromFile(filePath: string): string {
+ const normalized = filePath.replace(/\\/g, "/");
+ const lastSlash = normalized.lastIndexOf("/");
+ if (lastSlash === -1) {
+ return ".";
+ }
+ const directory = normalized.slice(0, lastSlash);
+ return directory.length > 0 ? directory : ".";
+}
+
+function UpRow({ label, onPress }: { label: string; onPress: () => void }) {
+ const { theme } = useUnistyles();
+ return (
+
+
+ {label}
+
+ );
+}
+
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
- headerRow: {
- flexDirection: "row",
- alignItems: "center",
- paddingHorizontal: theme.spacing[4],
- paddingVertical: theme.spacing[2],
- gap: theme.spacing[3],
- },
- breadcrumbs: {
- flex: 1,
- },
- breadcrumbLabel: {
- color: theme.colors.mutedForeground,
- fontSize: theme.fontSize.xs,
- marginBottom: theme.spacing[1],
- },
- breadcrumbText: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize.sm,
- fontFamily: "monospace",
- },
- upButton: {
- paddingHorizontal: theme.spacing[4],
- paddingVertical: theme.spacing[2],
- borderRadius: theme.borderRadius.lg,
- backgroundColor: theme.colors.muted,
- },
- upButtonText: {
- color: theme.colors.foreground,
- fontWeight: theme.fontWeight.semibold,
- },
content: {
flex: 1,
flexDirection: "column",
- paddingHorizontal: theme.spacing[4],
- paddingBottom: theme.spacing[4],
- gap: theme.spacing[4],
+ paddingHorizontal: theme.spacing[3],
+ paddingBottom: theme.spacing[3],
+ gap: theme.spacing[3],
},
listSection: {
flex: 1,
- borderWidth: theme.borderWidth[1],
- borderColor: theme.colors.border,
- borderRadius: theme.borderRadius.lg,
- padding: theme.spacing[2],
+ },
+ entriesContent: {
+ paddingBottom: theme.spacing[4],
+ },
+ previewWrapper: {
+ flex: 1,
+ gap: theme.spacing[2],
},
previewSection: {
flex: 1,
@@ -316,10 +405,12 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
- paddingVertical: theme.spacing[3],
+ paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
- marginBottom: theme.spacing[2],
+ borderWidth: theme.borderWidth[1],
+ borderColor: theme.colors.border,
+ marginBottom: theme.spacing[1],
},
directoryRow: {
backgroundColor: theme.colors.muted,
@@ -328,7 +419,6 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.card,
},
selectedRow: {
- borderWidth: theme.borderWidth[2],
borderColor: theme.colors.primary,
},
entryTextContainer: {
@@ -345,7 +435,25 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
- entryAction: {
+ copyButton: {
+ width: 36,
+ height: 36,
+ borderRadius: theme.borderRadius.full,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ upRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ paddingVertical: theme.spacing[2],
+ paddingHorizontal: theme.spacing[3],
+ borderRadius: theme.borderRadius.md,
+ borderWidth: theme.borderWidth[1],
+ borderColor: theme.colors.border,
+ backgroundColor: theme.colors.background,
+ },
+ upRowText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx
index f73dc2630..703264c9c 100644
--- a/packages/app/src/components/agent-input-area.tsx
+++ b/packages/app/src/components/agent-input-area.tsx
@@ -448,7 +448,8 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.muted,
borderRadius: theme.borderRadius.lg,
color: theme.colors.foreground,
- fontSize: theme.fontSize.base,
+ fontSize: theme.fontSize.lg,
+ lineHeight: theme.fontSize.lg * 1.4,
},
buttonRow: {
flexDirection: "row",
diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx
index ab49013b9..ae13814b2 100644
--- a/packages/app/src/components/agent-stream-view.tsx
+++ b/packages/app/src/components/agent-stream-view.tsx
@@ -14,17 +14,20 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { BottomSheetModal } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { ChevronDown } from "lucide-react-native";
+import { useRouter } from "expo-router";
import {
AssistantMessage,
UserMessage,
ActivityLog,
ToolCall,
AgentThoughtMessage,
+ type InlinePathTarget,
} from "./message";
import { ToolCallBottomSheet } from "./tool-call-bottom-sheet";
import type { StreamItem } from "@/types/stream";
import type { SelectedToolCall, PendingPermission } from "@/types/shared";
import type { Agent } from "@/contexts/session-context";
+import { useSession } from "@/contexts/session-context";
export interface AgentStreamViewProps {
agentId: string;
@@ -52,6 +55,8 @@ export function AgentStreamView({
const isProgrammaticScrollRef = useRef(false);
const isNearBottomRef = useRef(true);
const isUserScrollingRef = useRef(false);
+ const router = useRouter();
+ const { requestDirectoryListing, requestFilePreview } = useSession();
useEffect(() => {
hasScrolledInitially.current = false;
@@ -71,6 +76,40 @@ export function AgentStreamView({
setSelectedToolCall(null);
}
+ const handleInlinePathPress = useCallback(
+ (target: InlinePathTarget) => {
+ if (!target.path) {
+ return;
+ }
+
+ const normalized = normalizeInlinePath(target.path);
+ if (!normalized) {
+ return;
+ }
+
+ requestDirectoryListing(agentId, normalized.directory);
+ if (normalized.file) {
+ requestFilePreview(agentId, normalized.file);
+ }
+
+ router.push({
+ pathname: "/file-explorer",
+ params: {
+ agentId,
+ path: normalized.directory,
+ ...(normalized.file ? { file: normalized.file } : {}),
+ ...(target.lineStart !== undefined
+ ? { lineStart: String(target.lineStart) }
+ : {}),
+ ...(target.lineEnd !== undefined
+ ? { lineEnd: String(target.lineEnd) }
+ : {}),
+ },
+ });
+ },
+ [agentId, requestDirectoryListing, requestFilePreview, router]
+ );
+
const handleScroll = useCallback(
(event: NativeSyntheticEvent) => {
const { contentOffset } = event.nativeEvent;
@@ -179,6 +218,7 @@ export function AgentStreamView({
);
@@ -324,6 +364,48 @@ export function AgentStreamView({
);
}
+function normalizeInlinePath(rawPath: string):
+ | { directory: string; file?: string }
+ | null {
+ if (!rawPath) {
+ return null;
+ }
+
+ let value = rawPath.trim();
+ value = value.replace(/^['"`]/, "").replace(/['"`]$/, "");
+ if (!value) {
+ return null;
+ }
+
+ let normalized = value.replace(/\\/g, "/");
+ normalized = normalized.replace(/\/{2,}/g, "/");
+
+ if (normalized.startsWith("./")) {
+ normalized = normalized.slice(2) || ".";
+ }
+
+ if (!normalized.length) {
+ normalized = ".";
+ }
+
+ if (normalized === ".") {
+ return { directory: "." };
+ }
+
+ if (normalized.endsWith("/")) {
+ const dir = normalized.replace(/\/+$/, "");
+ return { directory: dir.length > 0 ? dir : "." };
+ }
+
+ const lastSlash = normalized.lastIndexOf("/");
+ const directory = lastSlash >= 0 ? normalized.slice(0, lastSlash) : ".";
+
+ return {
+ directory: directory.length > 0 ? directory : ".",
+ file: normalized,
+ };
+}
+
// Permission Request Card Component
function PermissionRequestCard({
permission,
diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx
index 34aca3ead..16248d4cd 100644
--- a/packages/app/src/components/message.tsx
+++ b/packages/app/src/components/message.tsx
@@ -125,10 +125,18 @@ export const UserMessage = memo(function UserMessage({
);
});
+export interface InlinePathTarget {
+ raw: string;
+ path: string;
+ lineStart?: number;
+ lineEnd?: number;
+}
+
interface AssistantMessageProps {
message: string;
timestamp: number;
isStreaming?: boolean;
+ onInlinePathPress?: (target: InlinePathTarget) => void;
}
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
@@ -211,6 +219,19 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontStyle: "italic" as const,
},
+ pathChip: {
+ backgroundColor: theme.colors.secondary,
+ borderRadius: theme.borderRadius.full,
+ paddingHorizontal: theme.spacing[2],
+ paddingVertical: 2,
+ marginRight: theme.spacing[1],
+ marginVertical: 2,
+ },
+ pathChipText: {
+ color: theme.colors.secondaryForeground,
+ fontFamily: "monospace",
+ fontSize: 13,
+ },
}));
const markdownStyles = {
@@ -229,12 +250,129 @@ const markdownStyles = {
blockquote_text: assistantMessageStylesheet.markdownBlockquoteText,
};
+function isLikelyPathToken(value: string): boolean {
+ if (!value || value.length > 300) {
+ return false;
+ }
+
+ if (/\s/.test(value)) {
+ return false;
+ }
+
+ const hasSlash = value.includes("/") || value.includes("\\");
+ const hasExtension = /\.[a-zA-Z0-9]{1,8}$/.test(value);
+
+ if (!hasSlash && !hasExtension) {
+ return false;
+ }
+
+ const looksLikeDir = value.endsWith("/") || value.startsWith("./") || value.startsWith("../");
+
+ return hasExtension || looksLikeDir || value.includes("/");
+}
+
+function normalizeInlinePathValue(value: string): string | null {
+ const trimmed = value.trim().replace(/^['"`]/, "").replace(/['"`]$/, "");
+ if (!trimmed) {
+ return null;
+ }
+
+ return trimmed.replace(/\\/g, "/");
+}
+
+function parseInlinePathToken(
+ value: string,
+ lastPathRef: React.MutableRefObject
+): InlinePathTarget | null {
+ const rawValue = value ?? "";
+ const trimmed = rawValue.trim();
+ if (!trimmed) {
+ return null;
+ }
+
+ const rangeOnlyMatch = trimmed.match(/^:([0-9]+)(?:-([0-9]+))?$/);
+ if (rangeOnlyMatch) {
+ const basePath = lastPathRef.current;
+ if (!basePath) {
+ return null;
+ }
+ const lineStart = parseInt(rangeOnlyMatch[1], 10);
+ const lineEnd = rangeOnlyMatch[2] ? parseInt(rangeOnlyMatch[2], 10) : undefined;
+ return {
+ raw: rawValue,
+ path: basePath,
+ lineStart,
+ lineEnd,
+ };
+ }
+
+ const pathMatch = trimmed.match(/^(.*?)(?::([0-9]+)(?:-([0-9]+))?)?$/);
+ if (!pathMatch) {
+ return null;
+ }
+
+ const basePath = pathMatch[1]?.trim();
+ if (!basePath || !isLikelyPathToken(basePath)) {
+ return null;
+ }
+
+ const normalizedPath = normalizeInlinePathValue(basePath);
+ if (!normalizedPath) {
+ return null;
+ }
+
+ lastPathRef.current = normalizedPath;
+
+ const lineStart = pathMatch[2] ? parseInt(pathMatch[2], 10) : undefined;
+ const lineEnd = pathMatch[3] ? parseInt(pathMatch[3], 10) : undefined;
+
+ return {
+ raw: rawValue,
+ path: normalizedPath,
+ lineStart,
+ lineEnd,
+ };
+}
+
export const AssistantMessage = memo(function AssistantMessage({
message,
timestamp,
isStreaming = false,
+ onInlinePathPress,
}: AssistantMessageProps) {
const fadeAnim = useRef(new Animated.Value(0.3)).current;
+ const lastPathRef = useRef(null);
+
+ const markdownRules = useMemo(() => {
+ if (!onInlinePathPress) {
+ return undefined;
+ }
+
+ return {
+ code_inline: (node: any) => {
+ const content = node.content ?? "";
+ const parsed = parseInlinePathToken(content, lastPathRef);
+
+ if (!parsed) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return (
+ parsed && onInlinePathPress?.(parsed)}
+ style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
+ >
+ {content}
+
+ );
+ },
+ };
+ }, [onInlinePathPress]);
useEffect(() => {
if (isStreaming) {
@@ -260,7 +398,9 @@ export const AssistantMessage = memo(function AssistantMessage({
return (
- {message}
+
+ {message}
+
{isStreaming && (
{
+ return typeof value === "object" && value !== null;
+}
+
+function getString(value: unknown): string | undefined {
+ return typeof value === "string" ? value : undefined;
+}
+
+function splitIntoLines(text: string): string[] {
+ if (!text) {
+ return [];
+ }
+
+ return text.replace(/\r\n/g, "\n").split("\n");
+}
+
+function buildLineDiff(originalText: string, updatedText: string): DiffLine[] {
+ const originalLines = splitIntoLines(originalText);
+ const updatedLines = splitIntoLines(updatedText);
+
+ const hasAnyContent = originalLines.length > 0 || updatedLines.length > 0;
+ if (!hasAnyContent) {
+ return [];
+ }
+
+ const m = originalLines.length;
+ const n = updatedLines.length;
+ const dp: number[][] = Array.from({ length: m + 1 }, () =>
+ Array(n + 1).fill(0)
+ );
+
+ for (let i = m - 1; i >= 0; i -= 1) {
+ for (let j = n - 1; j >= 0; j -= 1) {
+ if (originalLines[i] === updatedLines[j]) {
+ dp[i][j] = dp[i + 1][j + 1] + 1;
+ } else {
+ dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
+ }
+ }
+ }
+
+ const diff: DiffLine[] = [];
+
+ let i = 0;
+ let j = 0;
+ while (i < m && j < n) {
+ if (originalLines[i] === updatedLines[j]) {
+ diff.push({ type: "context", content: ` ${originalLines[i]}` });
+ i += 1;
+ j += 1;
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
+ diff.push({ type: "remove", content: `-${originalLines[i]}` });
+ i += 1;
+ } else {
+ diff.push({ type: "add", content: `+${updatedLines[j]}` });
+ j += 1;
+ }
+ }
+
+ while (i < m) {
+ diff.push({ type: "remove", content: `-${originalLines[i]}` });
+ i += 1;
+ }
+
+ while (j < n) {
+ diff.push({ type: "add", content: `+${updatedLines[j]}` });
+ j += 1;
+ }
+
+ return diff;
+}
+
+function parseUnifiedDiff(diffText?: string): DiffLine[] {
+ if (!diffText) {
+ return [];
+ }
+
+ const lines = splitIntoLines(diffText);
+ const diff: DiffLine[] = [];
+
+ for (const line of lines) {
+ if (!line.length) {
+ diff.push({ type: "context", content: line });
+ continue;
+ }
+
+ if (line.startsWith("@@")) {
+ diff.push({ type: "header", content: line });
+ continue;
+ }
+
+ if (line.startsWith("+")) {
+ if (!line.startsWith("+++")) {
+ diff.push({ type: "add", content: line });
+ }
+ continue;
+ }
+
+ if (line.startsWith("-")) {
+ if (!line.startsWith("---")) {
+ diff.push({ type: "remove", content: line });
+ }
+ continue;
+ }
+
+ if (
+ line.startsWith("diff --git") ||
+ line.startsWith("index ") ||
+ line.startsWith("---") ||
+ line.startsWith("+++")
+ ) {
+ continue;
+ }
+
+ if (line.startsWith("\\ No newline")) {
+ diff.push({ type: "header", content: line });
+ continue;
+ }
+
+ diff.push({ type: "context", content: line });
+ }
+
+ return diff;
+}
+
+function deriveDiffLines({
+ unifiedDiff,
+ original,
+ updated,
+}: {
+ unifiedDiff?: string;
+ original?: string;
+ updated?: string;
+}): DiffLine[] {
+ if (unifiedDiff) {
+ const parsed = parseUnifiedDiff(unifiedDiff);
+ if (parsed.length > 0) {
+ return parsed;
+ }
+ }
+
+ if (original !== undefined || updated !== undefined) {
+ return buildLineDiff(original ?? "", updated ?? "");
+ }
+
+ return [];
+}
+
interface ToolCallBottomSheetProps {
bottomSheetRef: React.RefObject;
selectedToolCall: SelectedToolCall | null;
@@ -81,6 +239,110 @@ export function ToolCallBottomSheet({
}
}, [selectedToolCall]);
+ const editEntries = useMemo(() => {
+ if (!args || !isRecord(args)) {
+ return [] as EditEntry[];
+ }
+
+ const rawArgs = args as Record;
+ const changesValue = rawArgs["changes"];
+
+ if (isRecord(changesValue)) {
+ const changeEntries = Object.entries(
+ changesValue as Record
+ );
+
+ return changeEntries
+ .map(([filePath, value]) => {
+ if (!isRecord(value)) {
+ return null;
+ }
+
+ let changeBlock: Record | null = null;
+ if (isRecord(value["update"])) {
+ changeBlock = value["update"] as Record;
+ } else if (isRecord(value["create"])) {
+ changeBlock = value["create"] as Record;
+ } else if (isRecord(value["delete"])) {
+ changeBlock = value["delete"] as Record;
+ } else {
+ changeBlock = value;
+ }
+
+ if (!changeBlock) {
+ return null;
+ }
+
+ const diffLines = deriveDiffLines({
+ unifiedDiff: getString(
+ changeBlock["unified_diff"] ??
+ changeBlock["diff"] ??
+ changeBlock["patch"] ??
+ changeBlock["unifiedDiff"]
+ ),
+ original:
+ getString(
+ changeBlock["old_content"] ??
+ changeBlock["oldContent"] ??
+ changeBlock["old_string"] ??
+ changeBlock["previous_content"] ??
+ changeBlock["previousContent"] ??
+ changeBlock["base_content"] ??
+ changeBlock["baseContent"]
+ ) ?? undefined,
+ updated:
+ getString(
+ changeBlock["new_content"] ??
+ changeBlock["newContent"] ??
+ changeBlock["new_string"] ??
+ changeBlock["content"] ??
+ changeBlock["replace_with"] ??
+ changeBlock["replaceWith"]
+ ) ?? undefined,
+ });
+
+ return {
+ filePath,
+ diffLines,
+ };
+ })
+ .filter((entry): entry is EditEntry => Boolean(entry));
+ }
+
+ const filePath =
+ getString(
+ rawArgs["file_path"] ?? rawArgs["filePath"] ?? rawArgs["path"]
+ ) || undefined;
+ const diffLines = deriveDiffLines({
+ original:
+ getString(
+ rawArgs["old_string"] ??
+ rawArgs["oldString"] ??
+ rawArgs["old_content"] ??
+ rawArgs["previous_content"] ??
+ rawArgs["base_content"]
+ ) ?? undefined,
+ updated:
+ getString(
+ rawArgs["new_string"] ??
+ rawArgs["newString"] ??
+ rawArgs["new_content"] ??
+ rawArgs["content"]
+ ) ?? undefined,
+ });
+
+ if (!filePath && diffLines.length === 0) {
+ return [];
+ }
+
+ return [
+ {
+ filePath,
+ diffLines,
+ },
+ ];
+ }, [args]);
+
return (
+ {editEntries.length > 0 &&
+ editEntries.map((entry, index) => (
+
+ File
+
+
+ {entry.filePath ?? "Unknown file"}
+
+
+
+ Diff
+
+ {entry.diffLines.length === 0 ? (
+
+ No changes to display
+
+ ) : (
+
+
+
+ {entry.diffLines.map((line, lineIndex) => (
+
+
+ {line.content}
+
+
+ ))}
+
+
+
+ )}
+
+
+ ))}
+
{/* Content sections */}
{args !== undefined && (
@@ -196,6 +520,81 @@ const styles = StyleSheet.create((theme) => ({
textTransform: "uppercase" as const,
letterSpacing: 0.5,
},
+ fileInfoContainer: {
+ backgroundColor: theme.colors.background,
+ borderRadius: theme.borderRadius.lg,
+ borderWidth: theme.borderWidth[1],
+ borderColor: theme.colors.border,
+ padding: theme.spacing[3],
+ marginBottom: theme.spacing[4],
+ },
+ fileInfoText: {
+ fontFamily: "monospace",
+ fontSize: theme.fontSize.sm,
+ color: theme.colors.foreground,
+ },
+ diffContainer: {
+ borderRadius: theme.borderRadius.lg,
+ borderWidth: theme.borderWidth[1],
+ borderColor: theme.colors.border,
+ backgroundColor: theme.colors.card,
+ overflow: "hidden",
+ },
+ diffScrollVertical: {
+ maxHeight: 280,
+ },
+ diffVerticalContent: {
+ flexGrow: 1,
+ },
+ diffScrollContent: {
+ flexDirection: "column" as const,
+ },
+ diffLinesContainer: {
+ alignSelf: "flex-start",
+ },
+ diffLine: {
+ minWidth: "100%",
+ paddingHorizontal: theme.spacing[3],
+ paddingVertical: theme.spacing[1],
+ },
+ diffLineText: {
+ fontFamily: "monospace",
+ fontSize: theme.fontSize.xs,
+ color: theme.colors.foreground,
+ },
+ diffHeaderLine: {
+ backgroundColor: theme.colors.muted,
+ },
+ diffHeaderText: {
+ color: theme.colors.mutedForeground,
+ },
+ diffAddLine: {
+ backgroundColor: theme.colors.palette.green[900],
+ },
+ diffAddText: {
+ color: theme.colors.palette.green[200],
+ },
+ diffRemoveLine: {
+ backgroundColor: theme.colors.palette.red[900],
+ },
+ diffRemoveText: {
+ color: theme.colors.palette.red[200],
+ },
+ diffContextLine: {
+ backgroundColor: theme.colors.card,
+ },
+ diffContextText: {
+ color: theme.colors.mutedForeground,
+ },
+ diffEmptyState: {
+ padding: theme.spacing[4],
+ alignItems: "center" as const,
+ justifyContent: "center" as const,
+ },
+ diffEmptyText: {
+ fontSize: theme.fontSize.sm,
+ color: theme.colors.mutedForeground,
+ },
jsonContainer: {
backgroundColor: theme.colors.background,
borderRadius: theme.borderRadius.lg,
diff --git a/packages/server/package.json b/packages/server/package.json
index 641746058..31807e540 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -20,10 +20,10 @@
"@agentclientprotocol/sdk": "^0.4.9",
"@ai-sdk/openai": "^2.0.52",
"@boudra/claude-code-acp": "^0.8.5",
- "@zed-industries/codex-acp": "^0.3.9",
"@deepgram/sdk": "^3.4.0",
"@modelcontextprotocol/sdk": "^1.20.1",
"@openrouter/ai-sdk-provider": "^1.2.0",
+ "@zed-industries/codex-acp": "^0.4.0",
"ai": "^5.0.76",
"dotenv": "^17.2.3",
"express": "^4.18.2",
diff --git a/packages/server/src/server/acp/agent-manager.ts b/packages/server/src/server/acp/agent-manager.ts
index 9fa0475fc..36298ee0c 100644
--- a/packages/server/src/server/acp/agent-manager.ts
+++ b/packages/server/src/server/acp/agent-manager.ts
@@ -1,6 +1,8 @@
import { spawn } from "child_process";
import { Writable, Readable } from "stream";
-import { access, constants } from "fs/promises";
+import { access, constants, mkdir } from "fs/promises";
+import os from "os";
+import path from "path";
import {
ClientSideConnection,
ndJsonStream,
@@ -249,6 +251,16 @@ class ACPClient implements Client {
export class AgentManager {
private agents = new Map();
private persistence = new AgentPersistence();
+ private codexHomeDir: string;
+ private codexSessionDir: string;
+
+ constructor() {
+ const defaultCodexHome = path.join(os.homedir(), ".voice-dev", "codex");
+ this.codexHomeDir = process.env.CODEX_HOME ?? defaultCodexHome;
+ this.codexSessionDir =
+ process.env.CODEX_SESSION_DIR ??
+ path.join(this.codexHomeDir, "sessions");
+ }
/**
* Initialize the agent manager and load persisted agents
@@ -1088,9 +1100,21 @@ export class AgentManager {
}
const definition = getAgentTypeDefinition(agent.options.type);
- const agentProcess = spawn(definition.spawn.command, definition.spawn.args, {
+ const spawnArgs = [...definition.spawn.args];
+ const spawnEnv: NodeJS.ProcessEnv = { ...process.env };
+
+ if (agent.options.type === "codex") {
+ await mkdir(this.codexSessionDir, { recursive: true });
+ spawnArgs.push("--session-persist", this.codexSessionDir);
+ spawnEnv.CODEX_HOME = this.codexHomeDir;
+ spawnEnv.CODEX_SESSION_PERSIST = "1";
+ spawnEnv.CODEX_SESSION_DIR = this.codexSessionDir;
+ }
+
+ const agentProcess = spawn(definition.spawn.command, spawnArgs, {
stdio: ["pipe", "pipe", "pipe"],
cwd,
+ env: spawnEnv,
});
const input = Writable.toWeb(agentProcess.stdin);
diff --git a/packages/server/src/server/acp/agent-types.ts b/packages/server/src/server/acp/agent-types.ts
index 1e24f1ea0..acca5aaa7 100644
--- a/packages/server/src/server/acp/agent-types.ts
+++ b/packages/server/src/server/acp/agent-types.ts
@@ -71,12 +71,12 @@ const agentTypeDefinitions: Record = {
codex: {
id: "codex",
label: "Codex",
- description: "Zed Codex ACP integration (no session resume)",
+ description: "Zed Codex ACP integration with session persistence",
spawn: {
command: "npx",
args: ["@zed-industries/codex-acp"],
},
- supportsSessionPersistence: false,
+ supportsSessionPersistence: true,
availableModes: CODEX_MODES,
defaultModeId: "auto",
},
diff --git a/packages/server/src/server/agent/agent-sdk-capabilities.md b/packages/server/src/server/agent/agent-sdk-capabilities.md
new file mode 100644
index 000000000..a229f81a5
--- /dev/null
+++ b/packages/server/src/server/agent/agent-sdk-capabilities.md
@@ -0,0 +1,30 @@
+# Agent SDK Capability Notes
+
+## @openai/codex-sdk (0.58.0)
+
+- Entry point is `Codex`/`Thread` (`node_modules/@openai/codex-sdk/dist/index.d.ts`). `Codex.startThread()` spawns the bundled `codex` CLI and returns a `Thread` handle that can issue `run()` or `runStreamed()` calls.
+- `Thread.runStreamed()` yields JSONL events typed as `ThreadEvent` (agent messages, reasoning, file changes, MCP tool calls, command executions, web searches, todo lists). These map one-to-one with CLI telemetry, so we can stream reasoning, tool calls, and status without ACP.
+- Session persistence is provided via `Codex.resumeThread(id)`; the CLI stores rollouts in `~/.codex/sessions` and resuming passes `resume ` to the CLI.
+- Session configuration is passed via `ThreadOptions`: `model`, `sandboxMode` (`read-only|workspace-write|danger-full-access`), `approvalPolicy`, `modelReasoningEffort`, `networkAccessEnabled`, `webSearchEnabled`, `workingDirectory`, and `skipGitRepoCheck`.
+- **Streams reasoning & tool activity**: `ThreadEvent` includes `item.started/updated/completed` with `reasoning` items, file changes, MCP tool calls, command execution events, etc. We get full turn telemetry similar to ACP.
+- **Gaps**:
+ - No public API to enumerate supported modes/models or switch modes mid-session; options are fixed per `Thread` instance.
+ - MCP servers cannot be injected dynamically; MCP items appear in the stream, but the CLI discovers servers from `codex` config, not via SDK APIs.
+ - Thread metadata (title, timestamps) isn’t exposed, so our persistence layer would still need to scrape Codex’s manifest if we want richer state.
+
+## @anthropic-ai/claude-agent-sdk (0.1.37)
+
+- Primary surface is the `query()` helper (`node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts`). It returns an `AsyncGenerator` so we get incremental updates (`SDKAssistantMessage`, `SDKPartialAssistantMessage`, `SDKSystemMessage`, `SDKToolProgressMessage`, etc.).
+- Control plane APIs are built into the generator: `interrupt()`, `setPermissionMode()`, `setModel()`, `setMaxThinkingTokens()`, and query-time introspection helpers (`supportedCommands()`, `supportedModels()`, `mcpServerStatus()`, `accountInfo()`). Final `SDKResultMessage` entries contain usage/cost and `permission_denials` so we can mirror Claude’s session summary.
+- Reasoning/tool output arrives in-band via `SDKAssistantMessage.message` (Anthropic streaming delta structure) and can include `tool_use` blocks; there’s also `SDKToolProgressMessage` updates and `SDKPartialAssistantMessage` typed separately for partial thoughts.
+- Session lifecycle:
+ - Create a session with `query({ prompt, options })`.
+ - Resume via `options.resume` (session id) and optionally `forkSession` if we don’t want to mutate the original log.
+ - Hooks (`hooks` option) let us observe `SessionStart`, `SessionEnd`, `PreToolUse`, etc., mirroring Claude Code’s plugin system.
+- Configuration options include `agents`, `permissionMode`, `allowedTools`/`disallowedTools`, `systemPrompt`, `plugins`, and `mcpServers`. MCP servers can be stdio/SSE/HTTP or in-process (`createSdkMcpServer`).
+- Tool gating is first-class: `canUseTool` callback receives tool invocations and can return allow/deny plus suggested permission persistence updates (`PermissionUpdate`).
+- Partial reasoning is delivered via `SDKPartialAssistantMessage` while the final `SDKResultMessage` includes usage, total cost, and permission denials.
+- **Gaps**:
+ - Claude Agent SDK currently consumes prompts via `query()` calls rather than maintaining a long-lived object per session, so we need to wrap it ourselves to keep stateful handles.
+ - While MCPs can be injected, there’s no baked-in manifest of sessions; persistence is up to us (the SDK exposes `session_id` on every message for us to store).
+ - Slash command support exists via `supportedCommands()`, but there’s no concept of sandbox/approval tiers like Codex’s `sandboxMode`; we instead rely on `permissionMode` + permission rules.
diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts
new file mode 100644
index 000000000..8880dc96e
--- /dev/null
+++ b/packages/server/src/server/agent/agent-sdk-types.ts
@@ -0,0 +1,148 @@
+import type { ThreadEvent as CodexThreadEvent } from "@openai/codex-sdk";
+import type {
+ Options as ClaudeAgentOptions,
+ SDKMessage as ClaudeStreamMessage,
+ McpServerConfig as ClaudeMcpServerConfig,
+} from "@anthropic-ai/claude-agent-sdk";
+import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+
+export type AgentProvider = "codex" | "claude";
+
+export type AgentMode = {
+ id: string;
+ label: string;
+ description?: string;
+};
+
+export type AgentCapabilityFlags = {
+ supportsStreaming: boolean;
+ supportsSessionPersistence: boolean;
+ supportsDynamicModes: boolean;
+ supportsMcpServers: boolean;
+ supportsReasoningStream: boolean;
+ supportsToolInvocations: boolean;
+};
+
+export type AgentPersistenceHandle = {
+ provider: AgentProvider;
+ sessionId: string;
+ /** Provider specific handle (Codex thread id, Claude resume token, etc). */
+ nativeHandle?: string;
+ metadata?: Record;
+};
+
+export type AgentPromptInput = string | { type: "text"; text: string }[];
+
+export type AgentRunOptions = {
+ outputSchema?: unknown;
+ resumeFrom?: AgentPersistenceHandle;
+ maxThinkingTokens?: number;
+};
+
+export type AgentUsage = {
+ inputTokens?: number;
+ cachedInputTokens?: number;
+ outputTokens?: number;
+ totalCostUsd?: number;
+};
+
+export type AgentTimelineItem =
+ | { type: "assistant_message"; text: string; raw?: unknown }
+ | { type: "reasoning"; text: string; raw?: unknown }
+ | { type: "command"; command: string; status: string; raw?: unknown }
+ | { type: "file_change"; files: { path: string; kind: string }[]; raw?: unknown }
+ | { type: "mcp_tool"; server: string; tool: string; status: string; raw?: unknown }
+ | { type: "web_search"; query: string; raw?: unknown }
+ | { type: "todo"; items: { text: string; completed: boolean }[]; raw?: unknown }
+ | { type: "error"; message: string; raw?: unknown };
+
+export type AgentStreamEvent =
+ | { type: "thread_started"; sessionId: string; provider: AgentProvider }
+ | { type: "turn_started"; provider: AgentProvider }
+ | { type: "turn_completed"; provider: AgentProvider; usage?: AgentUsage }
+ | { type: "turn_failed"; provider: AgentProvider; error: string }
+ | { type: "timeline"; item: AgentTimelineItem; provider: AgentProvider }
+ | { type: "provider_event"; provider: AgentProvider; raw: CodexThreadEvent | ClaudeStreamMessage }
+ | { type: "permission_requested"; provider: AgentProvider; request: AgentPermissionRequest }
+ | {
+ type: "permission_resolved";
+ provider: AgentProvider;
+ requestId: string;
+ resolution: AgentPermissionResponse;
+ };
+
+export type AgentPermissionRequestKind = "tool" | "plan" | "mode" | "other";
+
+export type AgentPermissionUpdate = Record;
+
+export type AgentPermissionRequest = {
+ id: string;
+ provider: AgentProvider;
+ name: string;
+ kind: AgentPermissionRequestKind;
+ title?: string;
+ description?: string;
+ input?: Record;
+ suggestions?: AgentPermissionUpdate[];
+ metadata?: Record;
+ raw?: unknown;
+};
+
+export type AgentPermissionResponse =
+ | {
+ behavior: "allow";
+ updatedInput?: Record;
+ updatedPermissions?: AgentPermissionUpdate[];
+ }
+ | {
+ behavior: "deny";
+ message?: string;
+ interrupt?: boolean;
+ };
+
+export type AgentRunResult = {
+ sessionId: string;
+ finalText: string;
+ usage?: AgentUsage;
+ timeline: AgentTimelineItem[];
+};
+
+export type AgentSessionConfig = {
+ provider: AgentProvider;
+ cwd: string;
+ modeId?: string;
+ model?: string;
+ approvalPolicy?: string;
+ sandboxMode?: string;
+ networkAccess?: boolean;
+ webSearch?: boolean;
+ reasoningEffort?: string;
+ extra?: {
+ codex?: Record;
+ claude?: Partial;
+ };
+ mcpServers?: Record Promise }>;
+};
+
+export interface AgentSession {
+ readonly provider: AgentProvider;
+ readonly id: string | null;
+ readonly capabilities: AgentCapabilityFlags;
+ run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise;
+ stream(prompt: AgentPromptInput, options?: AgentRunOptions): AsyncGenerator;
+ streamHistory(): AsyncGenerator;
+ getAvailableModes(): Promise;
+ getCurrentMode(): Promise;
+ setMode(modeId: string): Promise;
+ getPendingPermissions(): AgentPermissionRequest[];
+ respondToPermission(requestId: string, response: AgentPermissionResponse): Promise;
+ describePersistence(): AgentPersistenceHandle | null;
+ close(): Promise;
+}
+
+export interface AgentClient {
+ readonly provider: AgentProvider;
+ readonly capabilities: AgentCapabilityFlags;
+ createSession(config: AgentSessionConfig): Promise;
+ resumeSession(handle: AgentPersistenceHandle, overrides?: Partial): Promise;
+}
diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts
new file mode 100644
index 000000000..66522a4ac
--- /dev/null
+++ b/packages/server/src/server/agent/providers/claude-agent.test.ts
@@ -0,0 +1,261 @@
+import { describe, expect, test } from "vitest";
+import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+import { ClaudeAgentClient } from "./claude-agent.js";
+import type { AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js";
+
+function tmpCwd(): string {
+ const dir = mkdtempSync(path.join(os.tmpdir(), "claude-agent-e2e-"));
+ return dir;
+}
+
+async function autoApprove(session: Awaited>, event: AgentStreamEvent) {
+ if (event.type === "permission_requested") {
+ await session.respondToPermission(event.request.id, { behavior: "allow" });
+ }
+}
+
+describe("ClaudeAgentClient (SDK integration)", () => {
+ test(
+ "responds with text",
+ async () => {
+ const cwd = tmpCwd();
+ const client = new ClaudeAgentClient();
+ const config: AgentSessionConfig = {
+ provider: "claude",
+ cwd,
+ extra: { claude: { maxThinkingTokens: 1024 } },
+ };
+ const session = await client.createSession(config);
+
+ const result = await session.run(
+ "Reply with the single word ACK and then stop."
+ );
+
+ expect(result.finalText.toLowerCase()).toContain("ack");
+
+ await session.close();
+ rmSync(cwd, { recursive: true, force: true });
+ },
+ 120_000
+ );
+
+ test(
+ "streams reasoning chunks",
+ async () => {
+ const cwd = tmpCwd();
+ const client = new ClaudeAgentClient();
+ const config: AgentSessionConfig = {
+ provider: "claude",
+ cwd,
+ extra: { claude: { maxThinkingTokens: 2048 } },
+ };
+ const session = await client.createSession(config);
+
+ const events = session.stream(
+ "Think step by step about the pros and cons of single-file tests, but only share a short plan."
+ );
+
+ let sawReasoning = false;
+
+ for await (const event of events) {
+ await autoApprove(session, event);
+ if (event.type === "timeline" && event.item.type === "reasoning") {
+ sawReasoning = true;
+ }
+ if (event.type === "turn_completed" || event.type === "turn_failed") {
+ break;
+ }
+ }
+
+ expect(sawReasoning).toBe(true);
+
+ await session.close();
+ rmSync(cwd, { recursive: true, force: true });
+ },
+ 120_000
+ );
+
+ test(
+ "tracks permission + tool lifecycle when editing a file",
+ async () => {
+ const cwd = tmpCwd();
+ const client = new ClaudeAgentClient();
+ const config: AgentSessionConfig = {
+ provider: "claude",
+ cwd,
+ extra: { claude: { maxThinkingTokens: 1024 } },
+ };
+ const session = await client.createSession(config);
+
+ const events = session.stream(
+ "First run a Bash command to print the working directory, then use your editor tools (not the shell) to create a file named tool-test.txt in the current directory that contains exactly the text 'hello world'. Report 'done' after the write finishes."
+ );
+
+ const timeline: any[] = [];
+ let completed = false;
+
+ for await (const event of events) {
+ await autoApprove(session, event);
+ if (event.type === "timeline") {
+ timeline.push(event.item);
+ }
+ if (event.type === "turn_completed") {
+ completed = true;
+ break;
+ }
+ if (event.type === "turn_failed") {
+ break;
+ }
+ }
+
+ const toolEvent = timeline.find((item) => item.type === "mcp_tool");
+
+ expect(completed).toBe(true);
+ expect(toolEvent).toBeTruthy();
+
+ const filePath = path.join(cwd, "tool-test.txt");
+ expect(existsSync(filePath)).toBe(true);
+ expect(readFileSync(filePath, "utf8")).toContain("hello world");
+
+ await session.close();
+ rmSync(cwd, { recursive: true, force: true });
+ },
+ 180_000
+ );
+
+ test(
+ "supports multi-turn conversations",
+ async () => {
+ const cwd = tmpCwd();
+ const client = new ClaudeAgentClient();
+ const config: AgentSessionConfig = {
+ provider: "claude",
+ cwd,
+ extra: { claude: { maxThinkingTokens: 2048 } },
+ };
+ const session = await client.createSession(config);
+
+ const first = await session.run("Respond only with the word alpha.");
+ expect(first.finalText.toLowerCase()).toContain("alpha");
+
+ const second = await session.run(
+ "Without adding any explanations, repeat exactly the same word you just said."
+ );
+ expect(second.finalText.toLowerCase()).toContain("alpha");
+
+ await session.close();
+ rmSync(cwd, { recursive: true, force: true });
+ },
+ 120_000
+ );
+
+ test(
+ "resumes a persisted session",
+ async () => {
+ const cwd = tmpCwd();
+ const client = new ClaudeAgentClient();
+ const config: AgentSessionConfig = {
+ provider: "claude",
+ cwd,
+ extra: { claude: { maxThinkingTokens: 1024 } },
+ };
+ const session = await client.createSession(config);
+
+ const first = await session.run("Say READY and then stop.");
+ expect(first.finalText.toLowerCase()).toContain("ready");
+
+ const handle = session.describePersistence();
+ expect(handle).toBeTruthy();
+
+ await session.close();
+
+ const resumed = await client.resumeSession(handle!, { cwd });
+ const resumedResult = await resumed.run(
+ "Respond with the single word RESUMED."
+ );
+ expect(resumedResult.finalText.toLowerCase()).toContain("resumed");
+ await resumed.close();
+
+ rmSync(cwd, { recursive: true, force: true });
+ },
+ 150_000
+ );
+
+ test(
+ "updates session modes",
+ async () => {
+ const cwd = tmpCwd();
+ const client = new ClaudeAgentClient();
+ const config: AgentSessionConfig = {
+ provider: "claude",
+ cwd,
+ extra: { claude: { maxThinkingTokens: 1024 } },
+ };
+ const session = await client.createSession(config);
+
+ const modes = await session.getAvailableModes();
+ expect(modes.map((m) => m.id)).toContain("plan");
+
+ await session.setMode("plan");
+ expect(await session.getCurrentMode()).toBe("plan");
+
+ const result = await session.run(
+ "Just reply with the word PLAN to confirm you're still responsive."
+ );
+ expect(result.finalText.toLowerCase()).toContain("plan");
+
+ await session.close();
+ rmSync(cwd, { recursive: true, force: true });
+ },
+ 120_000
+ );
+
+ test(
+ "handles plan mode approval flow",
+ async () => {
+ const cwd = tmpCwd();
+ const client = new ClaudeAgentClient();
+ const config: AgentSessionConfig = {
+ provider: "claude",
+ cwd,
+ extra: { claude: { maxThinkingTokens: 2048 } },
+ };
+ const session = await client.createSession(config);
+ await session.setMode("plan");
+
+ const events = session.stream(
+ "Devise a plan to create a file named dummy.txt containing the word plan-test. After planning, proceed to execute your plan."
+ );
+
+ let sawPlan = false;
+ for await (const event of events) {
+ await autoApprove(session, event);
+ if (
+ event.type === "timeline" &&
+ event.item.type === "todo" &&
+ event.item.items.some((entry) => entry.text.includes("dummy.txt"))
+ ) {
+ sawPlan = true;
+ }
+
+ if (event.type === "turn_completed" || event.type === "turn_failed") {
+ break;
+ }
+ }
+
+ expect(sawPlan).toBe(true);
+ expect(await session.getCurrentMode()).toBe("acceptEdits");
+
+ const filePath = path.join(cwd, "dummy.txt");
+ expect(existsSync(filePath)).toBe(true);
+ expect(readFileSync(filePath, "utf8")).toContain("plan-test");
+
+ await session.close();
+ rmSync(cwd, { recursive: true, force: true });
+ },
+ 180_000
+ );
+});
diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts
new file mode 100644
index 000000000..199811926
--- /dev/null
+++ b/packages/server/src/server/agent/providers/claude-agent.ts
@@ -0,0 +1,707 @@
+import { randomUUID } from "node:crypto";
+import {
+ query,
+ type AgentDefinition,
+ type CanUseTool,
+ type McpServerConfig as ClaudeMcpServerConfig,
+ type Options as ClaudeOptions,
+ type PermissionMode,
+ type PermissionResult,
+ type PermissionUpdate,
+ type Query,
+ type SDKMessage,
+ type SDKPartialAssistantMessage,
+ type SDKResultMessage,
+ type SDKSystemMessage,
+ type SDKUserMessage,
+} from "@anthropic-ai/claude-agent-sdk";
+
+import type {
+ AgentCapabilityFlags,
+ AgentClient,
+ AgentMode,
+ AgentPermissionRequest,
+ AgentPermissionResponse,
+ AgentPermissionUpdate,
+ AgentPersistenceHandle,
+ AgentPromptInput,
+ AgentRunOptions,
+ AgentRunResult,
+ AgentSession,
+ AgentSessionConfig,
+ AgentStreamEvent,
+ AgentTimelineItem,
+ AgentUsage,
+} from "../agent-sdk-types.js";
+
+const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
+ supportsStreaming: true,
+ supportsSessionPersistence: true,
+ supportsDynamicModes: true,
+ supportsMcpServers: true,
+ supportsReasoningStream: true,
+ supportsToolInvocations: true,
+};
+
+const DEFAULT_MODES: AgentMode[] = [
+ {
+ id: "default",
+ label: "Always Ask",
+ description: "Prompts for permission the first time a tool is used",
+ },
+ {
+ id: "acceptEdits",
+ label: "Accept File Edits",
+ description: "Automatically approves edit-focused tools without prompting",
+ },
+ {
+ id: "plan",
+ label: "Plan Mode",
+ description: "Analyze the codebase without executing tools or edits",
+ },
+ {
+ id: "bypassPermissions",
+ label: "Bypass Permissions",
+ description: "Skip all permission prompts (use with caution)",
+ },
+];
+
+type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" };
+
+type ClaudeContentChunk = { type: string; [key: string]: any };
+
+type PendingPermission = {
+ request: AgentPermissionRequest;
+ resolve: (result: PermissionResult) => void;
+ reject: (error: Error) => void;
+ cleanup?: () => void;
+};
+
+export class ClaudeAgentClient implements AgentClient {
+ readonly provider = "claude" as const;
+ readonly capabilities = CLAUDE_CAPABILITIES;
+
+ constructor(private readonly defaults?: { agents?: Record }) {}
+
+ async createSession(config: AgentSessionConfig): Promise {
+ const claudeConfig = this.assertConfig(config);
+ return new ClaudeAgentSession(claudeConfig, this.defaults);
+ }
+
+ async resumeSession(
+ handle: AgentPersistenceHandle,
+ overrides?: Partial
+ ): Promise {
+ const metadata = (handle.metadata ?? {}) as Partial;
+ const merged = { ...metadata, ...overrides } as Partial;
+ if (!merged.cwd) {
+ throw new Error("Claude resume requires the original working directory in metadata");
+ }
+ const mergedConfig = { ...merged, provider: "claude" } as AgentSessionConfig;
+ const claudeConfig = this.assertConfig(mergedConfig);
+ return new ClaudeAgentSession(claudeConfig, this.defaults, handle);
+ }
+
+ private assertConfig(config: AgentSessionConfig): ClaudeAgentConfig {
+ if (config.provider !== "claude") {
+ throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`);
+ }
+ return config as ClaudeAgentConfig;
+ }
+}
+
+class ClaudeAgentSession implements AgentSession {
+ readonly provider = "claude" as const;
+ readonly capabilities = CLAUDE_CAPABILITIES;
+
+ private readonly config: ClaudeAgentConfig;
+ private readonly defaults?: { agents?: Record };
+ private query: Query | null = null;
+ private input: Pushable | null = null;
+ private claudeSessionId: string | null;
+ private pendingLocalId: string;
+ private persistence: AgentPersistenceHandle | null;
+ private currentMode: PermissionMode;
+ private availableModes: AgentMode[] = DEFAULT_MODES;
+ private toolUseCache = new Map();
+ private pendingPermissions = new Map();
+ private eventQueue: Pushable | null = null;
+
+ constructor(
+ config: ClaudeAgentConfig,
+ defaults?: { agents?: Record },
+ handle?: AgentPersistenceHandle
+ ) {
+ this.config = config;
+ this.defaults = defaults;
+ this.claudeSessionId = handle?.sessionId ?? null;
+ this.pendingLocalId = this.claudeSessionId ?? `claude-${randomUUID()}`;
+ this.persistence = handle ?? null;
+ this.currentMode = (config.modeId as PermissionMode) ?? "default";
+ }
+
+ get id(): string | null {
+ return this.claudeSessionId;
+ }
+
+ async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise {
+ const events = this.stream(prompt, options);
+ const timeline: AgentTimelineItem[] = [];
+ let finalText = "";
+ let usage: AgentUsage | undefined;
+
+ for await (const event of events) {
+ if (event.type === "timeline") {
+ timeline.push(event.item);
+ if (event.item.type === "assistant_message") {
+ finalText = event.item.text;
+ }
+ } else if (event.type === "turn_completed") {
+ usage = event.usage;
+ } else if (event.type === "turn_failed") {
+ throw new Error(event.error);
+ }
+ }
+
+ return {
+ sessionId: this.claudeSessionId ?? this.pendingLocalId,
+ finalText,
+ usage,
+ timeline,
+ };
+ }
+
+ async *stream(
+ prompt: AgentPromptInput,
+ _options?: AgentRunOptions
+ ): AsyncGenerator {
+ const sdkMessage = this.toSdkUserMessage(prompt);
+ const queue = new Pushable();
+ this.eventQueue = queue;
+ void this.forwardPromptEvents(sdkMessage, queue);
+
+ try {
+ for await (const event of queue) {
+ yield event;
+ if (event.type === "turn_completed" || event.type === "turn_failed") {
+ break;
+ }
+ }
+ } finally {
+ if (this.eventQueue === queue) {
+ this.eventQueue = null;
+ }
+ }
+ }
+
+ async *streamHistory(): AsyncGenerator {
+ // Claude Agent SDK does not provide a rollout replay API yet.
+ }
+
+ async getAvailableModes(): Promise {
+ return this.availableModes;
+ }
+
+ async getCurrentMode(): Promise {
+ return this.currentMode ?? null;
+ }
+
+ async setMode(modeId: string): Promise {
+ const normalized = modeId as PermissionMode;
+ const query = await this.ensureQuery();
+ await query.setPermissionMode(normalized);
+ this.currentMode = normalized;
+ }
+
+ getPendingPermissions(): AgentPermissionRequest[] {
+ return Array.from(this.pendingPermissions.values()).map((entry) => entry.request);
+ }
+
+ async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise {
+ const pending = this.pendingPermissions.get(requestId);
+ if (!pending) {
+ throw new Error(`No pending permission request with id '${requestId}'`);
+ }
+ this.pendingPermissions.delete(requestId);
+ pending.cleanup?.();
+
+ if (response.behavior === "allow") {
+ if (pending.request.kind === "plan") {
+ await this.setMode("acceptEdits");
+ this.enqueueTimeline({
+ type: "command",
+ command: "plan:approved",
+ status: "granted",
+ raw: pending.request,
+ });
+ }
+ const result: PermissionResult = {
+ behavior: "allow",
+ updatedInput: response.updatedInput ?? pending.request.input ?? {},
+ updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
+ };
+ pending.resolve(result);
+ this.enqueueTimeline({
+ type: "command",
+ command: `permission:${pending.request.name}`,
+ status: "granted",
+ raw: { request: pending.request, response },
+ });
+ } else {
+ const result: PermissionResult = {
+ behavior: "deny",
+ message: response.message ?? "Permission request denied",
+ interrupt: response.interrupt,
+ };
+ pending.resolve(result);
+ this.enqueueTimeline({
+ type: "command",
+ command: `permission:${pending.request.name}`,
+ status: "denied",
+ raw: { request: pending.request, response },
+ });
+ }
+
+ this.pushEvent({
+ type: "permission_resolved",
+ provider: "claude",
+ requestId,
+ resolution: response,
+ });
+ }
+
+ describePersistence(): AgentPersistenceHandle | null {
+ if (this.persistence) {
+ return this.persistence;
+ }
+ if (!this.claudeSessionId) {
+ return null;
+ }
+ this.persistence = {
+ provider: "claude",
+ sessionId: this.claudeSessionId,
+ nativeHandle: this.claudeSessionId,
+ metadata: this.config,
+ };
+ return this.persistence;
+ }
+
+ async close(): Promise {
+ this.rejectAllPendingPermissions(new Error("Claude session closed"));
+ this.input?.end();
+ await this.query?.return?.();
+ this.query = null;
+ this.input = null;
+ }
+
+ private async ensureQuery(): Promise {
+ if (this.query) {
+ return this.query;
+ }
+
+ const input = new Pushable();
+ const options = this.buildOptions();
+ this.input = input;
+ this.query = query({ prompt: input, options });
+ return this.query;
+ }
+
+ private buildOptions(): ClaudeOptions {
+ const base: ClaudeOptions = {
+ cwd: this.config.cwd,
+ includePartialMessages: true,
+ permissionMode: this.currentMode,
+ agents: this.defaults?.agents,
+ canUseTool: this.handlePermissionRequest,
+ ...this.config.extra?.claude,
+ };
+
+ if (this.config.mcpServers) {
+ base.mcpServers = this.normalizeMcpServers(this.config.mcpServers);
+ }
+ if (this.config.model) {
+ base.model = this.config.model;
+ }
+ if (this.claudeSessionId) {
+ base.resume = this.claudeSessionId;
+ base.continue = true;
+ }
+ return base;
+ }
+
+ private normalizeMcpServers(
+ servers: ClaudeAgentConfig["mcpServers"]
+ ): Record | undefined {
+ if (!servers) {
+ return undefined;
+ }
+ const result: Record = {};
+ for (const [name, config] of Object.entries(servers)) {
+ if (!config) continue;
+ if ("type" in config || "command" in (config as any) || "url" in (config as any)) {
+ result[name] = config as ClaudeMcpServerConfig;
+ }
+ }
+ return Object.keys(result).length ? result : undefined;
+ }
+
+ private toSdkUserMessage(prompt: AgentPromptInput): SDKUserMessage {
+ const text = Array.isArray(prompt)
+ ? prompt.map((chunk) => chunk.text).join("\n\n")
+ : prompt;
+
+ return {
+ type: "user",
+ message: {
+ role: "user",
+ content: [{ type: "text", text }],
+ },
+ parent_tool_use_id: null,
+ session_id: this.claudeSessionId ?? this.pendingLocalId,
+ };
+ }
+
+ private async *processPrompt(
+ sdkMessage: SDKUserMessage
+ ): AsyncGenerator {
+ const query = await this.ensureQuery();
+ if (!this.input) {
+ throw new Error("Claude session input stream not initialized");
+ }
+
+ this.input.push(sdkMessage);
+
+ while (true) {
+ const { value, done } = await query.next();
+ if (done) {
+ break;
+ }
+ if (!value) {
+ continue;
+ }
+ yield value;
+ if (value.type === "result") {
+ break;
+ }
+ }
+ }
+
+ private async forwardPromptEvents(message: SDKUserMessage, queue: Pushable) {
+ try {
+ for await (const sdkEvent of this.processPrompt(message)) {
+ const events = this.translateMessageToEvents(sdkEvent);
+ for (const event of events) {
+ queue.push(event);
+ }
+ }
+ } catch (error) {
+ queue.push({
+ type: "turn_failed",
+ provider: "claude",
+ error: error instanceof Error ? error.message : "Claude stream failed",
+ });
+ } finally {
+ queue.end();
+ }
+ }
+
+ private translateMessageToEvents(message: SDKMessage): AgentStreamEvent[] {
+ const events: AgentStreamEvent[] = [{ type: "provider_event", provider: "claude", raw: message }];
+
+ switch (message.type) {
+ case "system":
+ if (message.subtype === "init") {
+ this.handleSystemMessage(message as SDKSystemMessage);
+ }
+ break;
+ case "assistant": {
+ const timelineItems = this.mapBlocksToTimeline(message.message.content);
+ for (const item of timelineItems) {
+ events.push({ type: "timeline", item, provider: "claude" });
+ }
+ break;
+ }
+ case "stream_event": {
+ const timelineItems = this.mapPartialEvent(message.event);
+ for (const item of timelineItems) {
+ events.push({ type: "timeline", item, provider: "claude" });
+ }
+ break;
+ }
+ case "result": {
+ const usage = this.convertUsage(message);
+ if (message.subtype === "success") {
+ events.push({ type: "turn_completed", provider: "claude", usage });
+ } else {
+ const errorMessage =
+ "errors" in message && Array.isArray(message.errors) && message.errors.length > 0
+ ? message.errors.join("\n")
+ : "Claude run failed";
+ events.push({ type: "turn_failed", provider: "claude", error: errorMessage });
+ }
+ break;
+ }
+ default:
+ break;
+ }
+
+ return events;
+ }
+
+ private handleSystemMessage(message: SDKSystemMessage): void {
+ if (message.subtype !== "init") {
+ return;
+ }
+ this.claudeSessionId = message.session_id;
+ this.availableModes = DEFAULT_MODES;
+ this.currentMode = message.permissionMode;
+ this.persistence = null;
+ }
+
+ private convertUsage(message: SDKResultMessage): AgentUsage | undefined {
+ if (!message.usage) {
+ return undefined;
+ }
+ return {
+ inputTokens: message.usage.input_tokens,
+ cachedInputTokens: message.usage.cache_read_input_tokens,
+ outputTokens: message.usage.output_tokens,
+ totalCostUsd: message.total_cost_usd,
+ };
+ }
+
+ private handlePermissionRequest: CanUseTool = async (
+ toolName,
+ input,
+ options
+ ): Promise => {
+ if (toolName === "ExitPlanMode") {
+ this.emitPlanTodoItems(input);
+ }
+
+ const requestId = `permission-${randomUUID()}`;
+ const metadata: Record = {};
+ const permissionOptions = options as { toolUseID?: string } | undefined;
+ if (permissionOptions?.toolUseID) {
+ metadata.toolUseId = permissionOptions.toolUseID;
+ }
+ if (toolName === "ExitPlanMode" && typeof input.plan === "string") {
+ metadata.planText = input.plan;
+ }
+
+ const request: AgentPermissionRequest = {
+ id: requestId,
+ provider: "claude",
+ name: toolName,
+ kind: toolName === "ExitPlanMode" ? "plan" : "tool",
+ input,
+ suggestions: options?.suggestions as AgentPermissionUpdate[] | undefined,
+ metadata: Object.keys(metadata).length ? metadata : undefined,
+ raw: { toolName, input, options },
+ };
+
+ this.enqueueTimeline({
+ type: "command",
+ command: `permission:${toolName}`,
+ status: "requested",
+ raw: { toolName, input },
+ });
+
+ this.pushEvent({ type: "permission_requested", provider: "claude", request });
+
+ return await new Promise((resolve, reject) => {
+ let cleanup: (() => void) | undefined;
+ const abortHandler = () => {
+ cleanup?.();
+ this.pendingPermissions.delete(requestId);
+ reject(new Error("Permission request aborted"));
+ };
+
+ if (options?.signal) {
+ if (options.signal.aborted) {
+ abortHandler();
+ return;
+ }
+ options.signal.addEventListener("abort", abortHandler, { once: true });
+ cleanup = () => options.signal?.removeEventListener("abort", abortHandler);
+ }
+
+ this.pendingPermissions.set(requestId, {
+ request,
+ resolve,
+ reject,
+ cleanup,
+ });
+ });
+ };
+
+ private emitPlanTodoItems(input: Record) {
+ const planText = typeof input.plan === "string" ? input.plan : JSON.stringify(input);
+ const todoItems = planText
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((text) => ({ text, completed: false }));
+ this.enqueueTimeline({
+ type: "todo",
+ items: todoItems.length > 0 ? todoItems : [{ text: planText, completed: false }],
+ raw: input,
+ });
+ }
+
+ private enqueueTimeline(item: AgentTimelineItem) {
+ this.pushEvent({ type: "timeline", item, provider: "claude" });
+ }
+
+ private pushEvent(event: AgentStreamEvent) {
+ if (this.eventQueue) {
+ this.eventQueue.push(event);
+ }
+ }
+
+ private normalizePermissionUpdates(
+ updates?: AgentPermissionUpdate[]
+ ): PermissionUpdate[] | undefined {
+ if (!updates || updates.length === 0) {
+ return undefined;
+ }
+ return updates as PermissionUpdate[];
+ }
+
+ private rejectAllPendingPermissions(error: Error) {
+ for (const [id, pending] of this.pendingPermissions) {
+ pending.cleanup?.();
+ pending.reject(error);
+ this.pendingPermissions.delete(id);
+ }
+ }
+
+ private mapBlocksToTimeline(content: string | ClaudeContentChunk[]): AgentTimelineItem[] {
+ if (typeof content === "string") {
+ if (!content || content === "[Request interrupted by user for tool use]") {
+ return [];
+ }
+ return [{ type: "assistant_message", text: content, raw: content }];
+ }
+
+ const items: AgentTimelineItem[] = [];
+ for (const block of content) {
+ switch (block.type) {
+ case "text":
+ case "text_delta":
+ if (block.text && block.text !== "[Request interrupted by user for tool use]") {
+ items.push({ type: "assistant_message", text: block.text, raw: block });
+ }
+ break;
+ case "thinking":
+ case "thinking_delta":
+ if (block.thinking) {
+ items.push({ type: "reasoning", text: block.thinking, raw: block });
+ }
+ break;
+ case "tool_use":
+ case "server_tool_use":
+ case "mcp_tool_use": {
+ const name = block.name ?? "tool";
+ const server = block.server ?? name;
+ this.toolUseCache.set(block.id, { name, server });
+ this.enqueueTimeline({
+ type: "command",
+ command: `permission:${name}`,
+ status: "granted",
+ raw: block,
+ });
+ items.push({
+ type: "mcp_tool",
+ server,
+ tool: name,
+ status: "pending",
+ raw: block,
+ });
+ break;
+ }
+ case "tool_result":
+ case "mcp_tool_result":
+ case "web_fetch_tool_result":
+ case "web_search_tool_result":
+ case "code_execution_tool_result":
+ case "bash_code_execution_tool_result":
+ case "text_editor_code_execution_tool_result": {
+ const cached = block.tool_use_id
+ ? this.toolUseCache.get(block.tool_use_id)
+ : undefined;
+ const server = cached?.server ?? block.server ?? "tool";
+ const tool = cached?.name ?? block.tool_name ?? "tool";
+ items.push({
+ type: "mcp_tool",
+ server,
+ tool,
+ status: block.is_error ? "failed" : "completed",
+ raw: block,
+ });
+ if (!block.is_error && block.tool_use_id) {
+ this.toolUseCache.delete(block.tool_use_id);
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ }
+ return items;
+ }
+
+ private mapPartialEvent(event: SDKPartialAssistantMessage["event"]): AgentTimelineItem[] {
+ switch (event.type) {
+ case "content_block_start":
+ return this.mapBlocksToTimeline([event.content_block as ClaudeContentChunk]);
+ case "content_block_delta":
+ return this.mapBlocksToTimeline([event.delta as ClaudeContentChunk]);
+ default:
+ return [];
+ }
+ }
+}
+
+class Pushable implements AsyncIterable {
+ private queue: T[] = [];
+ private resolvers: ((value: IteratorResult) => void)[] = [];
+ private closed = false;
+
+ push(item: T) {
+ if (this.closed) {
+ return;
+ }
+ if (this.resolvers.length > 0) {
+ const resolve = this.resolvers.shift()!;
+ resolve({ value: item, done: false });
+ } else {
+ this.queue.push(item);
+ }
+ }
+
+ end() {
+ this.closed = true;
+ while (this.resolvers.length > 0) {
+ const resolve = this.resolvers.shift()!;
+ resolve({ value: undefined as any, done: true });
+ }
+ }
+
+ [Symbol.asyncIterator](): AsyncIterator {
+ return {
+ next: (): Promise> => {
+ if (this.queue.length > 0) {
+ const value = this.queue.shift()!;
+ return Promise.resolve({ value, done: false });
+ }
+ if (this.closed) {
+ return Promise.resolve({ value: undefined as any, done: true });
+ }
+ return new Promise>((resolve) => {
+ this.resolvers.push(resolve);
+ });
+ },
+ };
+ }
+}
diff --git a/packages/server/src/server/agent/providers/codex-agent.test.ts b/packages/server/src/server/agent/providers/codex-agent.test.ts
new file mode 100644
index 000000000..cd91d1413
--- /dev/null
+++ b/packages/server/src/server/agent/providers/codex-agent.test.ts
@@ -0,0 +1,225 @@
+import { describe, expect, test } from "vitest";
+import { mkdtempSync, rmSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+import { CodexAgentClient } from "./codex-agent.js";
+import type { AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js";
+
+function tmpCwd(): string {
+ return mkdtempSync(path.join(os.tmpdir(), "codex-agent-e2e-"));
+}
+
+function useTempCodexSessionDir(): () => void {
+ const dir = mkdtempSync(path.join(os.tmpdir(), "codex-agent-session-"));
+ const previous = process.env.CODEX_SESSION_DIR;
+ process.env.CODEX_SESSION_DIR = dir;
+ return () => {
+ if (previous === undefined) {
+ delete process.env.CODEX_SESSION_DIR;
+ } else {
+ process.env.CODEX_SESSION_DIR = previous;
+ }
+ rmSync(dir, { recursive: true, force: true });
+ };
+}
+
+function log(message: string): void {
+ console.info(`[CodexAgentTest] ${message}`);
+}
+
+describe("CodexAgentClient (SDK integration)", () => {
+ test(
+ "responds with text",
+ async () => {
+ const cwd = tmpCwd();
+ const restoreSessionDir = useTempCodexSessionDir();
+ const client = new CodexAgentClient();
+ const config: AgentSessionConfig = { provider: "codex", cwd, modeId: "full-access" };
+ let session: Awaited> | null = null;
+ try {
+ session = await client.createSession(config);
+ log("Running single-turn acknowledgment test");
+
+ const result = await session.run("Reply with the single word ACK and then stop.");
+
+ expect(result.finalText.toLowerCase()).toContain("ack");
+ } finally {
+ await session?.close();
+ rmSync(cwd, { recursive: true, force: true });
+ restoreSessionDir();
+ }
+ },
+ 120_000
+ );
+
+ test(
+ "emits tool or command events when writing a file",
+ async () => {
+ const cwd = tmpCwd();
+ const restoreSessionDir = useTempCodexSessionDir();
+ const client = new CodexAgentClient();
+ const config: AgentSessionConfig = { provider: "codex", cwd };
+ let session: Awaited> | null = null;
+ try {
+ session = await client.createSession(config);
+ log("Streaming file creation activity");
+
+ const events = session.stream(
+ "Create a file named tool-test.txt in the current directory with the contents 'hello world', then stop."
+ );
+
+ let sawActivity = false;
+
+ for await (const event of events) {
+ if (
+ event.type === "timeline" &&
+ event.provider === "codex" &&
+ (event.item.type === "file_change" || event.item.type === "command" || event.item.type === "mcp_tool")
+ ) {
+ sawActivity = true;
+ }
+ if (
+ event.type === "provider_event" &&
+ event.provider === "codex" &&
+ event.raw?.type &&
+ event.raw.type.startsWith("item") &&
+ ["file_change", "command_execution", "mcp_tool_call"].includes((event.raw as any).item?.type)
+ ) {
+ sawActivity = true;
+ }
+
+ if (event.type === "turn_completed" || event.type === "turn_failed") {
+ break;
+ }
+ }
+
+ expect(sawActivity).toBe(true);
+ } finally {
+ await session?.close();
+ rmSync(cwd, { recursive: true, force: true });
+ restoreSessionDir();
+ }
+ },
+ 180_000
+ );
+
+ test(
+ "supports multiple turns within the same session",
+ async () => {
+ const cwd = tmpCwd();
+ const restoreSessionDir = useTempCodexSessionDir();
+ const client = new CodexAgentClient();
+ const config: AgentSessionConfig = { provider: "codex", cwd };
+ let session: Awaited> | null = null;
+ try {
+ session = await client.createSession(config);
+ log("Running multi-turn continuity test");
+
+ const first = await session.run("Reply with the exact text ACK-ONE and then stop.");
+ expect(first.finalText.toLowerCase()).toContain("ack-one");
+
+ const handleAfterFirst = session.describePersistence();
+ expect(handleAfterFirst?.sessionId).toBeTruthy();
+
+ const second = await session.run("Now reply with the exact text ACK-TWO and then stop.");
+ expect(second.finalText.toLowerCase()).toContain("ack-two");
+
+ const handleAfterSecond = session.describePersistence();
+ expect(handleAfterSecond?.sessionId).toBe(handleAfterFirst?.sessionId);
+ } finally {
+ await session?.close();
+ rmSync(cwd, { recursive: true, force: true });
+ restoreSessionDir();
+ }
+ },
+ 150_000
+ );
+
+ test(
+ "can change modes mid-session",
+ async () => {
+ const cwd = tmpCwd();
+ const restoreSessionDir = useTempCodexSessionDir();
+ const client = new CodexAgentClient();
+ const config: AgentSessionConfig = { provider: "codex", cwd, modeId: "auto" };
+ let session: Awaited> | null = null;
+ try {
+ session = await client.createSession(config);
+ log("Testing mode transitions inside a single session");
+
+ expect(await session.getCurrentMode()).toBe("auto");
+
+ const first = await session.run("Reply with ACK-MODE-AUTO and then stop.");
+ expect(first.finalText.toLowerCase()).toContain("ack-mode-auto");
+
+ await session.setMode("full-access");
+ expect(await session.getCurrentMode()).toBe("full-access");
+
+ const second = await session.run("Reply with ACK-MODE-FULL and then stop.");
+ expect(second.finalText.toLowerCase()).toContain("ack-mode-full");
+ } finally {
+ await session?.close();
+ rmSync(cwd, { recursive: true, force: true });
+ restoreSessionDir();
+ }
+ },
+ 180_000
+ );
+
+ test(
+ "resumes a session using a persistence handle",
+ async () => {
+ const cwd = tmpCwd();
+ const restoreSessionDir = useTempCodexSessionDir();
+ const client = new CodexAgentClient();
+ const config: AgentSessionConfig = { provider: "codex", cwd };
+ let session: Awaited> | null = null;
+ let resumed: Awaited> | null = null;
+ try {
+ session = await client.createSession(config);
+ log("Recording initial turn before persistence");
+
+ await session.run("Remember the word ALPHA and confirm with ACK.");
+ const handle = session.describePersistence();
+ expect(handle).not.toBeNull();
+ expect(handle?.sessionId).toBeTruthy();
+
+ await session.close();
+ session = null;
+
+ log("Resuming session from persistence handle");
+ resumed = await client.resumeSession(handle!);
+ const replayedHistory: AgentStreamEvent[] = [];
+ for await (const event of resumed.streamHistory()) {
+ replayedHistory.push(event);
+ }
+ log(
+ `Replayed ${replayedHistory.length} history events: ${JSON.stringify(
+ replayedHistory.map((e) => ({ type: e.type, timelineType: e.type === "timeline" ? e.item.type : undefined })),
+ null,
+ 2
+ )}`
+ );
+ expect(
+ replayedHistory.some(
+ (event) =>
+ event.type === "timeline" && event.provider === "codex" && event.item.type === "assistant_message"
+ )
+ ).toBe(true);
+
+ const response = await resumed.run("Respond with ACK-RESUMED and then stop.");
+ expect(response.finalText.toLowerCase()).toContain("ack-resumed");
+
+ const resumedHandle = resumed.describePersistence();
+ expect(resumedHandle?.sessionId).toBe(handle?.sessionId);
+ } finally {
+ await session?.close();
+ await resumed?.close();
+ rmSync(cwd, { recursive: true, force: true });
+ restoreSessionDir();
+ }
+ },
+ 180_000
+ );
+});
diff --git a/packages/server/src/server/agent/providers/codex-agent.ts b/packages/server/src/server/agent/providers/codex-agent.ts
new file mode 100644
index 000000000..070ad9c65
--- /dev/null
+++ b/packages/server/src/server/agent/providers/codex-agent.ts
@@ -0,0 +1,899 @@
+import { randomUUID } from "node:crypto";
+import { promises as fs, constants as fsConstants } from "node:fs";
+import type { Dirent } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+import {
+ Codex,
+ type ApprovalMode,
+ type CodexOptions,
+ type Input as CodexInput,
+ type ModelReasoningEffort,
+ type SandboxMode,
+ type Thread,
+ type ThreadEvent,
+ type ThreadItem,
+ type ThreadOptions,
+ type TurnOptions,
+ type Usage as CodexUsage,
+} from "@openai/codex-sdk";
+
+import type {
+ AgentCapabilityFlags,
+ AgentClient,
+ AgentMode,
+ AgentPermissionRequest,
+ AgentPermissionResponse,
+ AgentPersistenceHandle,
+ AgentPromptInput,
+ AgentRunOptions,
+ AgentRunResult,
+ AgentSession,
+ AgentSessionConfig,
+ AgentStreamEvent,
+ AgentTimelineItem,
+ AgentUsage,
+} from "../agent-sdk-types.js";
+
+type CodexAgentConfig = AgentSessionConfig & { provider: "codex" };
+
+const CODEX_CAPABILITIES: AgentCapabilityFlags = {
+ supportsStreaming: true,
+ supportsSessionPersistence: true,
+ supportsDynamicModes: false,
+ supportsMcpServers: false,
+ supportsReasoningStream: true,
+ supportsToolInvocations: true,
+};
+
+const CODEX_MODES: AgentMode[] = [
+ {
+ id: "read-only",
+ label: "Read Only",
+ description:
+ "Codex can read files and answer questions. Manual approval required for edits, commands, or network ops.",
+ },
+ {
+ id: "auto",
+ label: "Auto",
+ description:
+ "Codex can edit files and run commands inside the workspace but still asks before escalating scope.",
+ },
+ {
+ id: "full-access",
+ label: "Full Access",
+ description: "Codex can edit files, run commands, and access the network without additional prompts.",
+ },
+];
+
+const DEFAULT_CODEX_MODE_ID = "auto";
+
+const MODE_PRESETS: Record<
+ string,
+ Partial & { networkAccessEnabled?: boolean; webSearchEnabled?: boolean }
+> = {
+ "read-only": {
+ sandboxMode: "read-only",
+ approvalPolicy: "untrusted",
+ networkAccessEnabled: false,
+ webSearchEnabled: false,
+ },
+ auto: {
+ sandboxMode: "workspace-write",
+ approvalPolicy: "on-request",
+ networkAccessEnabled: false,
+ webSearchEnabled: false,
+ },
+ "full-access": {
+ sandboxMode: "danger-full-access",
+ approvalPolicy: "never",
+ networkAccessEnabled: true,
+ webSearchEnabled: true,
+ },
+};
+
+type CodexOptionsOverrides = Partial & { skipGitRepoCheck?: boolean };
+
+const MAX_ROLLOUT_SEARCH_DEPTH = 5;
+
+function coerceSandboxMode(value?: string): SandboxMode | undefined {
+ if (!value) return undefined;
+ if (value === "read-only" || value === "workspace-write" || value === "danger-full-access") {
+ return value;
+ }
+ return undefined;
+}
+
+function coerceApprovalMode(value?: string): ApprovalMode | undefined {
+ if (!value) return undefined;
+ if (value === "never" || value === "on-request" || value === "on-failure" || value === "untrusted") {
+ return value;
+ }
+ return undefined;
+}
+
+function coerceReasoningEffort(value?: string): ModelReasoningEffort | undefined {
+ if (!value) return undefined;
+ if (value === "minimal" || value === "low" || value === "medium" || value === "high") {
+ return value;
+ }
+ return undefined;
+}
+
+function normalizeExtraOptions(extra?: Record): CodexOptionsOverrides | undefined {
+ if (!extra) return undefined;
+ const allowedKeys = new Set([
+ "model",
+ "sandboxMode",
+ "skipGitRepoCheck",
+ "modelReasoningEffort",
+ "networkAccessEnabled",
+ "webSearchEnabled",
+ "approvalPolicy",
+ ]);
+
+ const normalized: CodexOptionsOverrides = {};
+ for (const [key, value] of Object.entries(extra)) {
+ if (!allowedKeys.has(key)) continue;
+ (normalized as Record)[key] = value;
+ }
+ return normalized;
+}
+
+export class CodexAgentClient implements AgentClient {
+ readonly provider = "codex" as const;
+ readonly capabilities = CODEX_CAPABILITIES;
+ private readonly codex: Codex;
+
+ constructor(options?: CodexOptions) {
+ this.codex = new Codex(options);
+ }
+
+ async createSession(config: AgentSessionConfig): Promise {
+ const codexConfig = this.assertConfig(config);
+ return CodexAgentSession.create(this.codex, codexConfig);
+ }
+
+ async resumeSession(handle: AgentPersistenceHandle, overrides?: Partial): Promise {
+ const metadata = (handle.metadata ?? {}) as Partial;
+ const merged = { ...metadata, ...overrides } as Partial;
+ if (!merged.cwd) {
+ throw new Error("Codex resume requires the original working directory in metadata");
+ }
+ const mergedConfig = { ...merged, provider: "codex" } as AgentSessionConfig;
+ const codexConfig = this.assertConfig(mergedConfig);
+ return CodexAgentSession.create(this.codex, codexConfig, handle);
+ }
+
+ private assertConfig(config: AgentSessionConfig): CodexAgentConfig {
+ if (config.provider !== "codex") {
+ throw new Error(`CodexAgentClient received config for provider '${config.provider}'`);
+ }
+ return config as CodexAgentConfig;
+ }
+}
+
+class CodexAgentSession implements AgentSession {
+ static async create(codex: Codex, config: CodexAgentConfig, handle?: AgentPersistenceHandle): Promise {
+ const session = new CodexAgentSession(codex, config, handle);
+ if (handle) {
+ await session.loadReplayHistory(handle);
+ }
+ return session;
+ }
+
+ readonly provider = "codex" as const;
+ readonly capabilities = CODEX_CAPABILITIES;
+
+ private readonly codex: Codex;
+ private config: CodexAgentConfig;
+ private threadOptions: ThreadOptions;
+ private thread: Thread | null;
+ private threadId: string | null;
+ private persistence: AgentPersistenceHandle | null;
+ private pendingLocalId: string;
+ private currentMode: string;
+ private availableModes: AgentMode[] = CODEX_MODES;
+ private readonly codexSessionDir: string | null;
+ private rolloutPath: string | null;
+ private historyEvents: AgentStreamEvent[] = [];
+
+ constructor(codex: Codex, config: CodexAgentConfig, handle?: AgentPersistenceHandle) {
+ this.codex = codex;
+ this.config = { ...config };
+ this.currentMode = this.config.modeId ?? DEFAULT_CODEX_MODE_ID;
+ this.threadOptions = this.buildThreadOptions(this.currentMode);
+ this.codexSessionDir = this.resolveCodexSessionDir(handle);
+ this.rolloutPath = this.readRolloutPath(handle);
+ this.threadId = handle?.sessionId ?? handle?.nativeHandle ?? null;
+ this.pendingLocalId = this.threadId ?? `codex-${randomUUID()}`;
+ this.persistence = handle ?? null;
+ if (handle && !this.threadId) {
+ throw new Error("Codex resume requires a thread id");
+ }
+ this.thread = handle
+ ? this.codex.resumeThread(this.threadId!, this.threadOptions)
+ : this.codex.startThread(this.threadOptions);
+ }
+
+ get id(): string | null {
+ return this.threadId;
+ }
+
+ async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise {
+ const events = this.stream(prompt, options);
+ const timeline: AgentTimelineItem[] = [];
+ let finalText = "";
+ let usage: AgentUsage | undefined;
+
+ for await (const event of events) {
+ if (event.type === "timeline") {
+ timeline.push(event.item);
+ if (event.item.type === "assistant_message") {
+ finalText = event.item.text;
+ }
+ } else if (event.type === "turn_completed") {
+ usage = event.usage;
+ } else if (event.type === "turn_failed") {
+ throw new Error(event.error);
+ }
+ }
+
+ return {
+ sessionId: this.threadId ?? this.pendingLocalId,
+ finalText,
+ usage,
+ timeline,
+ };
+ }
+
+ async *stream(prompt: AgentPromptInput, options?: AgentRunOptions): AsyncGenerator {
+ const thread = this.thread;
+ if (!thread) {
+ throw new Error("Codex session is closed");
+ }
+
+ const replayEvents = this.drainHistoryEvents();
+ for (const event of replayEvents) {
+ yield event;
+ }
+
+ const input = this.toCodexInput(prompt);
+ const turnOptions = this.buildTurnOptions(options);
+ const { events } = await thread.runStreamed(input, turnOptions);
+
+ for await (const rawEvent of events) {
+ yield* this.translateEvent(rawEvent);
+ if (rawEvent.type === "turn.completed" || rawEvent.type === "turn.failed" || rawEvent.type === "error") {
+ break;
+ }
+ }
+ }
+
+ async *streamHistory(): AsyncGenerator {
+ const replayEvents = this.drainHistoryEvents();
+ for (const event of replayEvents) {
+ yield event;
+ }
+ }
+
+ async getAvailableModes(): Promise {
+ return this.availableModes;
+ }
+
+ async getCurrentMode(): Promise {
+ return this.currentMode ?? null;
+ }
+
+ async setMode(modeId: string): Promise {
+ if (modeId === this.currentMode) {
+ return;
+ }
+ if (!this.availableModes.some((mode) => mode.id === modeId)) {
+ throw new Error(`Mode '${modeId}' not supported by Codex`);
+ }
+
+ this.currentMode = modeId;
+ this.config.modeId = modeId;
+ this.threadOptions = this.buildThreadOptions(modeId);
+
+ if (this.threadId) {
+ this.thread = this.codex.resumeThread(this.threadId, this.threadOptions);
+ } else {
+ this.pendingLocalId = `codex-${randomUUID()}`;
+ this.thread = this.codex.startThread(this.threadOptions);
+ }
+ }
+
+ getPendingPermissions(): AgentPermissionRequest[] {
+ return [];
+ }
+
+ async respondToPermission(_requestId: string, _response: AgentPermissionResponse): Promise {
+ throw new Error("Codex permission responses are not implemented yet");
+ }
+
+ describePersistence(): AgentPersistenceHandle | null {
+ if (this.persistence) {
+ return this.persistence;
+ }
+ if (!this.threadId) {
+ return null;
+ }
+ this.persistence = {
+ provider: "codex",
+ sessionId: this.threadId,
+ nativeHandle: this.threadId,
+ metadata: {
+ ...this.config,
+ codexSessionDir: this.codexSessionDir ?? undefined,
+ codexRolloutPath: this.rolloutPath ?? undefined,
+ },
+ };
+ return this.persistence;
+ }
+
+ async close(): Promise {
+ this.thread = null;
+ }
+
+ private drainHistoryEvents(): AgentStreamEvent[] {
+ if (!this.historyEvents.length) {
+ return [];
+ }
+ const events = [...this.historyEvents];
+ this.historyEvents = [];
+ return events;
+ }
+
+ private buildThreadOptions(modeId: string): ThreadOptions {
+ const options: ThreadOptions = {
+ workingDirectory: this.config.cwd,
+ skipGitRepoCheck: true,
+ };
+
+ const preset = MODE_PRESETS[modeId] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
+ if (preset) {
+ Object.assign(options, preset);
+ }
+
+ if (this.config.model) {
+ options.model = this.config.model;
+ }
+ if (this.config.sandboxMode) {
+ options.sandboxMode = coerceSandboxMode(this.config.sandboxMode) ?? options.sandboxMode;
+ }
+ if (this.config.approvalPolicy) {
+ options.approvalPolicy = coerceApprovalMode(this.config.approvalPolicy) ?? options.approvalPolicy;
+ }
+ if (typeof this.config.networkAccess === "boolean") {
+ options.networkAccessEnabled = this.config.networkAccess;
+ }
+ if (typeof this.config.webSearch === "boolean") {
+ options.webSearchEnabled = this.config.webSearch;
+ }
+ if (this.config.reasoningEffort) {
+ options.modelReasoningEffort =
+ coerceReasoningEffort(this.config.reasoningEffort) ?? options.modelReasoningEffort;
+ }
+
+ const extra = normalizeExtraOptions(this.config.extra?.codex as Record | undefined);
+ if (extra) {
+ Object.assign(options, extra);
+ }
+
+ return options;
+ }
+
+ private async loadReplayHistory(handle: AgentPersistenceHandle): Promise {
+ const threadIdentifier = handle.sessionId ?? handle.nativeHandle;
+ if (!threadIdentifier || !this.codexSessionDir) {
+ return;
+ }
+
+ let rolloutFile = this.rolloutPath;
+ if (rolloutFile && !(await fileExists(rolloutFile))) {
+ rolloutFile = null;
+ }
+
+ if (!rolloutFile) {
+ rolloutFile = await findRolloutFile(threadIdentifier, this.codexSessionDir);
+ }
+
+ if (!rolloutFile) {
+ return;
+ }
+
+ this.rolloutPath = rolloutFile;
+
+ try {
+ const events = await parseRolloutFile(rolloutFile, threadIdentifier);
+ if (events.length) {
+ this.historyEvents = events;
+ }
+ } catch (error) {
+ console.warn(`[CodexAgentSession] Failed to parse rollout ${rolloutFile}:`, error);
+ }
+ }
+
+ private resolveCodexSessionDir(handle?: AgentPersistenceHandle): string | null {
+ const metadataDir = readMetadataString(handle, "codexSessionDir");
+ if (metadataDir) {
+ return metadataDir;
+ }
+
+ const extraDir = readCodexExtraString(this.config, "sessionDir");
+ if (extraDir) {
+ return extraDir;
+ }
+
+ if (process.env.CODEX_SESSION_DIR) {
+ return process.env.CODEX_SESSION_DIR;
+ }
+
+ const extraHome = readCodexExtraString(this.config, "home");
+ const codexHome = extraHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
+ return path.join(codexHome, "sessions");
+ }
+
+ private readRolloutPath(handle?: AgentPersistenceHandle): string | null {
+ const metadataRollout = readMetadataString(handle, "codexRolloutPath");
+ if (metadataRollout) {
+ return metadataRollout;
+ }
+ const extraRollout = readCodexExtraString(this.config, "rolloutPath");
+ return extraRollout ?? null;
+ }
+
+ private buildTurnOptions(options?: AgentRunOptions): TurnOptions {
+ if (!options?.outputSchema) {
+ return {};
+ }
+ return { outputSchema: options.outputSchema };
+ }
+
+ private toCodexInput(prompt: AgentPromptInput): CodexInput {
+ if (typeof prompt === "string") {
+ return prompt;
+ }
+ return prompt.map((chunk) => ({ type: "text", text: chunk.text }));
+ }
+
+ private *translateEvent(event: ThreadEvent): Generator {
+ yield { type: "provider_event", provider: "codex", raw: event };
+
+ switch (event.type) {
+ case "thread.started":
+ this.threadId = event.thread_id;
+ this.pendingLocalId = event.thread_id;
+ this.persistence = null;
+ yield { type: "thread_started", provider: "codex", sessionId: event.thread_id };
+ break;
+ case "turn.started":
+ yield { type: "turn_started", provider: "codex" };
+ break;
+ case "turn.completed":
+ yield {
+ type: "turn_completed",
+ provider: "codex",
+ usage: this.convertUsage(event.usage),
+ };
+ break;
+ case "turn.failed":
+ yield {
+ type: "turn_failed",
+ provider: "codex",
+ error: event.error?.message ?? "Codex turn failed",
+ };
+ break;
+ case "item.started":
+ case "item.updated":
+ case "item.completed": {
+ const item = this.threadItemToTimeline(event.item);
+ if (item) {
+ yield { type: "timeline", provider: "codex", item };
+ }
+ break;
+ }
+ case "error": {
+ const message = event.message ?? "Codex stream error";
+ yield {
+ type: "timeline",
+ provider: "codex",
+ item: { type: "error", message, raw: event },
+ };
+ yield { type: "turn_failed", provider: "codex", error: message };
+ break;
+ }
+ default:
+ break;
+ }
+ }
+
+ private threadItemToTimeline(item: ThreadItem): AgentTimelineItem | null {
+ switch (item.type) {
+ case "agent_message":
+ return { type: "assistant_message", text: item.text, raw: item };
+ case "reasoning":
+ return { type: "reasoning", text: item.text, raw: item };
+ case "command_execution":
+ return { type: "command", command: item.command, status: item.status, raw: item };
+ case "file_change":
+ return {
+ type: "file_change",
+ files: item.changes.map((change) => ({ path: change.path, kind: change.kind })),
+ raw: item,
+ };
+ case "mcp_tool_call":
+ return {
+ type: "mcp_tool",
+ server: item.server,
+ tool: item.tool,
+ status: item.status,
+ raw: item,
+ };
+ case "web_search":
+ return { type: "web_search", query: item.query, raw: item };
+ case "todo_list":
+ return { type: "todo", items: item.items, raw: item };
+ case "error":
+ return { type: "error", message: item.message, raw: item };
+ default:
+ return null;
+ }
+ }
+
+ private convertUsage(usage?: CodexUsage): AgentUsage | undefined {
+ if (!usage) {
+ return undefined;
+ }
+ return {
+ inputTokens: usage.input_tokens,
+ cachedInputTokens: usage.cached_input_tokens,
+ outputTokens: usage.output_tokens,
+ };
+ }
+}
+
+async function fileExists(filePath: string): Promise {
+ try {
+ await fs.access(filePath, fsConstants.F_OK);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function findRolloutFile(threadId: string, root: string): Promise {
+ const stack: { dir: string; depth: number }[] = [{ dir: root, depth: 0 }];
+ while (stack.length > 0) {
+ const { dir, depth } = stack.pop()!;
+ let entries: Dirent[];
+ try {
+ entries = await fs.readdir(dir, { withFileTypes: true });
+ } catch {
+ continue;
+ }
+ for (const entry of entries) {
+ const entryPath = path.join(dir, entry.name);
+ if (entry.isFile()) {
+ const matchesThread = entry.name.includes(threadId);
+ const matchesPrefix = entry.name.startsWith("rollout-");
+ const matchesExtension = entry.name.endsWith(".json") || entry.name.endsWith(".jsonl");
+ if (matchesThread && matchesPrefix && matchesExtension) {
+ return entryPath;
+ }
+ } else if (entry.isDirectory() && depth < MAX_ROLLOUT_SEARCH_DEPTH) {
+ stack.push({ dir: entryPath, depth: depth + 1 });
+ }
+ }
+ }
+ return null;
+}
+
+async function parseRolloutFile(filePath: string, threadId: string): Promise {
+ const content = await fs.readFile(filePath, "utf8");
+ const lines = content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
+ const events: AgentStreamEvent[] = [{ type: "thread_started", provider: "codex", sessionId: threadId }];
+ const commandCalls = new Map();
+
+ for (const line of lines) {
+ const entry = parseRolloutEntry(line);
+ if (!entry) continue;
+
+ if (entry.type === "response_item") {
+ handleRolloutResponseItem(entry.payload, events, commandCalls);
+ } else if (entry.type === "event_msg") {
+ handleRolloutEventMessage(entry.payload, events);
+ }
+ }
+
+ return events;
+}
+
+function parseRolloutEntry(line: string): { type: string; payload?: any } | null {
+ if (!line) {
+ return null;
+ }
+ try {
+ const parsed = JSON.parse(line);
+ if (parsed && typeof parsed === "object" && "type" in parsed) {
+ return parsed as { type: string; payload?: any };
+ }
+ if (parsed && typeof parsed === "object" && typeof (parsed as any).output === "string") {
+ return parseRolloutEntry((parsed as any).output);
+ }
+ } catch {
+ return null;
+ }
+ return null;
+}
+
+function handleRolloutResponseItem(
+ payload: any,
+ events: AgentStreamEvent[],
+ commandCalls: Map
+): void {
+ if (!payload || typeof payload !== "object") {
+ return;
+ }
+
+ switch (payload.type) {
+ case "message": {
+ if (payload.role !== "assistant") {
+ return;
+ }
+ const text = extractMessageText(payload.content);
+ if (text) {
+ events.push({ type: "timeline", provider: "codex", item: { type: "assistant_message", text, raw: payload } });
+ }
+ break;
+ }
+ case "reasoning": {
+ const text = extractReasoningText(payload);
+ if (text) {
+ events.push({ type: "timeline", provider: "codex", item: { type: "reasoning", text, raw: payload } });
+ }
+ break;
+ }
+ case "function_call": {
+ handleRolloutFunctionCall(payload, events, commandCalls);
+ break;
+ }
+ case "function_call_output": {
+ finalizeRolloutFunctionCall(payload, events, commandCalls);
+ break;
+ }
+ case "custom_tool_call": {
+ handleRolloutCustomToolCall(payload, events);
+ break;
+ }
+ default:
+ break;
+ }
+}
+
+function handleRolloutEventMessage(payload: any, events: AgentStreamEvent[]): void {
+ if (!payload || typeof payload !== "object") {
+ return;
+ }
+ if (payload.type === "agent_reasoning" && typeof payload.text === "string") {
+ events.push({ type: "timeline", provider: "codex", item: { type: "reasoning", text: payload.text, raw: payload } });
+ }
+}
+
+function handleRolloutFunctionCall(
+ payload: any,
+ events: AgentStreamEvent[],
+ commandCalls: Map
+): void {
+ const name = typeof payload.name === "string" ? payload.name : undefined;
+ if (!name) {
+ return;
+ }
+
+ if (name === "shell") {
+ const args = safeJsonParse(payload.arguments);
+ const command = formatCommand(args);
+ if (command && typeof payload.call_id === "string") {
+ commandCalls.set(payload.call_id, { command });
+ }
+ return;
+ }
+
+ if (name === "update_plan") {
+ const args = safeJsonParse(payload.arguments);
+ const planItems = parsePlanItems(args);
+ if (planItems.length) {
+ events.push({ type: "timeline", provider: "codex", item: { type: "todo", items: planItems, raw: payload } });
+ }
+ return;
+ }
+
+ events.push({
+ type: "timeline",
+ provider: "codex",
+ item: {
+ type: "mcp_tool",
+ server: "codex",
+ tool: name,
+ status: payload.status ?? "in_progress",
+ raw: payload,
+ },
+ });
+}
+
+function finalizeRolloutFunctionCall(
+ payload: any,
+ events: AgentStreamEvent[],
+ commandCalls: Map
+): void {
+ if (typeof payload.call_id !== "string") {
+ return;
+ }
+ const command = commandCalls.get(payload.call_id);
+ if (!command) {
+ return;
+ }
+ const result = safeJsonParse(payload.output);
+ const exitCode = result?.metadata?.exit_code;
+ const status = exitCode === undefined || exitCode === 0 ? "completed" : "failed";
+ events.push({
+ type: "timeline",
+ provider: "codex",
+ item: {
+ type: "command",
+ command: command.command,
+ status,
+ raw: { payload, result },
+ },
+ });
+ commandCalls.delete(payload.call_id);
+}
+
+function handleRolloutCustomToolCall(payload: any, events: AgentStreamEvent[]): void {
+ if (payload?.name === "apply_patch" && typeof payload.input === "string") {
+ const files = parsePatchFiles(payload.input);
+ if (files.length) {
+ events.push({
+ type: "timeline",
+ provider: "codex",
+ item: {
+ type: "file_change",
+ files,
+ raw: payload,
+ },
+ });
+ }
+ return;
+ }
+
+ if (typeof payload?.name === "string") {
+ events.push({
+ type: "timeline",
+ provider: "codex",
+ item: {
+ type: "mcp_tool",
+ server: "codex",
+ tool: payload.name,
+ status: payload.status ?? "completed",
+ raw: payload,
+ },
+ });
+ }
+}
+
+function extractMessageText(content: unknown): string {
+ if (!Array.isArray(content)) {
+ return "";
+ }
+ const parts: string[] = [];
+ for (const block of content) {
+ if (block && typeof block === "object" && (block as any).type === "output_text" && typeof (block as any).text === "string") {
+ parts.push((block as any).text);
+ }
+ }
+ return parts.join("\n").trim();
+}
+
+function extractReasoningText(payload: any): string {
+ if (Array.isArray(payload?.summary)) {
+ const text = payload.summary
+ .map((item: any) => (typeof item?.text === "string" ? item.text : ""))
+ .filter(Boolean)
+ .join("\n")
+ .trim();
+ if (text) {
+ return text;
+ }
+ }
+ if (typeof payload?.text === "string") {
+ return payload.text;
+ }
+ return "";
+}
+
+function parsePlanItems(args: any): { text: string; completed: boolean }[] {
+ const plan = Array.isArray(args?.plan) ? args.plan : [];
+ const items: { text: string; completed: boolean }[] = [];
+ for (const step of plan) {
+ if (!step || typeof step !== "object") {
+ continue;
+ }
+ const text = typeof step.step === "string" ? step.step : typeof step.text === "string" ? step.text : "";
+ if (!text) {
+ continue;
+ }
+ const status = typeof step.status === "string" ? step.status : "";
+ items.push({ text, completed: status === "completed" });
+ }
+ return items;
+}
+
+function parsePatchFiles(patch: string): { path: string; kind: string }[] {
+ const files: { path: string; kind: string }[] = [];
+ const seen = new Set();
+ const lines = patch.split(/\r?\n/);
+ for (const line of lines) {
+ const trimmed = line.trim();
+ let kind: string | null = null;
+ let pathValue: string | null = null;
+ if (trimmed.startsWith("*** Add File:")) {
+ kind = "add";
+ pathValue = trimmed.replace("*** Add File:", "").trim();
+ } else if (trimmed.startsWith("*** Delete File:")) {
+ kind = "delete";
+ pathValue = trimmed.replace("*** Delete File:", "").trim();
+ } else if (trimmed.startsWith("*** Update File:")) {
+ kind = "update";
+ pathValue = trimmed.replace("*** Update File:", "").trim();
+ }
+ if (kind && pathValue && !seen.has(`${kind}:${pathValue}`)) {
+ seen.add(`${kind}:${pathValue}`);
+ files.push({ path: pathValue, kind });
+ }
+ }
+ return files;
+}
+
+function safeJsonParse(value: unknown): T | null {
+ if (typeof value !== "string") {
+ return null;
+ }
+ try {
+ return JSON.parse(value) as T;
+ } catch {
+ return null;
+ }
+}
+
+function formatCommand(args: any): string | null {
+ if (!args) {
+ return null;
+ }
+ if (Array.isArray(args.command)) {
+ return args.command.join(" ");
+ }
+ if (typeof args.command === "string") {
+ return args.command;
+ }
+ return null;
+}
+
+function readCodexExtraString(config: CodexAgentConfig, key: string): string | undefined {
+ const extras = config.extra?.codex;
+ if (!extras || typeof extras !== "object") {
+ return undefined;
+ }
+ const value = (extras as Record)[key];
+ return typeof value === "string" && value.length > 0 ? value : undefined;
+}
+
+function readMetadataString(handle: AgentPersistenceHandle | undefined, key: string): string | undefined {
+ if (!handle?.metadata || typeof handle.metadata !== "object") {
+ return undefined;
+ }
+ const value = (handle.metadata as Record)[key];
+ return typeof value === "string" && value.length > 0 ? value : undefined;
+}
diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts
index 2314e3ee7..76158c5b0 100644
--- a/packages/server/src/server/file-explorer/service.ts
+++ b/packages/server/src/server/file-explorer/service.ts
@@ -101,8 +101,10 @@ export async function listDirectoryEntries({
);
entries.sort((a, b) => {
- if (a.kind !== b.kind) {
- return a.kind === "directory" ? -1 : 1;
+ const modifiedComparison =
+ new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime();
+ if (modifiedComparison !== 0) {
+ return modifiedComparison;
}
return a.name.localeCompare(b.name);
});