diff --git a/CLAUDE.md b/CLAUDE.md index fae0758..35db516 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,10 +65,36 @@ Universal schema guidance: - `sandbox-agent api sessions reject-question` ↔ `POST /v1/sessions/{sessionId}/questions/{questionId}/reject` - `sandbox-agent api sessions reply-permission` ↔ `POST /v1/sessions/{sessionId}/permissions/{permissionId}/reply` -## OpenCode CLI (Experimental) +## OpenCode Compatibility Layer `sandbox-agent opencode` starts a sandbox-agent server and attaches an OpenCode session (uses `/opencode`). +### Session ownership + +Sessions are stored **only** in sandbox-agent's v1 `SessionManager` — they are never sent to or stored in the native OpenCode server. The OpenCode TUI reads sessions via `GET /session` which the compat layer serves from the v1 store. The native OpenCode process has no knowledge of sessions. + +### Proxy elimination strategy + +The `/opencode` compat layer (`opencode_compat.rs`) historically proxied many endpoints to the native OpenCode server via `proxy_native_opencode()`. The goal is to **eliminate proxying** by implementing each endpoint natively using the v1 `SessionManager` as the single source of truth. + +**Already de-proxied** (use v1 SessionManager directly): +- `GET /session` — `oc_session_list` reads from `SessionManager::list_sessions()` +- `GET /session/{id}` — `oc_session_get` reads from `SessionManager::get_session_info()` +- `GET /session/status` — `oc_session_status` derives busy/idle from v1 session `ended` flag +- `POST /tui/open-sessions` — returns `true` directly (TUI fetches sessions from `GET /session`) +- `POST /tui/select-session` — emits `tui.session.select` event via the OpenCode event broadcaster + +**Still proxied** (none of these reference session IDs or the session list — all are session-agnostic): +- `GET /command` — command list +- `GET /config`, `PATCH /config` — project config read/write +- `GET /global/config`, `PATCH /global/config` — global config read/write +- `GET /tui/control/next`, `POST /tui/control/response` — TUI control loop +- `POST /tui/append-prompt`, `/tui/submit-prompt`, `/tui/clear-prompt` — prompt management +- `POST /tui/open-help`, `/tui/open-themes`, `/tui/open-models` — TUI navigation +- `POST /tui/execute-command`, `/tui/show-toast`, `/tui/publish` — TUI actions + +When converting a proxied endpoint: add needed fields to `SessionState`/`SessionInfo` in `router.rs`, implement the logic natively in `opencode_compat.rs`, and use `session_info_to_opencode_value()` to format responses. + ## Post-Release Testing After cutting a release, verify the release works correctly. Run `/project:post-release-testing` to execute the testing agent. diff --git a/server/packages/sandbox-agent/src/cli.rs b/server/packages/sandbox-agent/src/cli.rs index 9fc38bc..c27ed8b 100644 --- a/server/packages/sandbox-agent/src/cli.rs +++ b/server/packages/sandbox-agent/src/cli.rs @@ -703,6 +703,8 @@ fn run_sessions(command: &SessionsCommand, cli: &CliConfig) -> Result<(), CliErr model: args.model.clone(), variant: args.variant.clone(), agent_version: args.agent_version.clone(), + directory: None, + title: None, }; let path = format!("{API_PREFIX}/sessions/{}", args.session_id); let response = ctx.post(&path, &body)?; diff --git a/server/packages/sandbox-agent/src/opencode_compat.rs b/server/packages/sandbox-agent/src/opencode_compat.rs index 55e835c..3ee9e5b 100644 --- a/server/packages/sandbox-agent/src/opencode_compat.rs +++ b/server/packages/sandbox-agent/src/opencode_compat.rs @@ -29,6 +29,7 @@ use utoipa::{IntoParams, OpenApi, ToSchema}; use crate::router::{ is_question_tool_action, AgentModelInfo, AppState, CreateSessionRequest, PermissionReply, + SessionInfo, }; use sandbox_agent_agent_management::agents::AgentId; use sandbox_agent_agent_management::credentials::{ @@ -153,6 +154,27 @@ impl OpenCodeSessionRecord { } } +/// Convert a v1 `SessionInfo` to the OpenCode session JSON format. +fn session_info_to_opencode_value(info: &SessionInfo, default_project_id: &str) -> Value { + let title = info + .title + .clone() + .unwrap_or_else(|| format!("Session {}", info.session_id)); + let directory = info.directory.clone().unwrap_or_default(); + json!({ + "id": info.session_id, + "slug": format!("session-{}", info.session_id), + "projectID": default_project_id, + "directory": directory, + "title": title, + "version": "0", + "time": { + "created": info.created_at, + "updated": info.updated_at, + } + }) +} + #[derive(Clone, Debug)] struct OpenCodeMessageRecord { info: Value, @@ -479,6 +501,14 @@ async fn ensure_backing_session( ) -> Result<(), SandboxError> { let model = model.filter(|value| !value.trim().is_empty()); let variant = variant.filter(|value| !value.trim().is_empty()); + // Pull directory and title from the OpenCode session record if available. + let (directory, title) = { + let sessions = state.opencode.sessions.lock().await; + sessions + .get(session_id) + .map(|s| (Some(s.directory.clone()), Some(s.title.clone()))) + .unwrap_or((None, None)) + }; let request = CreateSessionRequest { agent: agent.to_string(), agent_mode: None, @@ -486,6 +516,8 @@ async fn ensure_backing_session( model: model.clone(), variant: variant.clone(), agent_version: None, + directory, + title, }; match state .inner @@ -3505,8 +3537,12 @@ async fn oc_session_create( tag = "opencode" )] async fn oc_session_list(State(state): State>) -> impl IntoResponse { - let sessions = state.opencode.sessions.lock().await; - let values: Vec = sessions.values().map(|s| s.to_value()).collect(); + let sessions = state.inner.session_manager().list_sessions().await; + let project_id = &state.opencode.default_project_id; + let values: Vec = sessions + .iter() + .map(|s| session_info_to_opencode_value(s, project_id)) + .collect(); (StatusCode::OK, Json(json!(values))) } @@ -3523,9 +3559,18 @@ async fn oc_session_get( _headers: HeaderMap, _query: Query, ) -> impl IntoResponse { - let sessions = state.opencode.sessions.lock().await; - if let Some(session) = sessions.get(&session_id) { - return (StatusCode::OK, Json(session.to_value())).into_response(); + let project_id = &state.opencode.default_project_id; + if let Some(info) = state + .inner + .session_manager() + .get_session_info(&session_id) + .await + { + return ( + StatusCode::OK, + Json(session_info_to_opencode_value(&info, project_id)), + ) + .into_response(); } not_found("Session not found").into_response() } @@ -3586,10 +3631,11 @@ async fn oc_session_delete( tag = "opencode" )] async fn oc_session_status(State(state): State>) -> impl IntoResponse { - let sessions = state.opencode.sessions.lock().await; + let sessions = state.inner.session_manager().list_sessions().await; let mut status_map = serde_json::Map::new(); - for id in sessions.keys() { - status_map.insert(id.clone(), json!({"type": "idle"})); + for s in &sessions { + let status = if s.ended { "idle" } else { "busy" }; + status_map.insert(s.session_id.clone(), json!({"type": status})); } (StatusCode::OK, Json(Value::Object(status_map))) } @@ -5048,20 +5094,9 @@ async fn oc_tui_open_help( tag = "opencode" )] async fn oc_tui_open_sessions( - State(state): State>, - headers: HeaderMap, + State(_state): State>, + _headers: HeaderMap, ) -> Response { - if let Some(response) = proxy_native_opencode( - &state, - reqwest::Method::POST, - "/tui/open-sessions", - &headers, - None, - ) - .await - { - return response; - } bool_ok(true).into_response() } @@ -5250,19 +5285,21 @@ async fn oc_tui_publish( )] async fn oc_tui_select_session( State(state): State>, - headers: HeaderMap, + _headers: HeaderMap, body: Option>, ) -> Response { - if let Some(response) = proxy_native_opencode( - &state, - reqwest::Method::POST, - "/tui/select-session", - &headers, - body.map(|json| json.0), - ) - .await - { - return response; + if let Some(Json(body)) = body { + // Emit a tui.session.select event so the TUI navigates to the session. + let session_id = body + .get("sessionID") + .and_then(Value::as_str) + .unwrap_or_default(); + state.opencode.emit_event(json!({ + "type": "tui.session.select", + "properties": { + "sessionID": session_id + } + })); } bool_ok(true).into_response() } diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 59b89c3..6ce89e2 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -429,6 +429,10 @@ struct SessionState { claude_message_counter: u64, pending_assistant_native_ids: VecDeque, pending_assistant_counter: u64, + created_at: i64, + updated_at: i64, + directory: Option, + title: Option, } #[derive(Debug, Clone)] @@ -455,6 +459,10 @@ impl SessionState { request.permission_mode.as_deref(), )?; let (broadcaster, _rx) = broadcast::channel(256); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); Ok(Self { session_id, @@ -488,6 +496,10 @@ impl SessionState { claude_message_counter: 0, pending_assistant_native_ids: VecDeque::new(), pending_assistant_counter: 0, + created_at: now, + updated_at: now, + directory: request.directory.clone(), + title: request.title.clone(), }) } @@ -529,6 +541,12 @@ impl SessionState { } } } + if !events.is_empty() { + self.updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(self.updated_at); + } events } @@ -2117,7 +2135,7 @@ impl SessionManager { Ok(EventsResponse { events, has_more }) } - async fn list_sessions(&self) -> Vec { + pub(crate) async fn list_sessions(&self) -> Vec { let sessions = self.sessions.lock().await; sessions .iter() @@ -2132,10 +2150,33 @@ impl SessionManager { native_session_id: state.native_session_id.clone(), ended: state.ended, event_count: state.events.len() as u64, + created_at: state.created_at, + updated_at: state.updated_at, + directory: state.directory.clone(), + title: state.title.clone(), }) .collect() } + pub(crate) async fn get_session_info(&self, session_id: &str) -> Option { + let sessions = self.sessions.lock().await; + Self::session_ref(&sessions, session_id).map(|state| SessionInfo { + session_id: state.session_id.clone(), + agent: state.agent.as_str().to_string(), + agent_mode: state.agent_mode.clone(), + permission_mode: state.permission_mode.clone(), + model: state.model.clone(), + variant: state.variant.clone(), + native_session_id: state.native_session_id.clone(), + ended: state.ended, + event_count: state.events.len() as u64, + created_at: state.created_at, + updated_at: state.updated_at, + directory: state.directory.clone(), + title: state.title.clone(), + }) + } + pub(crate) async fn subscribe( &self, session_id: &str, @@ -4373,6 +4414,10 @@ pub struct SessionInfo { pub native_session_id: Option, pub ended: bool, pub event_count: u64, + pub created_at: i64, + pub updated_at: i64, + pub directory: Option, + pub title: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] @@ -4400,6 +4445,10 @@ pub struct CreateSessionRequest { pub variant: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub directory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] @@ -6363,6 +6412,8 @@ pub mod test_utils { model: None, variant: None, agent_version: None, + directory: None, + title: None, }; let mut session = SessionState::new(session_id.to_string(), agent, &request).expect("session"); diff --git a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__permissions__assert_session_snapshot@permission_events_mock.snap.new b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__permissions__assert_session_snapshot@permission_events_mock.snap.new new file mode 100644 index 0000000..abc9c33 --- /dev/null +++ b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__permissions__assert_session_snapshot@permission_events_mock.snap.new @@ -0,0 +1,63 @@ +--- +source: server/packages/sandbox-agent/tests/sessions/permissions.rs +assertion_line: 12 +expression: value +--- +- metadata: true + seq: 1 + session: started + type: session.started +- item: + content_types: + - text + kind: message + role: user + status: in_progress + seq: 2 + type: item.started +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 3 + type: item.delta +- item: + content_types: + - text + kind: message + role: user + status: completed + seq: 4 + type: item.completed +- item: + content_types: + - text + kind: message + role: assistant + status: in_progress + seq: 5 + type: item.started +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 6 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 7 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 8 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 9 + type: item.delta