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); });