From acc46f07da4731ccae28a3e51a5539d0873b0162 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Tue, 31 Mar 2026 09:57:17 +0800 Subject: [PATCH 01/70] feat: add codex agent support --- apps/server/Cargo.lock | 84 +++- apps/server/Cargo.toml | 1 + apps/server/src/app.rs | 1 + apps/server/src/command/http.rs | 147 ++++++- apps/server/src/infra/db.rs | 319 +++++++------- apps/server/src/main.rs | 32 +- apps/server/src/models.rs | 84 +++- apps/server/src/services/agent.rs | 202 ++++++--- apps/server/src/services/agent_client.rs | 148 +++++++ apps/server/src/services/app_settings.rs | 261 ++++++++++- apps/server/src/services/claude.rs | 54 ++- apps/server/src/services/codex.rs | 416 ++++++++++++++++++ apps/server/src/services/mod.rs | 2 + apps/server/src/services/workspace.rs | 74 +++- apps/server/src/services/workspace_runtime.rs | 25 +- .../HistoryDrawer/HistoryDrawer.tsx | 1 + .../RuntimeValidationOverlay.tsx | 8 +- .../Settings/CodexSettingsPanel.tsx | 387 ++++++++++++++++ apps/web/src/components/Settings/Settings.tsx | 40 ++ apps/web/src/components/Settings/index.ts | 1 + .../features/agents/AgentWorkspaceFeature.tsx | 29 ++ .../features/agents/agent-runtime-actions.ts | 49 ++- .../app/WorkbenchRuntimeCoordinator.tsx | 8 +- .../src/features/settings/SettingsScreen.tsx | 7 + .../features/workspace/WorkspaceScreen.tsx | 124 ++++-- .../src/features/workspace/session-actions.ts | 38 +- .../src/features/workspace/session-history.ts | 3 +- .../features/workspace/workspace-recovery.ts | 2 +- .../workspace/workspace-sync-hooks.ts | 12 +- apps/web/src/i18n.ts | 132 +++++- apps/web/src/services/http/agent.service.ts | 2 - apps/web/src/services/http/session.service.ts | 5 +- apps/web/src/shared/app/claude-settings.ts | 349 ++++++++++++++- apps/web/src/shared/utils/session.ts | 10 +- apps/web/src/shared/utils/workspace.ts | 39 +- apps/web/src/state/workbench-core.ts | 23 +- apps/web/src/types/app.ts | 42 +- docs/development/codex-compatibility.md | 107 +++++ docs/plans/2026-03-30-codex-support.md | 374 ++++++++++++++++ package.json | 1 + scripts/test/codex-hooks-smoke.mjs | 263 +++++++++++ tests/agent-startup-policy.test.ts | 47 ++ tests/claude-settings.test.ts | 17 + tests/workspace-empty-snapshot.test.ts | 57 +++ 44 files changed, 3624 insertions(+), 403 deletions(-) create mode 100644 apps/server/src/services/agent_client.rs create mode 100644 apps/server/src/services/codex.rs create mode 100644 apps/web/src/components/Settings/CodexSettingsPanel.tsx create mode 100644 docs/development/codex-compatibility.md create mode 100644 docs/plans/2026-03-30-codex-support.md create mode 100644 scripts/test/codex-hooks-smoke.mjs create mode 100644 tests/agent-startup-policy.test.ts create mode 100644 tests/workspace-empty-snapshot.test.ts diff --git a/apps/server/Cargo.lock b/apps/server/Cargo.lock index 25c5dc9..2cf4aaf 100644 --- a/apps/server/Cargo.lock +++ b/apps/server/Cargo.lock @@ -199,6 +199,7 @@ dependencies = [ "serde_json", "sha2", "tokio", + "toml", "tower-http", "url", ] @@ -261,6 +262,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -400,13 +407,19 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "hashlink" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hashbrown", + "hashbrown 0.14.5", ] [[package]] @@ -622,6 +635,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + [[package]] name = "inotify" version = "0.11.1" @@ -1027,6 +1050,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1282,6 +1314,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -1662,6 +1735,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.10.1" diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml index 50b14a7..5b7c0ab 100644 --- a/apps/server/Cargo.toml +++ b/apps/server/Cargo.toml @@ -17,5 +17,6 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10" tokio = { version = "1.40", features = ["rt-multi-thread", "macros", "process", "signal", "time"] } +toml = "0.8" tower-http = { version = "0.6", features = ["cors", "fs"] } url = "2" diff --git a/apps/server/src/app.rs b/apps/server/src/app.rs index ea9d460..45d8918 100644 --- a/apps/server/src/app.rs +++ b/apps/server/src/app.rs @@ -26,6 +26,7 @@ pub(crate) struct AgentRuntime { pub child: Mutex>, pub killer: Mutex>, pub writer: Mutex>>, + pub codex_first_submit_pending: Mutex, pub master: Mutex>, pub process_id: Option, pub process_group_leader: Option, diff --git a/apps/server/src/command/http.rs b/apps/server/src/command/http.rs index a72044c..f66d947 100644 --- a/apps/server/src/command/http.rs +++ b/apps/server/src/command/http.rs @@ -46,6 +46,7 @@ struct SessionCreateRequest { #[serde(flatten)] controller: WorkspaceControllerMutationRequest, mode: SessionMode, + provider: AgentProvider, } #[derive(Deserialize)] @@ -247,7 +248,6 @@ struct AgentStartRequest { #[serde(flatten)] controller: WorkspaceControllerMutationRequest, session_id: String, - provider: String, cols: Option, rows: Option, } @@ -797,7 +797,7 @@ fn dispatch_rpc( let req: SessionCreateRequest = parse_payload(payload).map_err(rpc_bad_request)?; require_workspace_controller_mutation(app, &req.controller, authorized)?; serde_json::to_value( - create_session(req.controller.workspace_id, req.mode, app.state()) + create_session(req.controller.workspace_id, req.mode, req.provider, app.state()) .map_err(rpc_bad_request)?, ) .map_err(|e| rpc_bad_request(e.to_string())) @@ -1189,7 +1189,6 @@ fn dispatch_rpc( crate::services::agent::AgentStartParams { workspace_id: req.controller.workspace_id, session_id: req.session_id, - provider: req.provider, cols: req.cols, rows: req.rows, }, @@ -2109,7 +2108,13 @@ mod tests { let authorized = authorized_request(); let workspace_id = launch_test_workspace(&app, "/tmp/ws-history-rpc-test"); let created = - create_session(workspace_id.clone(), SessionMode::Branch, app.state()).unwrap(); + create_session( + workspace_id.clone(), + SessionMode::Branch, + AgentProvider::Claude, + app.state(), + ) + .unwrap(); archive_session(workspace_id.clone(), created.id, app.state()).unwrap(); let history = dispatch_rpc(&app, "list_session_history", json!({}), &authorized) @@ -2448,6 +2453,50 @@ mod tests { ); } + #[test] + fn app_settings_update_normalizes_camel_case_codex_payloads_before_merge() { + let app = test_app(); + let authorized = authorized_request(); + + let updated = dispatch_rpc( + &app, + "app_settings_update", + json!({ + "settings": { + "codex": { + "global": { + "model": "gpt-5.4", + "approvalPolicy": "on-request", + "sandboxMode": "workspace-write", + "webSearch": "live", + "modelReasoningEffort": "high", + "extraArgs": ["--full-auto"] + } + } + } + }), + &authorized, + ) + .expect("camelCase codex settings update should succeed"); + let updated: Value = updated; + + assert_eq!(updated["codex"]["global"]["model"], "gpt-5.4"); + assert_eq!( + updated["codex"]["global"]["approval_policy"], + "on-request" + ); + assert_eq!( + updated["codex"]["global"]["sandbox_mode"], + "workspace-write" + ); + assert_eq!(updated["codex"]["global"]["web_search"], "live"); + assert_eq!( + updated["codex"]["global"]["model_reasoning_effort"], + "high" + ); + assert_eq!(updated["codex"]["global"]["extra_args"][0], "--full-auto"); + } + #[test] fn agent_start_uses_server_resolved_settings_from_storage() { let app = test_app(); @@ -2526,7 +2575,6 @@ mod tests { "client_id": "client-a", "fencing_token": runtime.controller.fencing_token, "session_id": session_id.to_string(), - "provider": "claude", "cols": 80, "rows": 24, }), @@ -2564,7 +2612,6 @@ mod tests { "client_id": "client-a", "fencing_token": 1, "session_id": "1", - "provider": "claude", "command": "claude" }), &authorized, @@ -2573,4 +2620,92 @@ mod tests { assert_eq!(error.status, StatusCode::BAD_REQUEST); } + + #[test] + fn agent_start_uses_session_provider_from_storage() { + let app = test_app(); + let authorized = authorized_request(); + let root = create_temp_workspace_root("agent-start-provider"); + let workspace_id = launch_test_workspace(&app, &root); + let marker_path = PathBuf::from(&root).join(".agent-start-provider-marker"); + *app.state().hook_endpoint.lock().unwrap() = Some("http://127.0.0.1:1/claude-hook".into()); + + dispatch_rpc( + &app, + "app_settings_update", + json!({ + "settings": { + "codex": { + "global": { + "executable": test_agent_marker_profile(".agent-start-provider-marker").0, + "extra_args": test_agent_marker_profile(".agent-start-provider-marker").1, + "env": { + "TEST_MARKER": "codex-provider" + } + } + } + } + }), + &authorized, + ) + .unwrap(); + + let attach = dispatch_rpc( + &app, + "workspace_runtime_attach", + json!({ + "workspace_id": workspace_id, + "device_id": "device-a", + "client_id": "client-a", + }), + &authorized, + ) + .unwrap(); + let runtime: WorkspaceRuntimeSnapshot = serde_json::from_value(attach).unwrap(); + + let created = dispatch_rpc( + &app, + "create_session", + json!({ + "workspace_id": workspace_id, + "device_id": "device-a", + "client_id": "client-a", + "fencing_token": runtime.controller.fencing_token, + "mode": "branch", + "provider": "codex", + }), + &authorized, + ) + .expect("create_session should persist codex provider"); + let created: SessionInfo = serde_json::from_value(created).unwrap(); + assert_eq!(created.provider, AgentProvider::Codex); + + let started = dispatch_rpc( + &app, + "agent_start", + json!({ + "workspace_id": workspace_id, + "device_id": "device-a", + "client_id": "client-a", + "fencing_token": runtime.controller.fencing_token, + "session_id": created.id.to_string(), + "cols": 80, + "rows": 24, + }), + &authorized, + ) + .expect("agent_start should read provider from stored session"); + let started: AgentStartResult = serde_json::from_value(started).unwrap(); + assert!(started.started); + + let mut marker_value = String::new(); + for _ in 0..100 { + if let Ok(value) = std::fs::read_to_string(&marker_path) { + marker_value = value; + break; + } + std::thread::sleep(Duration::from_millis(25)); + } + assert_eq!(marker_value, "codex-provider"); + } } diff --git a/apps/server/src/infra/db.rs b/apps/server/src/infra/db.rs index d192335..28644d7 100644 --- a/apps/server/src/infra/db.rs +++ b/apps/server/src/infra/db.rs @@ -7,6 +7,7 @@ const TERMINAL_STREAM_LIMIT: usize = 200_000; const AGENT_LIFECYCLE_HISTORY_LIMIT_PER_SESSION: i64 = 128; const APP_UI_STATE_ROW_ID: i64 = 1; const APP_SETTINGS_ROW_ID: i64 = 1; +const DB_SCHEMA_VERSION: i64 = 2; #[derive(Clone, Serialize, Deserialize)] struct DeviceWorkbenchUiState { @@ -269,17 +270,19 @@ fn persist_session_row( "UPDATE workspace_sessions SET status = ?3, last_active_at = ?4, - claude_session_id = ?5, - payload = ?6, - archived_at = ?7, - sort_order = ?8 + provider = ?5, + resume_id = ?6, + payload = ?7, + archived_at = ?8, + sort_order = ?9 WHERE workspace_id = ?1 AND id = ?2", params![ workspace_id, session.id as i64, status_label(&session.status), session.last_active_at, - session.claude_session_id, + serde_json::to_string(&session.provider).map_err(|e| e.to_string())?, + session.resume_id, payload, archived_at, sort_order, @@ -289,6 +292,148 @@ fn persist_session_row( Ok(()) } +fn recreate_all_tables(conn: &Connection) -> Result<(), rusqlite::Error> { + conn.execute_batch( + "DROP TABLE IF EXISTS app_client_ui_state; + DROP TABLE IF EXISTS app_device_ui_state; + DROP TABLE IF EXISTS app_settings; + DROP TABLE IF EXISTS app_ui_state; + DROP TABLE IF EXISTS agent_lifecycle_events; + DROP TABLE IF EXISTS workspace_terminals; + DROP TABLE IF EXISTS workspace_attachments; + DROP TABLE IF EXISTS workspace_controller_leases; + DROP TABLE IF EXISTS workspace_view_state; + DROP TABLE IF EXISTS workspace_sessions; + DROP TABLE IF EXISTS workspaces;", + ) +} + +fn ensure_schema_version(conn: &Connection) -> Result<(), rusqlite::Error> { + let current_version: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?; + if current_version != 0 && current_version != DB_SCHEMA_VERSION { + recreate_all_tables(conn)?; + } + conn.pragma_update(None, "user_version", DB_SCHEMA_VERSION)?; + Ok(()) +} + +pub(crate) fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { + ensure_schema_version(conn)?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + root_path TEXT NOT NULL UNIQUE, + source_kind TEXT NOT NULL, + source_value TEXT NOT NULL, + git_url TEXT, + target_json TEXT NOT NULL, + idle_policy_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_opened_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS workspace_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL, + archived_at INTEGER, + sort_order INTEGER NOT NULL, + last_active_at INTEGER NOT NULL, + status TEXT NOT NULL, + provider TEXT NOT NULL, + resume_id TEXT, + payload TEXT NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_workspace_sessions_workspace_active + ON workspace_sessions(workspace_id, archived_at, sort_order, last_active_at DESC); + CREATE UNIQUE INDEX IF NOT EXISTS idx_workspace_sessions_workspace_provider_resume + ON workspace_sessions(workspace_id, provider, resume_id) + WHERE resume_id IS NOT NULL; + CREATE TABLE IF NOT EXISTS workspace_view_state ( + workspace_id TEXT PRIMARY KEY, + payload TEXT NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS workspace_controller_leases ( + workspace_id TEXT PRIMARY KEY, + payload TEXT NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS workspace_attachments ( + attachment_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + device_id TEXT NOT NULL, + client_id TEXT NOT NULL, + role TEXT NOT NULL, + attached_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + detached_at INTEGER, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS workspace_terminals ( + workspace_id TEXT NOT NULL, + terminal_id INTEGER NOT NULL, + output TEXT NOT NULL, + recoverable INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL, + PRIMARY KEY (workspace_id, terminal_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS agent_lifecycle_events ( + workspace_id TEXT NOT NULL, + session_id TEXT NOT NULL, + seq INTEGER NOT NULL, + kind TEXT NOT NULL, + source_event TEXT NOT NULL, + data TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (workspace_id, session_id, seq), + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS app_ui_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + payload TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS app_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + payload TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS app_device_ui_state ( + device_id TEXT PRIMARY KEY, + payload TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS app_client_ui_state ( + device_id TEXT NOT NULL, + client_id TEXT NOT NULL, + payload TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (device_id, client_id) + );", + )?; + let payload = serde_json::to_string(&default_ui_state()).unwrap_or_else(|_| "{}".to_string()); + conn.execute( + "INSERT OR IGNORE INTO app_ui_state (id, payload, updated_at) VALUES (?1, ?2, ?3)", + params![APP_UI_STATE_ROW_ID, payload, now_ts()], + )?; + let app_settings_payload = + serde_json::to_string(&AppSettingsPayload::default()).unwrap_or_else(|_| "{}".to_string()); + conn.execute( + "INSERT OR IGNORE INTO app_settings (id, payload, updated_at) VALUES (?1, ?2, ?3)", + params![APP_SETTINGS_ROW_ID, app_settings_payload, now_ts()], + )?; + conn.execute( + "UPDATE workspace_terminals SET recoverable = 0, updated_at = ?1", + params![now_ts()], + )?; + Ok(()) +} + fn min_active_sort_order(conn: &Connection, workspace_id: &str) -> Result { let value: Option = conn .query_row( @@ -997,12 +1142,13 @@ fn session_row_to_history_record( session_id: session.id, title: session.title, status: session.status.clone(), + provider: session.provider, archived, mounted, recoverable: archived || !mounted, last_active_at: session.last_active_at, archived_at: row.archived_at, - claude_session_id: session.claude_session_id, + resume_id: session.resume_id, }) } @@ -1037,33 +1183,10 @@ fn build_snapshot_from_conn( workspace_id: &str, ) -> Result { let workspace = row_to_workspace_summary(load_workspace_row(conn, workspace_id)?); - let mut sessions = load_sessions_from_conn(conn, workspace_id, false)? + let sessions = load_sessions_from_conn(conn, workspace_id, false)? .into_iter() .map(|row| session_from_payload(&row.payload)) .collect::, _>>()?; - if sessions.is_empty() { - let template = SessionInfo { - id: 0, - title: String::new(), - status: SessionStatus::Idle, - mode: SessionMode::Branch, - auto_feed: true, - queue: Vec::new(), - messages: vec![SessionMessage { - id: format!("msg-{}", random_hex(6)?), - role: SessionMessageRole::System, - content: format!("{} ready", workspace.title), - time: now_label(), - }], - stream: String::new(), - unread: 0, - last_active_at: now_ts(), - claude_session_id: None, - }; - let session = create_workspace_session_from_template(conn, workspace_id, template)?; - sessions.push(session.clone()); - save_view_state_to_conn(conn, workspace_id, &default_view_state(session.id))?; - } let archive = session_rows_to_archive(load_sessions_from_conn(conn, workspace_id, true)?)?; let view_state = match load_view_state_from_conn(conn, workspace_id) { Ok(value) => value, @@ -1220,121 +1343,6 @@ fn remove_workspace_from_all_ui_state_scopes( load_ui_state_from_conn(conn, device_id, client_id) } -pub(crate) fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS workspaces ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - root_path TEXT NOT NULL UNIQUE, - source_kind TEXT NOT NULL, - source_value TEXT NOT NULL, - git_url TEXT, - target_json TEXT NOT NULL, - idle_policy_json TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - last_opened_at INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS workspace_sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - workspace_id TEXT NOT NULL, - archived_at INTEGER, - sort_order INTEGER NOT NULL, - last_active_at INTEGER NOT NULL, - status TEXT NOT NULL, - claude_session_id TEXT, - payload TEXT NOT NULL, - FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_workspace_sessions_workspace_active - ON workspace_sessions(workspace_id, archived_at, sort_order, last_active_at DESC); - CREATE UNIQUE INDEX IF NOT EXISTS idx_workspace_sessions_workspace_claude - ON workspace_sessions(workspace_id, claude_session_id) - WHERE claude_session_id IS NOT NULL; - CREATE TABLE IF NOT EXISTS workspace_view_state ( - workspace_id TEXT PRIMARY KEY, - payload TEXT NOT NULL, - updated_at INTEGER NOT NULL, - FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS workspace_controller_leases ( - workspace_id TEXT PRIMARY KEY, - payload TEXT NOT NULL, - updated_at INTEGER NOT NULL, - FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS workspace_attachments ( - attachment_id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL, - device_id TEXT NOT NULL, - client_id TEXT NOT NULL, - role TEXT NOT NULL, - attached_at INTEGER NOT NULL, - last_seen_at INTEGER NOT NULL, - detached_at INTEGER, - FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS workspace_terminals ( - workspace_id TEXT NOT NULL, - terminal_id INTEGER NOT NULL, - output TEXT NOT NULL, - recoverable INTEGER NOT NULL DEFAULT 1, - updated_at INTEGER NOT NULL, - PRIMARY KEY (workspace_id, terminal_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS agent_lifecycle_events ( - workspace_id TEXT NOT NULL, - session_id TEXT NOT NULL, - seq INTEGER NOT NULL, - kind TEXT NOT NULL, - source_event TEXT NOT NULL, - data TEXT NOT NULL, - created_at INTEGER NOT NULL, - PRIMARY KEY (workspace_id, session_id, seq), - FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS app_ui_state ( - id INTEGER PRIMARY KEY CHECK (id = 1), - payload TEXT NOT NULL, - updated_at INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS app_settings ( - id INTEGER PRIMARY KEY CHECK (id = 1), - payload TEXT NOT NULL, - updated_at INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS app_device_ui_state ( - device_id TEXT PRIMARY KEY, - payload TEXT NOT NULL, - updated_at INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS app_client_ui_state ( - device_id TEXT NOT NULL, - client_id TEXT NOT NULL, - payload TEXT NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (device_id, client_id) - );", - )?; - let payload = serde_json::to_string(&default_ui_state()).unwrap_or_else(|_| "{}".to_string()); - conn.execute( - "INSERT OR IGNORE INTO app_ui_state (id, payload, updated_at) VALUES (?1, ?2, ?3)", - params![APP_UI_STATE_ROW_ID, payload, now_ts()], - )?; - let app_settings_payload = - serde_json::to_string(&AppSettingsPayload::default()).unwrap_or_else(|_| "{}".to_string()); - conn.execute( - "INSERT OR IGNORE INTO app_settings (id, payload, updated_at) VALUES (?1, ?2, ?3)", - params![APP_SETTINGS_ROW_ID, app_settings_payload, now_ts()], - )?; - conn.execute( - "UPDATE workspace_terminals SET recoverable = 0, updated_at = ?1", - params![now_ts()], - )?; - Ok(()) -} - pub(crate) fn mark_active_sessions_interrupted_on_boot(conn: &Connection) -> Result<(), String> { let mut stmt = conn .prepare( @@ -1466,14 +1474,15 @@ fn create_workspace_session_from_template( ) -> Result { let sort_order = min_active_sort_order(conn, workspace_id)? - 1; conn.execute( - "INSERT INTO workspace_sessions (workspace_id, archived_at, sort_order, last_active_at, status, claude_session_id, payload) - VALUES (?1, NULL, ?2, ?3, ?4, ?5, '')", + "INSERT INTO workspace_sessions (workspace_id, archived_at, sort_order, last_active_at, status, provider, resume_id, payload) + VALUES (?1, NULL, ?2, ?3, ?4, ?5, ?6, '')", params![ workspace_id, sort_order, template.last_active_at, status_label(&template.status), - template.claude_session_id, + serde_json::to_string(&template.provider).map_err(|e| e.to_string())?, + template.resume_id, ], ) .map_err(|e| e.to_string())?; @@ -1489,6 +1498,7 @@ pub(crate) fn create_workspace_session( state: State<'_, AppState>, workspace_id: &str, mode: SessionMode, + provider: AgentProvider, ) -> Result { with_db(state, |conn| { let workspace = load_workspace_row(conn, workspace_id)?; @@ -1515,6 +1525,7 @@ pub(crate) fn create_workspace_session( title: String::new(), status, mode, + provider, auto_feed: true, queue: Vec::new(), messages: vec![SessionMessage { @@ -1526,7 +1537,7 @@ pub(crate) fn create_workspace_session( stream: String::new(), unread: 0, last_active_at: now_ts(), - claude_session_id: None, + resume_id: None, }; create_workspace_session_from_template(conn, workspace_id, template) }) @@ -1568,8 +1579,8 @@ pub(crate) fn update_workspace_session( if let Some(last_active_at) = patch.last_active_at { session.last_active_at = last_active_at; } - if let Some(claude_session_id) = patch.claude_session_id { - session.claude_session_id = Some(claude_session_id); + if let Some(resume_id) = patch.resume_id { + session.resume_id = Some(resume_id); } persist_session_row( conn, @@ -2007,16 +2018,16 @@ pub(crate) fn set_session_status_if_not_archived( }) } -pub(crate) fn set_session_claude_id( +pub(crate) fn set_session_resume_id( state: State<'_, AppState>, workspace_id: &str, session_id: u64, - claude_session_id: String, + resume_id: String, ) -> Result<(), String> { with_db(state, |conn| { let row = load_session_row(conn, workspace_id, session_id)?; let mut session = session_from_payload(&row.payload)?; - session.claude_session_id = Some(claude_session_id); + session.resume_id = Some(resume_id); persist_session_row( conn, workspace_id, diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index 915312c..ff787ff 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -62,7 +62,7 @@ pub(crate) use infra::db::{ load_session_history_records, load_workspace_controller_lease, mark_active_sessions_interrupted_on_boot, mark_workspace_client_detached, patch_workspace_view_state, persist_workspace_terminal, restore_workspace_session, - save_workspace_controller_lease, set_session_claude_id, set_session_status_if_not_archived, + save_workspace_controller_lease, set_session_resume_id, set_session_status_if_not_archived, set_workspace_terminal_recoverable, switch_workspace_session, update_workbench_layout as persist_workbench_layout, update_workspace_idle_policy, update_workspace_session, upsert_workspace_attachment, @@ -82,21 +82,23 @@ pub(crate) use infra::support::{ }; pub(crate) use infra::time::{default_idle_policy, now_label, now_ts, status_label}; pub(crate) use models::{ - AgentEvent, AgentLifecycleEvent, AgentLifecycleHistoryEntry, AgentStartResult, + AgentEvent, AgentLifecycleEvent, AgentLifecycleHistoryEntry, AgentProvider, AgentStartResult, AppSettingsPayload, ArchiveEntry, ClaudeRuntimeProfile, ClaudeSlashSkillEntry, CommandAvailability, ExecTarget, FileNode, FilePreview, FilesystemEntry, FilesystemListResponse, FilesystemRoot, GitChangeEntry, GitFileDiffPayload, GitStatus, - IdlePolicy, SessionHistoryRecord, SessionInfo, SessionMessage, SessionMessageRole, SessionMode, - SessionPatch, SessionRestoreResult, SessionStatus, TerminalEvent, TerminalInfo, TransportEvent, - WorkbenchBootstrap, WorkbenchLayout, WorkbenchUiState, WorkspaceControllerLease, - WorkspaceLaunchResult, WorkspaceRuntimeSnapshot, WorkspaceRuntimeStateEvent, WorkspaceSnapshot, - WorkspaceSource, WorkspaceSourceKind, WorkspaceSummary, WorkspaceTree, WorkspaceViewPatch, - WorkspaceViewState, WorktreeDetail, WorktreeInfo, + IdlePolicy, SessionHistoryRecord, SessionInfo, SessionMessage, SessionMessageRole, + SessionMode, SessionPatch, SessionRestoreResult, SessionStatus, TerminalEvent, TerminalInfo, + TransportEvent, WorkbenchBootstrap, WorkbenchLayout, WorkbenchUiState, + WorkspaceControllerLease, WorkspaceLaunchResult, WorkspaceRuntimeSnapshot, + WorkspaceRuntimeStateEvent, WorkspaceSnapshot, WorkspaceSource, WorkspaceSourceKind, + WorkspaceSummary, WorkspaceTree, WorkspaceViewPatch, WorkspaceViewState, WorktreeDetail, + WorktreeInfo, CodexRuntimeProfile, }; #[cfg(test)] pub(crate) use models::{ - ClaudeSettingsPayload, ClaudeTargetOverrides, CompletionNotificationSettings, - GeneralSettingsPayload, TargetClaudeOverride, + AgentDefaultsPayload, ClaudeSettingsPayload, ClaudeTargetOverrides, + CodexSettingsPayload, CodexTargetOverrides, CompletionNotificationSettings, + GeneralSettingsPayload, TargetClaudeOverride, TargetCodexOverride, }; pub(crate) use runtime::{AppHandle, State}; pub(crate) use services::agent::{ @@ -108,7 +110,11 @@ pub(crate) use services::app_settings::{ }; pub(crate) use services::claude::{ current_app_bin_for_target, current_hook_endpoint, ensure_claude_hook_settings, - resolve_claude_runtime_profile, run_claude_hook_helper, start_claude_hook_receiver, + parse_http_endpoint, resolve_claude_runtime_profile, run_claude_hook_helper, + start_claude_hook_receiver, +}; +pub(crate) use services::codex::{ + ensure_codex_hook_settings, resolve_codex_runtime_profile, run_codex_hook_helper, }; pub(crate) use services::filesystem::{ file_preview, file_save, filesystem_list, filesystem_roots, workspace_tree, @@ -215,6 +221,10 @@ async fn main() { run_claude_hook_helper(); return; } + if std::env::args().any(|arg| arg == "--coder-studio-codex-hook") { + run_codex_hook_helper(); + return; + } if let Err(error) = run().await { eprintln!("failed to start coder-studio: {error}"); diff --git a/apps/server/src/models.rs b/apps/server/src/models.rs index db0e593..5a0e30a 100644 --- a/apps/server/src/models.rs +++ b/apps/server/src/models.rs @@ -29,6 +29,14 @@ pub enum SessionStatus { Interrupted, } +#[derive(Clone, Copy, Serialize, Deserialize, Debug, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum AgentProvider { + #[default] + Claude, + Codex, +} + #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] pub struct IdlePolicy { pub enabled: bool, @@ -68,6 +76,10 @@ fn default_claude_executable() -> String { "claude".to_string() } +fn default_codex_executable() -> String { + "codex".to_string() +} + fn default_json_object() -> Value { Value::Object(Default::default()) } @@ -166,11 +178,75 @@ pub struct ClaudeSettingsPayload { pub overrides: ClaudeTargetOverrides, } +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)] +#[serde(default)] +pub struct AgentDefaultsPayload { + pub provider: AgentProvider, +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +#[serde(default)] +pub struct CodexRuntimeProfile { + #[serde(default = "default_codex_executable")] + pub executable: String, + #[serde(alias = "extraArgs")] + pub extra_args: Vec, + pub model: String, + #[serde(alias = "approvalPolicy")] + pub approval_policy: String, + #[serde(alias = "sandboxMode")] + pub sandbox_mode: String, + #[serde(alias = "webSearch")] + pub web_search: String, + #[serde(alias = "modelReasoningEffort")] + pub model_reasoning_effort: String, + pub env: BTreeMap, +} + +impl Default for CodexRuntimeProfile { + fn default() -> Self { + Self { + executable: default_codex_executable(), + extra_args: Vec::new(), + model: String::new(), + approval_policy: String::new(), + sandbox_mode: String::new(), + web_search: String::new(), + model_reasoning_effort: String::new(), + env: BTreeMap::new(), + } + } +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)] +#[serde(default)] +pub struct TargetCodexOverride { + pub enabled: bool, + pub profile: CodexRuntimeProfile, +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)] +#[serde(default)] +pub struct CodexTargetOverrides { + pub native: Option, + pub wsl: Option, +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)] +#[serde(default)] +pub struct CodexSettingsPayload { + pub global: CodexRuntimeProfile, + pub overrides: CodexTargetOverrides, +} + #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)] #[serde(default)] pub struct AppSettingsPayload { pub general: GeneralSettingsPayload, + #[serde(alias = "agentDefaults")] + pub agent_defaults: AgentDefaultsPayload, pub claude: ClaudeSettingsPayload, + pub codex: CodexSettingsPayload, } #[derive(Clone, Serialize, Deserialize, Debug)] @@ -202,13 +278,14 @@ pub struct SessionInfo { pub title: String, pub status: SessionStatus, pub mode: SessionMode, + pub provider: AgentProvider, pub auto_feed: bool, pub queue: Vec, pub messages: Vec, pub stream: String, pub unread: u32, pub last_active_at: i64, - pub claude_session_id: Option, + pub resume_id: Option, } #[derive(Clone, Serialize, Deserialize, Debug)] @@ -267,12 +344,13 @@ pub struct SessionHistoryRecord { pub session_id: u64, pub title: String, pub status: SessionStatus, + pub provider: AgentProvider, pub archived: bool, pub mounted: bool, pub recoverable: bool, pub last_active_at: i64, pub archived_at: Option, - pub claude_session_id: Option, + pub resume_id: Option, } #[derive(Clone, Serialize, Deserialize, Debug)] @@ -517,7 +595,7 @@ pub struct SessionPatch { pub stream: Option, pub unread: Option, pub last_active_at: Option, - pub claude_session_id: Option, + pub resume_id: Option, } #[derive(Clone, Deserialize, Debug)] diff --git a/apps/server/src/services/agent.rs b/apps/server/src/services/agent.rs index 2da3215..c6c6527 100644 --- a/apps/server/src/services/agent.rs +++ b/apps/server/src/services/agent.rs @@ -1,14 +1,16 @@ use crate::services::utf8_stream::Utf8StreamDecoder; use crate::*; +use std::time::Duration; const DEFAULT_PTY_COLS: u16 = 120; const DEFAULT_PTY_ROWS: u16 = 30; +const CODEX_FIRST_SUBMIT_NEWLINE_DELAY_MS: u64 = 120; #[derive(Default)] struct AgentLifecycleFallbackState { emitted_tool_started: bool, emitted_turn_completed: bool, - claude_session_id: Option, + resume_id: Option, } fn initial_pty_size(cols: Option, rows: Option) -> PtySize { @@ -29,7 +31,7 @@ fn fallback_agent_lifecycle_from_output( } state.emitted_tool_started = true; let data = state - .claude_session_id + .resume_id .as_deref() .map(|session_id| { json!({ @@ -73,36 +75,30 @@ fn terminate_agent_runtime(runtime: Arc) { } } -fn escape_agent_command_part(target: &ExecTarget, value: &str) -> String { - if matches!(target, ExecTarget::Wsl { .. }) { - return shell_escape(value); - } - - #[cfg(target_os = "windows")] - { - crate::infra::runtime::shell_escape_windows(value) - } +fn write_agent_input( + writer: &mut dyn Write, + input: &str, + append_newline: bool, + codex_first_submit_pending: &mut bool, + mut delay: F, +) -> Result<(), String> +where + F: FnMut(Duration), +{ + writer + .write_all(input.as_bytes()) + .map_err(|e| e.to_string())?; - #[cfg(not(target_os = "windows"))] - { - shell_escape(value) + if append_newline { + if *codex_first_submit_pending && !input.is_empty() { + writer.flush().map_err(|e| e.to_string())?; + delay(Duration::from_millis(CODEX_FIRST_SUBMIT_NEWLINE_DELAY_MS)); + } + writer.write_all(b"\r").map_err(|e| e.to_string())?; + *codex_first_submit_pending = false; } -} -fn build_claude_launch_command( - target: &ExecTarget, - profile: &ClaudeRuntimeProfile, - claude_session_id: Option<&str>, -) -> String { - let mut parts = Vec::with_capacity(1 + profile.startup_args.len()); - parts.push(escape_agent_command_part(target, &profile.executable)); - parts.extend( - profile - .startup_args - .iter() - .map(|arg| escape_agent_command_part(target, arg)), - ); - build_claude_resume_command(&parts.join(" "), claude_session_id) + writer.flush().map_err(|e| e.to_string()) } fn take_agent_runtime( @@ -129,7 +125,6 @@ pub(crate) fn stop_agent_runtime_without_status_update( pub(crate) struct AgentStartParams { pub(crate) workspace_id: String, pub(crate) session_id: String, - pub(crate) provider: String, pub(crate) cols: Option, pub(crate) rows: Option, } @@ -142,7 +137,6 @@ pub(crate) fn agent_start( let AgentStartParams { workspace_id, session_id, - provider, cols, rows, } = params; @@ -159,16 +153,15 @@ pub(crate) fn agent_start( .map_err(|_| "invalid_session_id".to_string())?; let (cwd, target) = workspace_access_context(state, &workspace_id)?; let stored_session = load_session(state, &workspace_id, session_id_num)?; - let effective_claude_session_id = stored_session.claude_session_id.clone(); - let (command, claude_profile) = if provider == "claude" { - let settings = load_or_default_app_settings(state)?; - let profile = resolve_claude_runtime_profile(&settings, &target); - let command = - build_claude_launch_command(&target, &profile, effective_claude_session_id.as_deref()); - (command, Some(profile)) - } else { - return Err("unsupported_agent_provider".to_string()); + let effective_resume_id = stored_session.resume_id.clone(); + let settings = load_or_default_app_settings(state)?; + let client = + crate::services::agent_client::resolve_agent_client(stored_session.provider, &settings, &target); + let command = match effective_resume_id.as_deref() { + Some(resume_id) => client.resume_command(&target, resume_id), + None => client.start_command(&target), }; + client.ensure_workspace_hooks(&cwd, &target)?; let (program, args) = build_agent_pty_command(&target, &cwd, &command); #[cfg(not(target_os = "windows"))] @@ -192,18 +185,15 @@ pub(crate) fn agent_start( crate::infra::runtime::apply_unix_pty_env_defaults(&mut cmd, shell_env.as_deref()); } - if let Some(profile) = claude_profile.as_ref() { - for (key, value) in &profile.env { - cmd.env(key, value); - } - ensure_claude_hook_settings(&cwd, &target)?; - let app_bin = current_app_bin_for_target(&target)?; - let hook_endpoint = current_hook_endpoint(&app)?; - cmd.env("CODER_STUDIO_APP_BIN", app_bin); - cmd.env("CODER_STUDIO_HOOK_ENDPOINT", hook_endpoint); - cmd.env("CODER_STUDIO_WORKSPACE_ID", workspace_id.clone()); - cmd.env("CODER_STUDIO_SESSION_ID", session_id.clone()); + for (key, value) in client.runtime_env() { + cmd.env(key, value); } + let app_bin = current_app_bin_for_target(&target)?; + let hook_endpoint = current_hook_endpoint(&app)?; + cmd.env("CODER_STUDIO_APP_BIN", app_bin); + cmd.env("CODER_STUDIO_HOOK_ENDPOINT", hook_endpoint); + cmd.env("CODER_STUDIO_WORKSPACE_ID", workspace_id.clone()); + cmd.env("CODER_STUDIO_SESSION_ID", session_id.clone()); let child = pair.slave.spawn_command(cmd).map_err(|e| { let raw = e.to_string(); @@ -229,6 +219,7 @@ pub(crate) fn agent_start( child: Mutex::new(child), killer: Mutex::new(killer), writer: Mutex::new(Some(writer)), + codex_first_submit_pending: Mutex::new(matches!(stored_session.provider, AgentProvider::Codex)), master: Mutex::new(pair.master), process_id, process_group_leader, @@ -251,7 +242,7 @@ pub(crate) fn agent_start( let session_out = session_id.clone(); let session_out_num = session_id_num; let lifecycle_fallback_state = Arc::new(Mutex::new(AgentLifecycleFallbackState { - claude_session_id: effective_claude_session_id.clone(), + resume_id: effective_resume_id.clone(), ..Default::default() })); let app_handle = app.clone(); @@ -380,14 +371,18 @@ pub(crate) fn agent_send( let runtime = agents.get(&key).ok_or("agent_not_running")?.clone(); drop(agents); let mut writer = runtime.writer.lock().map_err(|e| e.to_string())?; + let mut codex_first_submit_pending = runtime + .codex_first_submit_pending + .lock() + .map_err(|e| e.to_string())?; if let Some(handle) = writer.as_mut() { - handle - .write_all(input.as_bytes()) - .map_err(|e| e.to_string())?; - if append_newline.unwrap_or(true) { - handle.write_all(b"\r").map_err(|e| e.to_string())?; - } - handle.flush().map_err(|e| e.to_string())?; + write_agent_input( + &mut **handle, + &input, + append_newline.unwrap_or(true), + &mut codex_first_submit_pending, + std::thread::sleep, + )?; if let Ok(session_id_num) = session_id.parse::() { let _ = update_workspace_session( state, @@ -403,7 +398,7 @@ pub(crate) fn agent_send( stream: None, unread: None, last_active_at: Some(now_ts()), - claude_session_id: None, + resume_id: None, }, ); } @@ -487,6 +482,89 @@ pub(crate) fn agent_resize( #[cfg(test)] mod tests { use super::*; + use std::io::{self, Write}; + use std::sync::{Arc, Mutex}; + + #[derive(Clone, Default)] + struct RecordingWriter { + ops: Arc>>, + } + + impl RecordingWriter { + fn operations(&self) -> Vec { + self.ops.lock().unwrap().clone() + } + } + + impl Write for RecordingWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + let op = match buf { + b"\r" => "write:".to_string(), + other => format!("write:{}", String::from_utf8_lossy(other)), + }; + self.ops.lock().unwrap().push(op); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.ops.lock().unwrap().push("flush".to_string()); + Ok(()) + } + } + + #[test] + fn codex_first_submit_flushes_before_enter() { + let mut writer = RecordingWriter::default(); + let mut pending = true; + let mut delays = Vec::new(); + + write_agent_input( + &mut writer, + "hello", + true, + &mut pending, + |duration| delays.push(duration), + ) + .unwrap(); + + assert_eq!( + writer.operations(), + vec![ + "write:hello".to_string(), + "flush".to_string(), + "write:".to_string(), + "flush".to_string(), + ] + ); + assert_eq!( + delays, + vec![Duration::from_millis(CODEX_FIRST_SUBMIT_NEWLINE_DELAY_MS)] + ); + assert!(!pending); + } + + #[test] + fn codex_follow_up_enter_submits_buffered_prompt_without_extra_delay() { + let mut writer = RecordingWriter::default(); + let mut pending = true; + let mut delays = Vec::new(); + + write_agent_input( + &mut writer, + "", + true, + &mut pending, + |duration| delays.push(duration), + ) + .unwrap(); + + assert_eq!( + writer.operations(), + vec!["write:".to_string(), "flush".to_string()] + ); + assert!(delays.is_empty()); + assert!(!pending); + } #[test] fn fallback_agent_lifecycle_marks_first_output_as_tool_started_once() { @@ -507,9 +585,9 @@ mod tests { } #[test] - fn fallback_agent_lifecycle_carries_known_claude_session_id() { + fn fallback_agent_lifecycle_carries_known_resume_id() { let mut state = AgentLifecycleFallbackState { - claude_session_id: Some("claude-resume-known".to_string()), + resume_id: Some("claude-resume-known".to_string()), ..Default::default() }; diff --git a/apps/server/src/services/agent_client.rs b/apps/server/src/services/agent_client.rs new file mode 100644 index 0000000..5fea7ae --- /dev/null +++ b/apps/server/src/services/agent_client.rs @@ -0,0 +1,148 @@ +use crate::*; + +pub(crate) trait AgentClientAdapter { + fn start_command(&self, target: &ExecTarget) -> String; + fn resume_command(&self, target: &ExecTarget, resume_id: &str) -> String; + fn runtime_env(&self) -> &std::collections::BTreeMap; + fn ensure_workspace_hooks(&self, cwd: &str, target: &ExecTarget) -> Result<(), String>; +} + +pub(crate) struct ClaudeClient { + profile: ClaudeRuntimeProfile, +} + +impl ClaudeClient { + fn new(profile: ClaudeRuntimeProfile) -> Self { + Self { profile } + } +} + +impl AgentClientAdapter for ClaudeClient { + fn start_command(&self, target: &ExecTarget) -> String { + crate::services::claude::build_claude_start_command(target, &self.profile) + } + + fn resume_command(&self, target: &ExecTarget, resume_id: &str) -> String { + crate::services::claude::build_claude_resume_launch_command( + target, + &self.profile, + resume_id, + ) + } + + fn runtime_env(&self) -> &std::collections::BTreeMap { + &self.profile.env + } + + fn ensure_workspace_hooks(&self, cwd: &str, target: &ExecTarget) -> Result<(), String> { + ensure_claude_hook_settings(cwd, target) + } +} + +pub(crate) struct CodexClient { + profile: CodexRuntimeProfile, +} + +impl CodexClient { + fn new(profile: CodexRuntimeProfile) -> Self { + Self { profile } + } +} + +impl AgentClientAdapter for CodexClient { + fn start_command(&self, target: &ExecTarget) -> String { + crate::services::codex::build_codex_start_command(target, &self.profile) + } + + fn resume_command(&self, target: &ExecTarget, resume_id: &str) -> String { + crate::services::codex::build_codex_resume_command(target, &self.profile, resume_id) + } + + fn runtime_env(&self) -> &std::collections::BTreeMap { + &self.profile.env + } + + fn ensure_workspace_hooks(&self, cwd: &str, target: &ExecTarget) -> Result<(), String> { + ensure_codex_hook_settings(cwd, target) + } +} + +pub(crate) fn resolve_agent_client( + provider: AgentProvider, + settings: &AppSettingsPayload, + target: &ExecTarget, +) -> Box { + match provider { + AgentProvider::Claude => { + Box::new(ClaudeClient::new(resolve_claude_runtime_profile(settings, target))) + } + AgentProvider::Codex => { + Box::new(CodexClient::new(resolve_codex_runtime_profile(settings, target))) + } + } +} + +pub(crate) fn escape_agent_command_part(target: &ExecTarget, value: &str) -> String { + if matches!(target, ExecTarget::Wsl { .. }) { + return shell_escape(value); + } + + #[cfg(target_os = "windows")] + { + crate::infra::runtime::shell_escape_windows(value) + } + + #[cfg(not(target_os = "windows"))] + { + shell_escape(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn claude_client_separates_start_and_resume_commands() { + let client = ClaudeClient::new(ClaudeRuntimeProfile { + executable: "claude".into(), + startup_args: vec!["--model".into(), "sonnet".into()], + env: BTreeMap::new(), + settings_json: Value::Object(Map::new()), + global_config_json: Value::Object(Map::new()), + }); + + assert_eq!( + client.start_command(&ExecTarget::Native), + "claude --model sonnet" + ); + assert_eq!( + client.resume_command(&ExecTarget::Native, "resume-123"), + "claude --model sonnet --resume resume-123" + ); + } + + #[test] + fn codex_client_separates_start_and_resume_commands() { + let client = CodexClient::new(CodexRuntimeProfile { + executable: "codex".into(), + extra_args: vec!["--full-auto".into()], + model: String::new(), + approval_policy: String::new(), + sandbox_mode: String::new(), + web_search: String::new(), + model_reasoning_effort: String::new(), + env: BTreeMap::new(), + }); + + assert_eq!( + client.start_command(&ExecTarget::Native), + "codex --full-auto --enable codex_hooks" + ); + assert_eq!( + client.resume_command(&ExecTarget::Native, "resume-123"), + "codex resume resume-123 --full-auto --enable codex_hooks" + ); + } +} diff --git a/apps/server/src/services/app_settings.rs b/apps/server/src/services/app_settings.rs index a6ff0d1..1efb7b1 100644 --- a/apps/server/src/services/app_settings.rs +++ b/apps/server/src/services/app_settings.rs @@ -54,6 +54,26 @@ fn resolve_claude_home_root(root_override: Option<&Path>) -> Option { } } +fn resolve_codex_home_root(root_override: Option<&Path>) -> Option { + if let Some(root) = root_override { + return Some(root.to_path_buf()); + } + + if let Some(root) = std::env::var_os("CODER_STUDIO_CODEX_HOME") { + return Some(PathBuf::from(root)); + } + + #[cfg(test)] + { + None + } + + #[cfg(not(test))] + { + home_dir() + } +} + #[derive(Default)] struct ClaudeJsonSources { settings_json: Option>, @@ -69,6 +89,8 @@ impl ClaudeJsonSources { } } +type CodexTomlSource = toml::Table; + fn parse_json_object_text(raw: &str) -> Option> { match serde_json::from_str::(raw).ok()? { Value::Object(value) => Some(value), @@ -76,16 +98,30 @@ fn parse_json_object_text(raw: &str) -> Option> { } } +fn parse_toml_table_text(raw: &str) -> Option { + raw.parse::().ok() +} + fn read_json_object_file(path: &Path) -> Option> { let raw = fs::read_to_string(path).ok()?; parse_json_object_text(&raw) } +fn read_toml_table_file(path: &Path) -> Option { + let raw = fs::read_to_string(path).ok()?; + parse_toml_table_text(&raw) +} + fn read_target_json_object_file(target: &ExecTarget, path: &str) -> Option> { let raw = run_cmd(target, "", &["cat", path]).ok()?; parse_json_object_text(&raw) } +fn read_target_toml_table_file(target: &ExecTarget, path: &str) -> Option { + let raw = run_cmd(target, "", &["cat", path]).ok()?; + parse_toml_table_text(&raw) +} + fn load_native_claude_json_sources(root: &Path) -> ClaudeJsonSources { ClaudeJsonSources { settings_json: read_json_object_file(&root.join(".claude/settings.json")), @@ -118,6 +154,21 @@ fn load_wsl_claude_json_sources(target: &ExecTarget) -> Option Option { + read_toml_table_file(&root.join(".codex/config.toml")) +} + +fn load_wsl_codex_toml_source(target: &ExecTarget) -> Option { + let home = filesystem_home_for_target(target).ok()?; + let home = home.trim_end_matches('/'); + let path = if home.is_empty() || home == "/" { + "/.codex/config.toml".to_string() + } else { + format!("{home}/.codex/config.toml") + }; + read_target_toml_table_file(target, &path) +} + fn merge_missing_env_value( env: &mut std::collections::BTreeMap, key: &str, @@ -147,6 +198,20 @@ fn merge_missing_env_map( } } +fn merge_missing_string(target: &mut String, source: Option<&str>) { + if !target.trim().is_empty() { + return; + } + let Some(source) = source else { + return; + }; + let trimmed = source.trim(); + if trimmed.is_empty() { + return; + } + *target = trimmed.to_string(); +} + fn merge_missing_json(target: &mut Value, source: &Value) { match source { Value::Object(source_map) => { @@ -258,17 +323,91 @@ fn hydrate_settings_from_claude_home( hydrate_settings_from_claude_sources(settings, Some(&sources), None) } +fn hydrate_runtime_profile_from_codex_source( + profile: &CodexRuntimeProfile, + source: &CodexTomlSource, +) -> CodexRuntimeProfile { + let mut hydrated = profile.clone(); + + merge_missing_string( + &mut hydrated.model, + source.get("model").and_then(toml::Value::as_str), + ); + merge_missing_string( + &mut hydrated.approval_policy, + source.get("approval_policy").and_then(toml::Value::as_str), + ); + merge_missing_string( + &mut hydrated.sandbox_mode, + source.get("sandbox_mode").and_then(toml::Value::as_str), + ); + merge_missing_string( + &mut hydrated.web_search, + source.get("web_search").and_then(toml::Value::as_str), + ); + merge_missing_string( + &mut hydrated.model_reasoning_effort, + source + .get("model_reasoning_effort") + .and_then(toml::Value::as_str), + ); + + hydrated +} + +fn hydrate_settings_from_codex_sources( + settings: &AppSettingsPayload, + native_source: Option<&CodexTomlSource>, + wsl_source: Option<&CodexTomlSource>, +) -> AppSettingsPayload { + let mut hydrated = settings.clone(); + + if let Some(source) = native_source { + hydrated.codex.global = + hydrate_runtime_profile_from_codex_source(&hydrated.codex.global, source); + } + + if let Some(source) = wsl_source { + let existing_override = hydrated.codex.overrides.wsl.clone(); + let mut wsl_override = existing_override.clone().unwrap_or_default(); + let next_profile = hydrate_runtime_profile_from_codex_source(&wsl_override.profile, source); + if existing_override.is_some() || next_profile != wsl_override.profile { + wsl_override.profile = next_profile; + hydrated.codex.overrides.wsl = Some(wsl_override); + } + } + + hydrated +} + +fn hydrate_settings_from_codex_home( + settings: &AppSettingsPayload, + root_override: Option<&Path>, +) -> AppSettingsPayload { + let Some(root) = resolve_codex_home_root(root_override) else { + return settings.clone(); + }; + + let Some(source) = load_native_codex_toml_source(&root) else { + return settings.clone(); + }; + hydrate_settings_from_codex_sources(settings, Some(&source), None) +} + fn load_or_default_app_settings_from_conn_hydrated( conn: &Connection, ) -> Result { - let settings = - hydrate_settings_from_claude_home(&load_or_default_app_settings_from_conn(conn)?, None); + let settings = load_or_default_app_settings_from_conn(conn)?; + let settings = hydrate_settings_from_claude_home(&settings, None); + let settings = hydrate_settings_from_codex_home(&settings, None); let wsl_target = ExecTarget::Wsl { distro: None }; - let wsl_sources = load_wsl_claude_json_sources(&wsl_target); - Ok(hydrate_settings_from_claude_sources( + let wsl_claude_sources = load_wsl_claude_json_sources(&wsl_target); + let settings = hydrate_settings_from_claude_sources(&settings, None, wsl_claude_sources.as_ref()); + let wsl_codex_source = load_wsl_codex_toml_source(&wsl_target); + Ok(hydrate_settings_from_codex_sources( &settings, None, - wsl_sources.as_ref(), + wsl_codex_source.as_ref(), )) } @@ -298,6 +437,7 @@ fn should_replace_object_patch(path: &[String]) -> bool { ["claude", "global", "env"] | ["claude", "global", "settings_json"] | ["claude", "global", "global_config_json"] + | ["codex", "global", "env"] | ["claude", "overrides", "native", "profile", "env"] | ["claude", "overrides", "native", "profile", "settings_json"] | [ @@ -307,6 +447,7 @@ fn should_replace_object_patch(path: &[String]) -> bool { "profile", "global_config_json" ] + | ["codex", "overrides", "native", "profile", "env"] | ["claude", "overrides", "wsl", "profile", "env"] | ["claude", "overrides", "wsl", "profile", "settings_json"] | [ @@ -316,6 +457,7 @@ fn should_replace_object_patch(path: &[String]) -> bool { "profile", "global_config_json" ] + | ["codex", "overrides", "wsl", "profile", "env"] ) } @@ -371,6 +513,16 @@ fn normalize_settings_patch_key(path: &[String], key: &str) -> String { "globalConfigJson" => "global_config_json".to_string(), _ => key.to_string(), }, + ["codex", "global"] + | ["codex", "overrides", "native", "profile"] + | ["codex", "overrides", "wsl", "profile"] => match key { + "extraArgs" => "extra_args".to_string(), + "approvalPolicy" => "approval_policy".to_string(), + "sandboxMode" => "sandbox_mode".to_string(), + "webSearch" => "web_search".to_string(), + "modelReasoningEffort" => "model_reasoning_effort".to_string(), + _ => key.to_string(), + }, _ => key.to_string(), } } @@ -658,6 +810,105 @@ mod tests { assert_eq!(hydrated.claude.global.env.get("ANTHROPIC_API_KEY"), None); } + #[test] + fn hydrate_settings_from_codex_home_imports_existing_file_values() { + let root = unique_temp_dir("codex-settings-import"); + + if let Some(parent) = root.join(".codex/config.toml").parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write( + root.join(".codex/config.toml"), + [ + "model = \"gpt-5.4\"", + "approval_policy = \"on-request\"", + "sandbox_mode = \"workspace-write\"", + "web_search = \"live\"", + "model_reasoning_effort = \"high\"", + ] + .join("\n"), + ) + .unwrap(); + + let hydrated = + hydrate_settings_from_codex_home(&AppSettingsPayload::default(), Some(root.as_path())); + + assert_eq!(hydrated.codex.global.model, "gpt-5.4"); + assert_eq!(hydrated.codex.global.approval_policy, "on-request"); + assert_eq!(hydrated.codex.global.sandbox_mode, "workspace-write"); + assert_eq!(hydrated.codex.global.web_search, "live"); + assert_eq!(hydrated.codex.global.model_reasoning_effort, "high"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn hydrate_settings_from_codex_home_preserves_backend_values_over_local_files() { + let root = unique_temp_dir("codex-settings-precedence"); + + if let Some(parent) = root.join(".codex/config.toml").parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write( + root.join(".codex/config.toml"), + [ + "model = \"gpt-5.4\"", + "approval_policy = \"never\"", + "sandbox_mode = \"danger-full-access\"", + ] + .join("\n"), + ) + .unwrap(); + + let mut settings = AppSettingsPayload::default(); + settings.codex.global.model = "gpt-5.5".into(); + settings.codex.global.approval_policy = "on-request".into(); + settings.codex.global.sandbox_mode = "workspace-write".into(); + + let hydrated = hydrate_settings_from_codex_home(&settings, Some(root.as_path())); + + assert_eq!(hydrated.codex.global.model, "gpt-5.5"); + assert_eq!(hydrated.codex.global.approval_policy, "on-request"); + assert_eq!(hydrated.codex.global.sandbox_mode, "workspace-write"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn hydrate_settings_from_codex_sources_imports_wsl_values_into_wsl_override_profile() { + let hydrated = hydrate_settings_from_codex_sources( + &AppSettingsPayload::default(), + None, + Some(&toml::Table::from_iter([ + ("model".to_string(), toml::Value::String("gpt-5.4".into())), + ( + "approval_policy".to_string(), + toml::Value::String("on-request".into()), + ), + ( + "sandbox_mode".to_string(), + toml::Value::String("workspace-write".into()), + ), + ( + "model_reasoning_effort".to_string(), + toml::Value::String("high".into()), + ), + ])), + ); + + let wsl = hydrated + .codex + .overrides + .wsl + .expect("wsl override should be created"); + assert!(!wsl.enabled); + assert_eq!(wsl.profile.model, "gpt-5.4"); + assert_eq!(wsl.profile.approval_policy, "on-request"); + assert_eq!(wsl.profile.sandbox_mode, "workspace-write"); + assert_eq!(wsl.profile.model_reasoning_effort, "high"); + assert!(hydrated.codex.global.model.is_empty()); + } + #[test] fn app_settings_update_keeps_partial_updates_atomic() { let app = test_app(); diff --git a/apps/server/src/services/claude.rs b/apps/server/src/services/claude.rs index 0fde6b0..13355ae 100644 --- a/apps/server/src/services/claude.rs +++ b/apps/server/src/services/claude.rs @@ -7,7 +7,7 @@ struct ClaudeHookEnvelope { payload: Value, } -fn parse_http_endpoint(endpoint: &str) -> Option<(String, u16, String)> { +pub(crate) fn parse_http_endpoint(endpoint: &str) -> Option<(String, u16, String)> { let trimmed = endpoint.trim(); let without_scheme = trimmed.strip_prefix("http://")?; let (host_port, path) = without_scheme @@ -89,6 +89,32 @@ pub(crate) fn resolve_claude_runtime_profile( .unwrap_or_else(|| settings.claude.global.clone()) } +pub(crate) fn build_claude_start_command( + target: &ExecTarget, + profile: &ClaudeRuntimeProfile, +) -> String { + let mut parts = Vec::with_capacity(1 + profile.startup_args.len()); + parts.push(crate::services::agent_client::escape_agent_command_part( + target, + &profile.executable, + )); + parts.extend( + profile + .startup_args + .iter() + .map(|arg| crate::services::agent_client::escape_agent_command_part(target, arg)), + ); + parts.join(" ") +} + +pub(crate) fn build_claude_resume_launch_command( + target: &ExecTarget, + profile: &ClaudeRuntimeProfile, + resume_id: &str, +) -> String { + build_claude_resume_command(&build_claude_start_command(target, profile), Some(resume_id)) +} + fn parse_http_json(stream: &TcpStream) -> Result { let cloned = stream.try_clone().map_err(|e| e.to_string())?; let mut reader = BufReader::new(cloned); @@ -146,7 +172,7 @@ fn handle_claude_hook_payload(app: &AppHandle, envelope: ClaudeHookEnvelope) { { let state: State = app.state(); if let Ok(internal_session_id) = envelope.session_id.parse::() { - let _ = set_session_claude_id( + let _ = set_session_resume_id( state, &envelope.workspace_id, internal_session_id, @@ -402,6 +428,7 @@ mod tests { }, idle_policy: default_idle_policy(), }, + agent_defaults: AgentDefaultsPayload::default(), claude: ClaudeSettingsPayload { global: ClaudeRuntimeProfile { executable: "claude".into(), @@ -424,6 +451,7 @@ mod tests { wsl: None, }, }, + codex: CodexSettingsPayload::default(), }; let resolved = resolve_claude_runtime_profile(&settings, &ExecTarget::Native); @@ -447,6 +475,7 @@ mod tests { }, idle_policy: default_idle_policy(), }, + agent_defaults: AgentDefaultsPayload::default(), claude: ClaudeSettingsPayload { global: ClaudeRuntimeProfile { executable: "claude".into(), @@ -460,6 +489,7 @@ mod tests { wsl: None, }, }, + codex: CodexSettingsPayload::default(), }; let resolved = resolve_claude_runtime_profile( @@ -470,4 +500,24 @@ mod tests { ); assert_eq!(resolved.executable, "claude"); } + + #[test] + fn build_claude_commands_split_start_and_resume() { + let profile = ClaudeRuntimeProfile { + executable: "claude".into(), + startup_args: vec!["--model".into(), "claude-sonnet-4-5".into()], + env: BTreeMap::new(), + settings_json: Value::Object(Map::new()), + global_config_json: Value::Object(Map::new()), + }; + + assert_eq!( + build_claude_start_command(&ExecTarget::Native, &profile), + "claude --model claude-sonnet-4-5" + ); + assert_eq!( + build_claude_resume_launch_command(&ExecTarget::Native, &profile, "resume-123"), + "claude --model claude-sonnet-4-5 --resume resume-123" + ); + } } diff --git a/apps/server/src/services/codex.rs b/apps/server/src/services/codex.rs new file mode 100644 index 0000000..23c9149 --- /dev/null +++ b/apps/server/src/services/codex.rs @@ -0,0 +1,416 @@ +use crate::*; + +fn pick_codex_profile_value(base: &str, override_: &str) -> String { + let trimmed = override_.trim(); + if trimmed.is_empty() { + base.to_string() + } else { + trimmed.to_string() + } +} + +fn push_codex_config_override(parts: &mut Vec, key: &str, value: &str) { + let trimmed = value.trim(); + if trimmed.is_empty() { + return; + } + parts.push("--config".to_string()); + parts.push(format!( + "{key}={}", + toml::Value::String(trimmed.to_string()) + )); +} + +fn build_codex_config_override_args(profile: &CodexRuntimeProfile) -> Vec { + let mut parts = Vec::new(); + push_codex_config_override(&mut parts, "model", &profile.model); + push_codex_config_override( + &mut parts, + "approval_policy", + &profile.approval_policy, + ); + push_codex_config_override(&mut parts, "sandbox_mode", &profile.sandbox_mode); + push_codex_config_override(&mut parts, "web_search", &profile.web_search); + push_codex_config_override( + &mut parts, + "model_reasoning_effort", + &profile.model_reasoning_effort, + ); + parts +} + +fn build_codex_feature_args() -> Vec { + vec!["--enable".to_string(), "codex_hooks".to_string()] +} + +fn merge_codex_runtime_profile( + base: &CodexRuntimeProfile, + override_: &CodexRuntimeProfile, +) -> CodexRuntimeProfile { + CodexRuntimeProfile { + executable: pick_codex_profile_value(&base.executable, &override_.executable), + extra_args: if override_.extra_args.is_empty() { + base.extra_args.clone() + } else { + override_.extra_args.clone() + }, + model: pick_codex_profile_value(&base.model, &override_.model), + approval_policy: pick_codex_profile_value( + &base.approval_policy, + &override_.approval_policy, + ), + sandbox_mode: pick_codex_profile_value(&base.sandbox_mode, &override_.sandbox_mode), + web_search: pick_codex_profile_value(&base.web_search, &override_.web_search), + model_reasoning_effort: pick_codex_profile_value( + &base.model_reasoning_effort, + &override_.model_reasoning_effort, + ), + env: base + .env + .iter() + .chain(override_.env.iter()) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + } +} + +pub(crate) fn resolve_codex_runtime_profile( + settings: &AppSettingsPayload, + target: &ExecTarget, +) -> CodexRuntimeProfile { + let override_ = match target { + ExecTarget::Native => settings.codex.overrides.native.as_ref(), + ExecTarget::Wsl { .. } => settings.codex.overrides.wsl.as_ref(), + }; + + override_ + .filter(|override_| override_.enabled) + .map(|override_| merge_codex_runtime_profile(&settings.codex.global, &override_.profile)) + .unwrap_or_else(|| settings.codex.global.clone()) +} + +pub(crate) fn build_codex_start_command( + target: &ExecTarget, + profile: &CodexRuntimeProfile, +) -> String { + let executable = crate::services::agent_client::escape_agent_command_part( + target, + &profile.executable, + ); + let args = profile + .extra_args + .iter() + .chain(build_codex_config_override_args(profile).iter()) + .chain(build_codex_feature_args().iter()) + .map(|arg| crate::services::agent_client::escape_agent_command_part(target, arg)) + .collect::>(); + let mut parts = vec![executable]; + parts.extend(args); + parts.join(" ") +} + +pub(crate) fn build_codex_resume_command( + target: &ExecTarget, + profile: &CodexRuntimeProfile, + resume_id: &str, +) -> String { + let escaped_resume_id = crate::services::agent_client::escape_agent_command_part( + target, + resume_id.trim(), + ); + let mut parts = vec![ + crate::services::agent_client::escape_agent_command_part(target, &profile.executable), + "resume".to_string(), + escaped_resume_id, + ]; + parts.extend( + profile + .extra_args + .iter() + .chain(build_codex_config_override_args(profile).iter()) + .chain(build_codex_feature_args().iter()) + .map(|arg| crate::services::agent_client::escape_agent_command_part(target, arg)), + ); + parts.join(" ") +} + +fn build_codex_hook_command(target: &ExecTarget) -> String { + if matches!(target, ExecTarget::Wsl { .. }) { + "\"$CODER_STUDIO_APP_BIN\" --coder-studio-codex-hook".to_string() + } else { + #[cfg(target_os = "windows")] + { + "\"%CODER_STUDIO_APP_BIN%\" --coder-studio-codex-hook".to_string() + } + #[cfg(not(target_os = "windows"))] + { + "\"$CODER_STUDIO_APP_BIN\" --coder-studio-codex-hook".to_string() + } + } +} + +fn is_coder_studio_codex_group(group: &Value) -> bool { + group + .get("hooks") + .and_then(Value::as_array) + .map(|hooks| { + hooks.iter().any(|hook| { + hook.get("type").and_then(Value::as_str) == Some("command") + && hook + .get("command") + .and_then(Value::as_str) + .map(|command| command.contains("--coder-studio-codex-hook")) + .unwrap_or(false) + }) + }) + .unwrap_or(false) +} + +fn build_hook_group(command: &str, matcher: Option<&str>) -> Value { + let mut group = Map::new(); + if let Some(value) = matcher { + group.insert("matcher".to_string(), Value::String(value.to_string())); + } + group.insert( + "hooks".to_string(), + Value::Array(vec![json!({ + "type": "command", + "command": command + })]), + ); + Value::Object(group) +} + +fn upsert_hook_groups( + hooks_root: &mut Map, + event_name: &str, + matcher: Option<&str>, + command: &str, +) { + let entry = hooks_root + .entry(event_name.to_string()) + .or_insert_with(|| Value::Array(Vec::new())); + if !entry.is_array() { + *entry = Value::Array(Vec::new()); + } + let groups = entry.as_array_mut().expect("array"); + groups.retain(|group| !is_coder_studio_codex_group(group)); + groups.push(build_hook_group(command, matcher)); +} + +pub(crate) fn ensure_codex_hook_settings(cwd: &str, target: &ExecTarget) -> Result<(), String> { + let current = if matches!(target, ExecTarget::Wsl { .. }) { + run_cmd( + target, + cwd, + &[ + "/bin/sh", + "-lc", + "if [ -f .codex/hooks.json ]; then cat .codex/hooks.json; else printf '{}'; fi", + ], + ) + .unwrap_or_else(|_| "{}".to_string()) + } else { + let hooks_path = PathBuf::from(cwd).join(".codex").join("hooks.json"); + if hooks_path.exists() { + std::fs::read_to_string(&hooks_path).map_err(|e| e.to_string())? + } else { + "{}".to_string() + } + }; + + let mut root = + serde_json::from_str::(¤t).unwrap_or_else(|_| Value::Object(Map::new())); + if !root.is_object() { + root = Value::Object(Map::new()); + } + let root_obj = root.as_object_mut().expect("object"); + let hooks_value = root_obj + .entry("hooks".to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if !hooks_value.is_object() { + *hooks_value = Value::Object(Map::new()); + } + let hooks_obj = hooks_value.as_object_mut().expect("object"); + let command = build_codex_hook_command(target); + + upsert_hook_groups(hooks_obj, "SessionStart", Some("startup|resume"), &command); + upsert_hook_groups(hooks_obj, "UserPromptSubmit", None, &command); + upsert_hook_groups(hooks_obj, "PreToolUse", Some("Bash"), &command); + upsert_hook_groups(hooks_obj, "PostToolUse", Some("Bash"), &command); + upsert_hook_groups(hooks_obj, "Stop", None, &command); + + let serialized = serde_json::to_string_pretty(&root).map_err(|e| e.to_string())?; + if matches!(target, ExecTarget::Wsl { .. }) { + let script = format!( + "mkdir -p .codex && printf %s {} > .codex/hooks.json", + shell_escape(&serialized) + ); + run_cmd(target, cwd, &["/bin/sh", "-lc", &script]).map(|_| ()) + } else { + let hooks_dir = PathBuf::from(cwd).join(".codex"); + std::fs::create_dir_all(&hooks_dir).map_err(|e| e.to_string())?; + let hooks_path = hooks_dir.join("hooks.json"); + std::fs::write(hooks_path, serialized).map_err(|e| e.to_string()) + } +} + +pub(crate) fn run_codex_hook_helper() { + let _ = (|| -> Result<(), String> { + let endpoint = std::env::var("CODER_STUDIO_HOOK_ENDPOINT").map_err(|e| e.to_string())?; + let workspace_id = std::env::var("CODER_STUDIO_WORKSPACE_ID").map_err(|e| e.to_string())?; + let session_id = std::env::var("CODER_STUDIO_SESSION_ID").map_err(|e| e.to_string())?; + let (host, port, path) = parse_http_endpoint(&endpoint).ok_or("invalid_hook_endpoint")?; + + let mut stdin = String::new(); + std::io::stdin() + .read_to_string(&mut stdin) + .map_err(|e| e.to_string())?; + let payload = serde_json::from_str::(&stdin).map_err(|e| e.to_string())?; + let body = json!({ + "workspace_id": workspace_id, + "session_id": session_id, + "payload": payload + }) + .to_string(); + + let mut stream = TcpStream::connect((host.as_str(), port)).map_err(|e| e.to_string())?; + let request = format!( + "POST {path} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(request.as_bytes()) + .map_err(|e| e.to_string())?; + stream.flush().map_err(|e| e.to_string())?; + Ok(()) + })(); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn resolve_codex_runtime_profile_prefers_enabled_target_override() { + let settings = AppSettingsPayload { + codex: CodexSettingsPayload { + global: CodexRuntimeProfile { + executable: "codex".into(), + extra_args: vec!["--search".into()], + model: "gpt-5.4".into(), + approval_policy: String::new(), + sandbox_mode: "workspace-write".into(), + web_search: String::new(), + model_reasoning_effort: String::new(), + env: BTreeMap::new(), + }, + overrides: CodexTargetOverrides { + native: Some(TargetCodexOverride { + enabled: true, + profile: CodexRuntimeProfile { + executable: "codex-nightly".into(), + extra_args: vec!["--full-auto".into()], + model: String::new(), + approval_policy: "on-request".into(), + sandbox_mode: String::new(), + web_search: "live".into(), + model_reasoning_effort: "high".into(), + env: BTreeMap::new(), + }, + }), + wsl: None, + }, + }, + ..AppSettingsPayload::default() + }; + + let resolved = resolve_codex_runtime_profile(&settings, &ExecTarget::Native); + assert_eq!(resolved.executable, "codex-nightly"); + assert_eq!(resolved.extra_args, vec!["--full-auto"]); + assert_eq!(resolved.model, "gpt-5.4"); + assert_eq!(resolved.approval_policy, "on-request"); + assert_eq!(resolved.sandbox_mode, "workspace-write"); + assert_eq!(resolved.web_search, "live"); + assert_eq!(resolved.model_reasoning_effort, "high"); + } + + #[test] + fn build_codex_commands_split_start_and_resume() { + let profile = CodexRuntimeProfile { + executable: "codex".into(), + extra_args: vec!["--full-auto".into()], + model: String::new(), + approval_policy: String::new(), + sandbox_mode: String::new(), + web_search: String::new(), + model_reasoning_effort: String::new(), + env: BTreeMap::new(), + }; + + assert_eq!( + build_codex_start_command(&ExecTarget::Native, &profile), + "codex --full-auto --enable codex_hooks" + ); + assert_eq!( + build_codex_resume_command(&ExecTarget::Native, &profile, "resume-123"), + "codex resume resume-123 --full-auto --enable codex_hooks" + ); + } + + fn expected_config_args(target: &ExecTarget, values: &[(&str, &str)]) -> String { + values + .iter() + .flat_map(|(key, value)| { + [ + crate::services::agent_client::escape_agent_command_part(target, "--config"), + crate::services::agent_client::escape_agent_command_part( + target, + &format!( + "{key}={}", + toml::Value::String((*value).to_string()) + ), + ), + ] + }) + .collect::>() + .join(" ") + } + + #[test] + fn build_codex_commands_append_structured_config_overrides() { + let target = ExecTarget::Native; + let profile = CodexRuntimeProfile { + executable: "codex".into(), + extra_args: vec!["--full-auto".into()], + model: "gpt-5.4".into(), + approval_policy: "on-request".into(), + sandbox_mode: "workspace-write".into(), + web_search: "live".into(), + model_reasoning_effort: "high".into(), + env: BTreeMap::new(), + }; + let expected_config = expected_config_args( + &target, + &[ + ("model", "gpt-5.4"), + ("approval_policy", "on-request"), + ("sandbox_mode", "workspace-write"), + ("web_search", "live"), + ("model_reasoning_effort", "high"), + ], + ); + + assert_eq!( + build_codex_start_command(&target, &profile), + format!("codex --full-auto {expected_config} --enable codex_hooks") + ); + assert_eq!( + build_codex_resume_command(&target, &profile, "resume-123"), + format!( + "codex resume resume-123 --full-auto {expected_config} --enable codex_hooks" + ) + ); + } +} diff --git a/apps/server/src/services/mod.rs b/apps/server/src/services/mod.rs index e207d9e..4883eec 100644 --- a/apps/server/src/services/mod.rs +++ b/apps/server/src/services/mod.rs @@ -1,6 +1,8 @@ pub(crate) mod agent; +pub(crate) mod agent_client; pub(crate) mod app_settings; pub(crate) mod claude; +pub(crate) mod codex; pub(crate) mod filesystem; pub(crate) mod git; pub(crate) mod system; diff --git a/apps/server/src/services/workspace.rs b/apps/server/src/services/workspace.rs index a17faae..b3376fb 100644 --- a/apps/server/src/services/workspace.rs +++ b/apps/server/src/services/workspace.rs @@ -158,9 +158,10 @@ pub(crate) fn workspace_view_update( pub(crate) fn create_session( workspace_id: String, mode: SessionMode, + provider: AgentProvider, state: State<'_, AppState>, ) -> Result { - create_workspace_session(state, &workspace_id, mode) + create_workspace_session(state, &workspace_id, mode, provider) } pub(crate) fn session_update( @@ -333,8 +334,13 @@ mod tests { fn archive_session_keeps_suspended_status_after_runtime_stop() { let app = test_app(); let workspace_id = launch_test_workspace(&app, "/tmp/ws-history-archive-test"); - let created = - create_session(workspace_id.clone(), SessionMode::Branch, app.state()).unwrap(); + let created = create_session( + workspace_id.clone(), + SessionMode::Branch, + AgentProvider::Claude, + app.state(), + ) + .unwrap(); set_session_status( app.state(), &workspace_id, @@ -358,8 +364,13 @@ mod tests { fn restore_and_delete_session_round_trip_history_records() { let app = test_app(); let workspace_id = launch_test_workspace(&app, "/tmp/ws-history-restore-test"); - let created = - create_session(workspace_id.clone(), SessionMode::Branch, app.state()).unwrap(); + let created = create_session( + workspace_id.clone(), + SessionMode::Branch, + AgentProvider::Claude, + app.state(), + ) + .unwrap(); archive_session(workspace_id.clone(), created.id, app.state()).unwrap(); let history_before = list_session_history(app.state()).unwrap(); @@ -382,7 +393,13 @@ mod tests { fn close_workspace_archives_all_sessions_but_keeps_workspace_history_visible() { let app = test_app(); let workspace_id = launch_test_workspace(&app, "/tmp/ws-history-close-test"); - let extra = create_session(workspace_id.clone(), SessionMode::Branch, app.state()).unwrap(); + let extra = create_session( + workspace_id.clone(), + SessionMode::Branch, + AgentProvider::Claude, + app.state(), + ) + .unwrap(); let live_ids = workspace_snapshot(workspace_id.clone(), app.state()) .unwrap() .sessions @@ -404,4 +421,49 @@ mod tests { assert!(records.iter().any(|record| record.session_id == live_id)); } } + + #[test] + fn create_session_persists_provider_as_session_truth() { + let app = test_app(); + let workspace_id = launch_test_workspace(&app, "/tmp/ws-provider-persist-test"); + + let created = create_session( + workspace_id.clone(), + SessionMode::Branch, + AgentProvider::Codex, + app.state(), + ) + .unwrap(); + + assert_eq!(created.provider, AgentProvider::Codex); + assert_eq!(created.resume_id, None); + + let snapshot = workspace_snapshot(workspace_id.clone(), app.state()).unwrap(); + let restored = snapshot + .sessions + .into_iter() + .find(|session| session.id == created.id) + .expect("session should exist in snapshot"); + assert_eq!(restored.provider, AgentProvider::Codex); + assert_eq!(restored.resume_id, None); + } + + #[test] + fn launch_workspace_starts_without_persisted_sessions() { + let app = test_app(); + + let result = launch_workspace_record( + app.state(), + WorkspaceSource { + kind: WorkspaceSourceKind::Local, + path_or_url: "/tmp/ws-empty-session-launch-test".to_string(), + target: ExecTarget::Native, + }, + "/tmp/ws-empty-session-launch-test".to_string(), + default_idle_policy(), + ) + .unwrap(); + + assert!(result.snapshot.sessions.is_empty()); + } } diff --git a/apps/server/src/services/workspace_runtime.rs b/apps/server/src/services/workspace_runtime.rs index cbcdd00..e25168c 100644 --- a/apps/server/src/services/workspace_runtime.rs +++ b/apps/server/src/services/workspace_runtime.rs @@ -783,8 +783,13 @@ mod tests { fn workspace_runtime_attach_includes_agent_lifecycle_replay() { let app = test_app(); let workspace_id = launch_test_workspace(&app, "/tmp/ws-runtime-lifecycle-replay-test"); - let session = create_workspace_session(app.state(), &workspace_id, SessionMode::Branch) - .expect("session should be created"); + let session = create_workspace_session( + app.state(), + &workspace_id, + SessionMode::Branch, + AgentProvider::Claude, + ) + .expect("session should be created"); emit_agent_lifecycle( &app, @@ -818,8 +823,13 @@ mod tests { fn workspace_runtime_attach_keeps_created_session_view_and_claude_id() { let app = test_app(); let workspace_id = launch_test_workspace(&app, "/tmp/ws-runtime-session-view-test"); - let session = create_workspace_session(app.state(), &workspace_id, SessionMode::Branch) - .expect("session should be created"); + let session = create_workspace_session( + app.state(), + &workspace_id, + SessionMode::Branch, + AgentProvider::Claude, + ) + .expect("session should be created"); patch_workspace_view_state( app.state(), @@ -851,7 +861,7 @@ mod tests { stream: None, unread: None, last_active_at: None, - claude_session_id: Some("claude-runtime-attach".to_string()), + resume_id: Some("claude-runtime-attach".to_string()), }, ) .expect("session should be updated"); @@ -872,10 +882,7 @@ mod tests { .find(|candidate| candidate.id == session.id) .expect("created session should be present"); assert_eq!(restored.status, SessionStatus::Interrupted); - assert_eq!( - restored.claude_session_id.as_deref(), - Some("claude-runtime-attach") - ); + assert_eq!(restored.resume_id.as_deref(), Some("claude-runtime-attach")); assert_eq!( runtime.snapshot.view_state.active_session_id, session.id.to_string() diff --git a/apps/web/src/components/HistoryDrawer/HistoryDrawer.tsx b/apps/web/src/components/HistoryDrawer/HistoryDrawer.tsx index bc04796..b3e4826 100644 --- a/apps/web/src/components/HistoryDrawer/HistoryDrawer.tsx +++ b/apps/web/src/components/HistoryDrawer/HistoryDrawer.tsx @@ -123,6 +123,7 @@ export const HistoryDrawer = ({ ) : null}
+ {record.provider === "codex" ? "Codex" : "Claude"} {recordMetaLabel(record, t)} {record.status} {new Date(record.lastActiveAt).toLocaleString()} diff --git a/apps/web/src/components/RuntimeValidationOverlay/RuntimeValidationOverlay.tsx b/apps/web/src/components/RuntimeValidationOverlay/RuntimeValidationOverlay.tsx index 6af5bd1..2b825f9 100644 --- a/apps/web/src/components/RuntimeValidationOverlay/RuntimeValidationOverlay.tsx +++ b/apps/web/src/components/RuntimeValidationOverlay/RuntimeValidationOverlay.tsx @@ -3,7 +3,7 @@ import type { Translator } from "../../i18n"; import type { ExecTarget } from "../../state/workbench"; import { HeaderCloseIcon } from "../icons"; -export type RuntimeRequirementId = "claude" | "git"; +export type RuntimeRequirementId = "claude" | "codex" | "git"; export type RuntimeRequirementStatus = { id: RuntimeRequirementId; @@ -39,6 +39,12 @@ const requirementCopy = (id: RuntimeRequirementId, t: Translator) => { hint: t("runtimeCheckClaudeHint"), }; } + if (id === "codex") { + return { + label: t("runtimeCheckCodexLabel"), + hint: t("runtimeCheckCodexHint"), + }; + } return { label: t("runtimeCheckGitLabel"), diff --git a/apps/web/src/components/Settings/CodexSettingsPanel.tsx b/apps/web/src/components/Settings/CodexSettingsPanel.tsx new file mode 100644 index 0000000..cffa127 --- /dev/null +++ b/apps/web/src/components/Settings/CodexSettingsPanel.tsx @@ -0,0 +1,387 @@ +import { useMemo, useState } from "react"; +import type { Locale, Translator } from "../../i18n.ts"; +import type { AppSettings, ClaudeSettingsScope } from "../../types/app.ts"; +import { + formatCodexRuntimeCommand, + getCodexScopeProfile, + isCodexScopeOverrideEnabled, + patchCodexStructuredSettings, + setCodexScopeOverrideEnabled, +} from "../../shared/app/claude-settings.ts"; + +type CodexSettingsPanelProps = { + locale: Locale; + settings: AppSettings; + onChange: (settings: AppSettings) => void; + t: Translator; +}; + +const APPROVAL_POLICY_OPTIONS = [ + { value: "", labelKey: "codexSelectUnsetOption" }, + { value: "untrusted", labelKey: "codexApprovalPolicyUntrustedOption" }, + { value: "on-request", labelKey: "codexApprovalPolicyOnRequestOption" }, + { value: "never", labelKey: "codexApprovalPolicyNeverOption" }, +] as const; + +const SANDBOX_MODE_OPTIONS = [ + { value: "", labelKey: "codexSelectUnsetOption" }, + { value: "read-only", labelKey: "codexSandboxReadOnlyOption" }, + { value: "workspace-write", labelKey: "codexSandboxWorkspaceWriteOption" }, + { value: "danger-full-access", labelKey: "codexSandboxDangerFullAccessOption" }, +] as const; + +const WEB_SEARCH_OPTIONS = [ + { value: "", labelKey: "codexSelectUnsetOption" }, + { value: "disabled", labelKey: "codexWebSearchDisabledOption" }, + { value: "cached", labelKey: "codexWebSearchCachedOption" }, + { value: "live", labelKey: "codexWebSearchLiveOption" }, +] as const; + +const REASONING_EFFORT_OPTIONS = [ + { value: "", labelKey: "codexSelectUnsetOption" }, + { value: "minimal", labelKey: "codexReasoningMinimalOption" }, + { value: "low", labelKey: "codexReasoningLowOption" }, + { value: "medium", labelKey: "codexReasoningMediumOption" }, + { value: "high", labelKey: "codexReasoningHighOption" }, + { value: "xhigh", labelKey: "codexReasoningXhighOption" }, +] as const; + +const linesToList = (value: string) => value + .split("\n") + .map((entry) => entry.trim()) + .filter(Boolean); + +const listToLines = (value: string[]) => value.join("\n"); + +const envToText = (env: Record) => Object.entries(env) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + +const textToEnv = (value: string) => Object.fromEntries( + value + .split("\n") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const splitIndex = entry.indexOf("="); + if (splitIndex === -1) { + return [entry, ""] as const; + } + return [entry.slice(0, splitIndex).trim(), entry.slice(splitIndex + 1)] as const; + }) + .filter(([key]) => Boolean(key)), +); + +const FieldCopy = ({ + label, + meta, +}: { + label: string; + meta?: string; +}) => ( +
+ + {label} + + {meta ? {meta} : null} +
+); + +const TextField = ({ + label, + meta, + value, + onChange, + placeholder, + testId, + className = "", +}: { + label: string; + meta?: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + testId?: string; + className?: string; +}) => ( + +); + +const TextareaField = ({ + label, + meta, + hint, + value, + onChange, + placeholder, + rows, + testId, +}: { + label: string; + meta?: string; + hint?: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + rows: number; + testId?: string; +}) => ( +