Skip to main content

oino_client/
in_process.rs

1//! [`InProcessBackend`]: the full command set over an `oino-core`
2//! [`AgentSessionRuntime`], without serialisation.
3
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::sync::atomic::{AtomicBool, Ordering};
7
8use futures::StreamExt;
9use futures::future::BoxFuture;
10use futures::stream::BoxStream;
11use oino_ai::JsonValue;
12use oino_core::events::{ClientEvent, OinoEvent, ReplaceReason};
13use oino_core::runtime::{AgentSessionRuntime, ForkPosition};
14use oino_core::session::{AgentSession, CycleDirection, InputSource, PromptOptions, RewindResult};
15use oino_core::ui::UiResponse;
16use oino_session::EntryId;
17use serde::Serialize;
18use serde_json::json;
19
20use crate::backend::{ClientError, SessionBackend};
21use crate::follow::Followers;
22use crate::protocol::{AgentCommand, OinoCommand, RpcCommand, RpcResponseData, RpcSessionState};
23
24/// Name and version reported by `_oino/hello`.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct ServerInfo {
27    /// Server name (`oino`).
28    pub name: String,
29    /// Server version.
30    pub version: String,
31}
32
33impl Default for ServerInfo {
34    fn default() -> Self {
35        Self {
36            name: "oino".to_owned(),
37            version: env!("CARGO_PKG_VERSION").to_owned(),
38        }
39    }
40}
41
42/// A [`SessionBackend`] over a live runtime in this process.
43///
44/// Runtime-agnostic (GA-40): commands awaited from a non-tokio executor (GPUI) run on the
45/// tokio runtime the backend was created on.
46pub struct InProcessBackend {
47    shared: Arc<Shared>,
48    handle: Option<tokio::runtime::Handle>,
49}
50
51struct Shared {
52    runtime: Arc<AgentSessionRuntime>,
53    server: ServerInfo,
54    closed: AtomicBool,
55    followers: Followers,
56}
57
58impl std::fmt::Debug for InProcessBackend {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        f.debug_struct("InProcessBackend")
61            .field("server", &self.shared.server)
62            .finish_non_exhaustive()
63    }
64}
65
66fn data(value: impl Serialize) -> Result<RpcResponseData, ClientError> {
67    serde_json::to_value(value)
68        .map(Some)
69        .map_err(|e| ClientError::Protocol(e.to_string()))
70}
71
72fn failed(error: impl std::fmt::Display) -> ClientError {
73    ClientError::Command(error.to_string())
74}
75
76fn find_agent(
77    session: &AgentSession,
78    agent_id: &str,
79) -> Result<Arc<oino_core::agents::ChildAgent>, ClientError> {
80    session
81        .find_agent(agent_id)
82        .ok_or_else(|| ClientError::Command(format!("Unknown agent: {agent_id}")))
83}
84
85impl InProcessBackend {
86    /// Wraps `runtime`.
87    #[must_use]
88    pub fn new(runtime: Arc<AgentSessionRuntime>) -> Self {
89        Self::with_server_info(runtime, ServerInfo::default())
90    }
91
92    /// Wraps `runtime`, reporting `server` from `_oino/hello`. Commands awaited outside a
93    /// tokio runtime run on the runtime current here (or the session's own runtime).
94    #[must_use]
95    pub fn with_server_info(runtime: Arc<AgentSessionRuntime>, server: ServerInfo) -> Self {
96        let handle = tokio::runtime::Handle::try_current()
97            .ok()
98            .or_else(|| runtime.session().runtime_handle());
99        Self {
100            shared: Arc::new(Shared {
101                runtime,
102                server,
103                closed: AtomicBool::new(false),
104                followers: Followers::default(),
105            }),
106            handle,
107        }
108    }
109
110    /// The runtime.
111    #[must_use]
112    pub fn runtime(&self) -> &Arc<AgentSessionRuntime> {
113        &self.shared.runtime
114    }
115
116    /// Returns the state.
117    #[must_use]
118    pub fn state(&self) -> RpcSessionState {
119        self.shared.state()
120    }
121
122    /// Runs `task` here when already on a tokio runtime, else on the backend's runtime.
123    fn on_runtime<T: Send + 'static>(
124        &self,
125        task: impl Future<Output = T> + Send + 'static,
126        closed: T,
127    ) -> BoxFuture<'static, T> {
128        match (tokio::runtime::Handle::try_current(), &self.handle) {
129            (Err(_), Some(handle)) => {
130                let join = handle.spawn(task);
131                Box::pin(async move { join.await.unwrap_or(closed) })
132            }
133            _ => Box::pin(task),
134        }
135    }
136}
137
138impl Shared {
139    fn session(&self) -> AgentSession {
140        self.runtime.session()
141    }
142
143    /// Resolves a client path against the session cwd (the process cwd is not used,
144    /// which is the session cwd for the `oino` binary).
145    fn path(&self, path: &str) -> PathBuf {
146        let path = Path::new(path);
147        if path.is_absolute() {
148            path.to_path_buf()
149        } else {
150            self.runtime.services().cwd.join(path)
151        }
152    }
153
154    /// Points every event stream at the session that replaced the previous one.
155    fn rebind(&self, reason: ReplaceReason, cancelled: bool) {
156        if cancelled {
157            return;
158        }
159        let session = self.session();
160        let announce = ClientEvent::Oino(OinoEvent::SessionReplaced {
161            session_file: session.session_file().map(|p| p.display().to_string()),
162            session_id: session.session_id(),
163            reason,
164        });
165        self.followers
166            .rebind(|| self.runtime.session().subscribe(), &announce);
167    }
168
169    fn state(&self) -> RpcSessionState {
170        let session = self.session();
171        let auto_compaction_enabled = session.with_settings(|s| s.settings().compaction_enabled());
172        RpcSessionState {
173            model: session.model(),
174            thinking_level: session.thinking_level(),
175            is_streaming: session.is_streaming(),
176            is_compacting: session.is_compacting(),
177            steering_mode: session.steering_mode(),
178            follow_up_mode: session.follow_up_mode(),
179            session_file: session.session_file().map(|p| p.display().to_string()),
180            session_id: session.session_id(),
181            session_name: session.session_name(),
182            auto_compaction_enabled,
183            message_count: session.messages().len(),
184            pending_message_count: session.pending_message_count(),
185        }
186    }
187
188    async fn dispatch(
189        &self,
190        request_id: Option<String>,
191        command: RpcCommand,
192    ) -> Result<RpcResponseData, ClientError> {
193        if self.closed.load(Ordering::Acquire) {
194            return Err(ClientError::Closed);
195        }
196        match command {
197            RpcCommand::Agent(command) => self.agent(request_id, command).await,
198            RpcCommand::Oino(command) => self.oino(command).await,
199        }
200    }
201
202    #[allow(
203        clippy::too_many_lines,
204        reason = "one arm per agent command, mirroring handleCommand"
205    )]
206    async fn agent(
207        &self,
208        request_id: Option<String>,
209        command: AgentCommand,
210    ) -> Result<RpcResponseData, ClientError> {
211        let session = self.session();
212        match command {
213            AgentCommand::Prompt {
214                message,
215                images,
216                streaming_behavior,
217            } => {
218                let disposition = session
219                    .prompt(
220                        message,
221                        PromptOptions {
222                            images,
223                            streaming_behavior,
224                            source: InputSource::Rpc,
225                            ..PromptOptions::default()
226                        },
227                    )
228                    .await
229                    .map_err(failed)?;
230                data(json!({ "disposition": disposition }))
231            }
232            AgentCommand::Steer { message, images } => {
233                let disposition = session
234                    .steer(message, images, InputSource::Rpc)
235                    .await
236                    .map_err(failed)?;
237                data(json!({ "disposition": disposition }))
238            }
239            AgentCommand::FollowUp { message, images } => {
240                let disposition = session
241                    .follow_up(message, images, InputSource::Rpc)
242                    .await
243                    .map_err(failed)?;
244                data(json!({ "disposition": disposition }))
245            }
246            AgentCommand::Abort => {
247                session.abort().await;
248                Ok(None)
249            }
250            AgentCommand::ClearQueue => {
251                let (steering, follow_up) = session.clear_queue();
252                data(json!({ "steering": steering, "followUp": follow_up }))
253            }
254            AgentCommand::NewSession { parent_session } => {
255                let parent = parent_session.map(|p| self.path(&p));
256                let result = self.runtime.new_session(parent).await.map_err(failed)?;
257                self.rebind(ReplaceReason::New, result.cancelled);
258                data(json!({ "cancelled": result.cancelled }))
259            }
260            AgentCommand::GetState => data(self.state()),
261            AgentCommand::SetModel { provider, model_id } => {
262                let model = session
263                    .models()
264                    .available()
265                    .into_iter()
266                    .find(|m| m.provider == provider && m.id == model_id)
267                    .ok_or_else(|| {
268                        ClientError::Command(format!("Model not found: {provider}/{model_id}"))
269                    })?;
270                let model = session.set_model(model, true).await.map_err(failed)?;
271                data(model)
272            }
273            AgentCommand::CycleModel => {
274                let result = session
275                    .cycle_model(CycleDirection::Forward, true)
276                    .await
277                    .map_err(failed)?;
278                data(result)
279            }
280            AgentCommand::GetAvailableModels => {
281                data(json!({ "models": session.models().available() }))
282            }
283            AgentCommand::SetThinkingLevel { level } => {
284                session.set_thinking_level(level, true).await;
285                Ok(None)
286            }
287            AgentCommand::CycleThinkingLevel => {
288                let level = session.cycle_thinking_level(true).await;
289                Ok(Some(level.map_or(
290                    JsonValue::Null,
291                    |level| json!({ "level": level }),
292                )))
293            }
294            AgentCommand::GetAvailableThinkingLevels => {
295                data(json!({ "levels": session.available_thinking_levels() }))
296            }
297            AgentCommand::SetSteeringMode { mode } => {
298                session.set_steering_mode(mode);
299                Ok(None)
300            }
301            AgentCommand::SetFollowUpMode { mode } => {
302                session.set_follow_up_mode(mode);
303                Ok(None)
304            }
305            AgentCommand::Compact {
306                custom_instructions,
307            } => data(session.compact(custom_instructions).await.map_err(failed)?),
308            AgentCommand::SetAutoCompaction { enabled } => {
309                session.set_auto_compaction_enabled(enabled);
310                Ok(None)
311            }
312            AgentCommand::SetAutoRetry { enabled } => {
313                session.set_auto_retry_enabled(enabled);
314                Ok(None)
315            }
316            AgentCommand::AbortRetry => {
317                session.abort_retry();
318                Ok(None)
319            }
320            AgentCommand::Bash {
321                command,
322                exclude_from_context,
323            } => {
324                let result = session
325                    .execute_bash(command, exclude_from_context.unwrap_or(false), request_id)
326                    .await
327                    .map_err(failed)?;
328                data(result)
329            }
330            AgentCommand::AbortBash => {
331                session.abort_bash();
332                Ok(None)
333            }
334            AgentCommand::GetSessionStats => data(session.session_stats()),
335            AgentCommand::ExportHtml { output_path } => {
336                let path = session
337                    .export_html(output_path.map(|p| self.path(&p)))
338                    .await
339                    .map_err(failed)?;
340                data(json!({ "path": path.display().to_string() }))
341            }
342            AgentCommand::SwitchSession { session_path } => {
343                let result = self
344                    .runtime
345                    .switch_session(self.path(&session_path), None)
346                    .await
347                    .map_err(failed)?;
348                self.rebind(ReplaceReason::Resume, result.cancelled);
349                data(json!({ "cancelled": result.cancelled }))
350            }
351            AgentCommand::Fork { entry_id } => {
352                let result = self
353                    .runtime
354                    .fork(&EntryId::new(entry_id), ForkPosition::Before)
355                    .await
356                    .map_err(failed)?;
357                self.rebind(ReplaceReason::Fork, result.cancelled);
358                let mut value = json!({ "cancelled": result.cancelled });
359                if let Some(text) = result.selected_text {
360                    value = json!({ "text": text, "cancelled": result.cancelled });
361                }
362                Ok(Some(value))
363            }
364            AgentCommand::Clone => {
365                let leaf = session.with_manager(|m| m.leaf_id().cloned());
366                if leaf.is_none() {
367                    return Err(ClientError::Command(
368                        "Cannot clone session: no current entry selected".to_owned(),
369                    ));
370                }
371                let result = self.runtime.clone_session().await.map_err(failed)?;
372                self.rebind(ReplaceReason::Clone, result.cancelled);
373                data(json!({ "cancelled": result.cancelled }))
374            }
375            AgentCommand::GetForkMessages => {
376                data(json!({ "messages": session.user_messages_for_forking() }))
377            }
378            AgentCommand::GetEntries { since } => {
379                let (entries, leaf) =
380                    session.with_manager(|m| (m.entries().to_vec(), m.leaf_id().cloned()));
381                let entries = match since {
382                    None => entries,
383                    Some(since) => {
384                        let index = entries
385                            .iter()
386                            .position(|e| e.id.as_str() == since)
387                            .ok_or_else(|| {
388                                ClientError::Command(format!("Entry not found: {since}"))
389                            })?;
390                        entries[index + 1..].to_vec()
391                    }
392                };
393                data(json!({ "entries": entries, "leafId": leaf }))
394            }
395            AgentCommand::GetTree => {
396                let (tree, leaf) = session.with_manager(|m| (m.tree(), m.leaf_id().cloned()));
397                data(json!({ "tree": tree, "leafId": leaf }))
398            }
399            AgentCommand::GetLastAssistantText => {
400                data(json!({ "text": session.last_assistant_text() }))
401            }
402            AgentCommand::SetSessionName { name } => {
403                let name = name.trim();
404                if name.is_empty() {
405                    return Err(ClientError::Command(
406                        "Session name cannot be empty".to_owned(),
407                    ));
408                }
409                session
410                    .set_session_name(Some(name.to_owned()))
411                    .await
412                    .map_err(failed)?;
413                Ok(None)
414            }
415            AgentCommand::GetMessages => data(json!({ "messages": session.messages() })),
416            AgentCommand::GetCommands => data(json!({ "commands": session.commands() })),
417        }
418    }
419
420    async fn oino(&self, command: OinoCommand) -> Result<RpcResponseData, ClientError> {
421        let session = self.session();
422        match command {
423            OinoCommand::Hello { client, .. } => {
424                session.register_frontend(&client.name);
425                data(json!({
426                    "server": {"name": self.server.name, "version": self.server.version},
427                    "protocol": 1,
428                    "sessionId": session.session_id(),
429                    "safeMode": self
430                        .runtime
431                        .services()
432                        .safe_mode
433                        .as_ref()
434                        .is_some_and(|plan| plan.disable_extensions),
435                }))
436            }
437            OinoCommand::UiSnapshot => {
438                let snapshot = session.ui_snapshot();
439                let mut value = serde_json::to_value(snapshot).unwrap_or(JsonValue::Null);
440                if let Some(object) = value.as_object_mut() {
441                    // Transitional: v1 frontends still replay `requests` until the
442                    // TUI renderer (WP-U4) and `oino-view` (WP-U5) read the registry.
443                    object.insert(
444                        "requests".to_owned(),
445                        serde_json::to_value(session.ui().snapshot()).unwrap_or(JsonValue::Null),
446                    );
447                }
448                data(value)
449            }
450            OinoCommand::UiVisible {
451                entry_id,
452                visible,
453                frontend,
454            } => {
455                let frontend = frontend
456                    .as_deref()
457                    .filter(|name| !name.is_empty())
458                    .unwrap_or("rpc");
459                session.ui_visible(frontend, &entry_id, visible);
460                Ok(None)
461            }
462            OinoCommand::UiResync { .. } => data(session.ui_resync()),
463            OinoCommand::NavigateTree { target_id, options } => {
464                let result = session
465                    .navigate_tree(&EntryId::new(target_id), options)
466                    .await
467                    .map_err(failed)?;
468                data(result)
469            }
470            OinoCommand::SetLabel { entry_id, label } => {
471                session
472                    .set_label(&EntryId::new(entry_id), label)
473                    .await
474                    .map_err(failed)?;
475                Ok(None)
476            }
477            OinoCommand::ImportJsonl { path } => {
478                let result = self
479                    .runtime
480                    .import_jsonl(self.path(&path), None)
481                    .await
482                    .map_err(failed)?;
483                self.rebind(ReplaceReason::Import, result.cancelled);
484                data(json!({ "cancelled": result.cancelled }))
485            }
486            OinoCommand::ExportJsonl { output_path } => {
487                let path = session
488                    .export_jsonl(output_path.map(|p| self.path(&p)))
489                    .await
490                    .map_err(failed)?;
491                data(json!({ "path": path.display().to_string() }))
492            }
493            OinoCommand::GetContextUsage => data(session.context_usage()),
494            command @ (OinoCommand::Topics { .. }
495            | OinoCommand::BusGet { .. }
496            | OinoCommand::Agents { .. }
497            | OinoCommand::AgentMessages { .. }
498            | OinoCommand::AgentAbort { .. }
499            | OinoCommand::QueueEdit(_)
500            | OinoCommand::Checkpoints
501            | OinoCommand::Rewind { .. }
502            | OinoCommand::RunningBash
503            | OinoCommand::Extensions
504            | OinoCommand::SetExtensionEnabled { .. }) => self.gui(&session, command).await,
505            command => self.frontend(&session, command).await,
506        }
507    }
508
509    /// GUI feeds and controls (gui-audit area E): topics, agents, queue edits, checkpoints,
510    /// running bash calls, extensions.
511    async fn gui(
512        &self,
513        session: &AgentSession,
514        command: OinoCommand,
515    ) -> Result<RpcResponseData, ClientError> {
516        match command {
517            OinoCommand::Topics {
518                subscribe,
519                unsubscribe,
520            } => {
521                let topics = session.subscribe_topics(&subscribe, &unsubscribe).await;
522                data(json!({ "topics": topics, "patterns": session.topic_patterns() }))
523            }
524            OinoCommand::BusGet { topic } => Ok(Some(session.bus_get(&topic).map_or_else(
525                || json!({ "data": null }),
526                |v| json!({ "data": v.data, "publisher": v.publisher }),
527            ))),
528            OinoCommand::Agents { follow } => {
529                if let Some(follow) = follow {
530                    session.follow_agents(follow);
531                }
532                data(json!({ "agents": session.agent_tree(), "follow": session.follows_agents() }))
533            }
534            OinoCommand::AgentMessages { agent_id } => {
535                let agent = find_agent(session, &agent_id)?;
536                data(json!({ "messages": agent.messages() }))
537            }
538            OinoCommand::AgentAbort { agent_id } => {
539                find_agent(session, &agent_id)?.abort();
540                Ok(None)
541            }
542            OinoCommand::QueueEdit(edit) => {
543                session.edit_queue(edit).await.map_err(failed)?;
544                Ok(None)
545            }
546            OinoCommand::Checkpoints => data(json!({ "checkpoints": session.checkpoints() })),
547            OinoCommand::Rewind {
548                entry_id,
549                files,
550                conversation,
551            } => self.rewind(session, &entry_id, files, conversation).await,
552            OinoCommand::RunningBash => data(json!({ "calls": session.running_bash().await })),
553            OinoCommand::Extensions => data(json!({ "extensions": session.extension_infos() })),
554            OinoCommand::SetExtensionEnabled { id, enabled } => {
555                session
556                    .set_extension_enabled(&id, enabled)
557                    .map_err(failed)?;
558                Ok(None)
559            }
560            other => Err(ClientError::Protocol(format!(
561                "{} is not a GUI command",
562                other.kind()
563            ))),
564        }
565    }
566
567    /// `_oino/rewind`: files first (checkpoints at and after the entry), then the
568    /// conversation fork before the entry.
569    async fn rewind(
570        &self,
571        session: &AgentSession,
572        entry_id: &str,
573        files: bool,
574        conversation: bool,
575    ) -> Result<RpcResponseData, ClientError> {
576        if session.is_streaming() {
577            return Err(ClientError::Command(
578                "Cannot rewind while the agent is running".to_owned(),
579            ));
580        }
581        let mut result = if files && (session.has_checkpoint(entry_id) || !conversation) {
582            session.rewind_files(entry_id).map_err(failed)?
583        } else {
584            RewindResult::default()
585        };
586        if conversation {
587            let forked = self
588                .runtime
589                .fork(&EntryId::new(entry_id), ForkPosition::Before)
590                .await
591                .map_err(failed)?;
592            self.rebind(ReplaceReason::Fork, forked.cancelled);
593            result.cancelled = forked.cancelled;
594            result.text = forked.selected_text;
595        }
596        data(result)
597    }
598
599    /// `_oino/render` → core render request (`ui-protocol §6.4`).
600    fn render_request(
601        command: &OinoCommand,
602    ) -> Result<oino_core::session::RenderRequest, ClientError> {
603        let OinoCommand::Render {
604            target,
605            key,
606            entry_id,
607            turn_entry_id,
608            turn_index,
609            running,
610            density,
611            expanded,
612            width,
613            data: payload,
614        } = command
615        else {
616            return Err(ClientError::Protocol(format!(
617                "{} is not a render command",
618                command.kind()
619            )));
620        };
621        let target = oino_core::session::RenderTarget::parse(target)
622            .ok_or_else(|| ClientError::Protocol(format!("render target `{target}`")))?;
623        Ok(oino_core::session::RenderRequest {
624            target,
625            key: key.clone(),
626            entry_id: entry_id.clone(),
627            turn_entry_id: turn_entry_id.clone(),
628            turn_index: *turn_index,
629            running: *running,
630            density: density.as_deref().map_or_else(Default::default, |d| {
631                oino_core::session::RenderDensity::parse(d).unwrap_or_default()
632            }),
633            expanded: *expanded,
634            width: width.as_deref().map_or_else(Default::default, |w| {
635                oino_core::session::RenderWidth::parse(w).unwrap_or_default()
636            }),
637            data: payload.clone(),
638        })
639    }
640
641    /// The frontend command surface (`_oino/*` completions, actions, settings, auth).
642    #[allow(
643        clippy::too_many_lines,
644        reason = "one arm per frontend command, mirroring the RPC surface"
645    )]
646    async fn frontend(
647        &self,
648        session: &AgentSession,
649        command: OinoCommand,
650    ) -> Result<RpcResponseData, ClientError> {
651        match command {
652            OinoCommand::GetCompletions { text, cursor } => {
653                data(json!({ "groups": session.completions(&text, cursor).await }))
654            }
655            OinoCommand::UiAction {
656                panel_id,
657                action_id,
658                value,
659                values,
660                path,
661                source,
662            } => {
663                let action = oino_core::ui::UiAction {
664                    panel_id,
665                    action_id,
666                    value,
667                    values: values.and_then(|values| values.as_object().cloned()),
668                    path: path.and_then(|path| serde_json::from_value(path).ok()),
669                    source: source.as_deref().and_then(|name| match name {
670                        "tui" => Some(oino_core::ui::ActionSource::Tui),
671                        "app" => Some(oino_core::ui::ActionSource::App),
672                        "rpc" => Some(oino_core::ui::ActionSource::Rpc),
673                        _ => None,
674                    }),
675                };
676                session.ui_action_with(&action).map_err(failed)?;
677                Ok(None)
678            }
679            OinoCommand::EditorEvent(event) => {
680                let consumed = session.editor_event(event).await;
681                data(json!({ "consumed": consumed }))
682            }
683            OinoCommand::InvokeShortcut { shortcut_id } => {
684                session.invoke_shortcut(&shortcut_id).map_err(failed)?;
685                Ok(None)
686            }
687            OinoCommand::GetShortcuts => {
688                let shortcuts: Vec<JsonValue> = session
689                    .runner()
690                    .shortcuts()
691                    .into_iter()
692                    .map(|(extension, spec)| {
693                        json!({
694                            "id": spec.id,
695                            "defaultKeys": spec.default_keys,
696                            "description": spec.description,
697                            "extensionId": extension,
698                        })
699                    })
700                    .collect();
701                data(json!({ "shortcuts": shortcuts }))
702            }
703            command @ (OinoCommand::GetSettingsSchema
704            | OinoCommand::GetExtensionSettings { .. }
705            | OinoCommand::SetExtensionSetting { .. }
706            | OinoCommand::UnsetExtensionSetting { .. }
707            | OinoCommand::GetSettings { .. }
708            | OinoCommand::SetSetting { .. }) => self.settings_command(session, command),
709            OinoCommand::ServiceCall {
710                target,
711                name,
712                payload,
713            } => data(
714                session
715                    .service_call(target.as_deref(), &name, payload)
716                    .await
717                    .map_err(failed)?,
718            ),
719            OinoCommand::AuthProviders => data(json!({ "providers": session.auth_providers() })),
720            OinoCommand::Login { provider } => {
721                session.login(&provider).await.map_err(failed)?;
722                Ok(None)
723            }
724            OinoCommand::Logout { provider } => {
725                session.logout(&provider).map_err(failed)?;
726                Ok(None)
727            }
728            OinoCommand::SetApiKey { provider, key } => {
729                session.set_api_key(&provider, &key).await.map_err(failed)?;
730                Ok(None)
731            }
732            render @ OinoCommand::Render { .. } => {
733                let node = session
734                    .render_spec(&Self::render_request(&render)?)
735                    .map_err(failed)?;
736                data(json!({ "node": node }))
737            }
738            OinoCommand::Hello { .. }
739            | OinoCommand::Topics { .. }
740            | OinoCommand::BusGet { .. }
741            | OinoCommand::Agents { .. }
742            | OinoCommand::AgentMessages { .. }
743            | OinoCommand::AgentAbort { .. }
744            | OinoCommand::QueueEdit(_)
745            | OinoCommand::Checkpoints
746            | OinoCommand::Rewind { .. }
747            | OinoCommand::RunningBash
748            | OinoCommand::Extensions
749            | OinoCommand::SetExtensionEnabled { .. }
750            | OinoCommand::UiSnapshot
751            | OinoCommand::UiVisible { .. }
752            | OinoCommand::UiResync { .. }
753            | OinoCommand::NavigateTree { .. }
754            | OinoCommand::SetLabel { .. }
755            | OinoCommand::ImportJsonl { .. }
756            | OinoCommand::ExportJsonl { .. }
757            | OinoCommand::GetContextUsage => Err(ClientError::Protocol(format!(
758                "{} is not a frontend command",
759                command.kind()
760            ))),
761        }
762    }
763
764    /// Extension settings registry and core settings commands.
765    fn settings_command(
766        &self,
767        session: &AgentSession,
768        command: OinoCommand,
769    ) -> Result<RpcResponseData, ClientError> {
770        let registry = self.runtime.extension_settings();
771        match command {
772            OinoCommand::GetSettingsSchema => data(json!({ "namespaces": registry.schema() })),
773            OinoCommand::GetExtensionSettings { namespace, scope } => {
774                if registry.namespace(&namespace).is_none() {
775                    return Err(ClientError::Command(format!(
776                        "Unknown settings namespace: {namespace}"
777                    )));
778                }
779                // `diagnostics` lists stored values `resolve` rejected (ui-protocol §7.1).
780                let resolved = registry.resolved(&namespace, scope);
781                data(json!({
782                    "values": resolved.values,
783                    "diagnostics": resolved.diagnostics,
784                }))
785            }
786            OinoCommand::SetExtensionSetting {
787                namespace,
788                key,
789                value,
790                scope,
791            } => {
792                let value = value.filter(|v| !v.is_null());
793                registry
794                    .set(&namespace, &key, value, scope)
795                    .map_err(failed)?;
796                Ok(None)
797            }
798            OinoCommand::UnsetExtensionSetting {
799                namespace,
800                key,
801                scope,
802            } => {
803                registry
804                    .set(&namespace, &key, None, scope)
805                    .map_err(failed)?;
806                Ok(None)
807            }
808            OinoCommand::GetSettings { key } => {
809                let merged = registry.merged_settings();
810                match key {
811                    None => data(json!({ "settings": merged })),
812                    Some(key) => {
813                        let path: Vec<&str> = key.split('.').collect();
814                        let value = oino_settings::json::get_path(&merged, &path).cloned();
815                        data(json!({ "value": value }))
816                    }
817                }
818            }
819            OinoCommand::SetSetting { key, value, scope } => {
820                let path: Vec<&str> = key.split('.').filter(|p| !p.is_empty()).collect();
821                if path.is_empty() {
822                    return Err(ClientError::Command("Setting key cannot be empty".into()));
823                }
824                let value = value.filter(|v| !v.is_null());
825                session
826                    .with_settings(|settings| settings.set(scope, &path, value))
827                    .map_err(failed)?;
828                Ok(None)
829            }
830            other => Err(ClientError::Protocol(format!(
831                "{} is not a settings command",
832                other.kind()
833            ))),
834        }
835    }
836}
837
838impl SessionBackend for InProcessBackend {
839    fn send(&self, command: RpcCommand) -> BoxFuture<'_, Result<RpcResponseData, ClientError>> {
840        self.send_with_id(None, command)
841    }
842
843    fn send_with_id(
844        &self,
845        request_id: Option<String>,
846        command: RpcCommand,
847    ) -> BoxFuture<'_, Result<RpcResponseData, ClientError>> {
848        let shared = Arc::clone(&self.shared);
849        self.on_runtime(
850            async move { shared.dispatch(request_id, command).await },
851            Err(ClientError::Closed),
852        )
853    }
854
855    /// Events of the current session; streams follow session replacements made through
856    /// this backend and end after [`SessionBackend::shutdown`].
857    fn events(&self) -> BoxStream<'static, ClientEvent> {
858        self.shared
859            .followers
860            .stream(self.shared.session().subscribe())
861            .boxed()
862    }
863
864    fn ui_respond(&self, response: UiResponse) {
865        self.shared.session().ui().respond(response);
866    }
867
868    fn session_id(&self) -> String {
869        self.shared.session().session_id()
870    }
871
872    fn shutdown(&self) -> BoxFuture<'_, ()> {
873        let shared = Arc::clone(&self.shared);
874        self.on_runtime(
875            async move {
876                if !shared.closed.swap(true, Ordering::AcqRel) {
877                    shared.runtime.dispose().await;
878                    shared.followers.close();
879                }
880            },
881            (),
882        )
883    }
884
885    /// Resolves once an extension called `ctx.shutdown()` and the session is idle.
886    fn shutdown_requested(&self) -> BoxFuture<'static, ()> {
887        let runtime = Arc::clone(&self.shared.runtime);
888        self.on_runtime(
889            async move {
890                let mut requested = runtime.shutdown_requested();
891                if requested.wait_for(|on| *on).await.is_err() {
892                    return std::future::pending().await;
893                }
894                runtime.session().wait_for_idle().await;
895            },
896            (),
897        )
898    }
899}