Skip to main content

oino_core/session/
feeds.rs

1//! Frontend-facing session state the GUI needs beyond the core events (gui-audit area E): the runtime
2//! handle background work is spawned on, the `_oino/topics` subscription, attached frontends,
3//! running `bash` calls with their detach registry, and file checkpoints.
4
5use std::future::Future;
6use std::path::PathBuf;
7use std::sync::{Mutex, OnceLock};
8
9use futures::StreamExt;
10use futures::future::BoxFuture;
11use futures::stream::BoxStream;
12use indexmap::IndexMap;
13use oino_ai::{JsonObject, JsonValue};
14use oino_tools::BashDetachRegistry;
15use serde::{Deserialize, Serialize};
16use tokio_util::sync::CancellationToken;
17
18use super::checkpoints::{Checkpoint, CheckpointStore, RewindResult};
19use super::{AgentSession, SessionOpError, lock};
20use crate::events::OinoEvent;
21use crate::extensions::api::ExtensionMode;
22use crate::extensions::host::HostError;
23use crate::topics::{self, TopicValue};
24
25/// A running foreground `bash` call (`control.running-bash`).
26#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct RunningBash {
29    /// Tool-call id.
30    pub tool_call_id: String,
31    /// Command line.
32    pub command: String,
33    /// Start (epoch ms).
34    pub started_at: i64,
35    /// Shell process id, when known.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub pid: Option<u32>,
38}
39
40/// A process taken over from a detached `bash` call (core side of
41/// `oino_extension_api::DetachedBash`).
42pub struct DetachedBash {
43    /// The command line.
44    pub command: String,
45    /// Process id, when known.
46    pub pid: Option<u32>,
47    /// Output the tool call returned before the detach marker.
48    pub output_so_far: String,
49    /// Temp file with the complete output so far.
50    pub full_output_path: Option<String>,
51    /// Further output chunks.
52    pub output: BoxStream<'static, Vec<u8>>,
53    /// Exit code once the process ended (`None` for a signal).
54    pub exit: BoxFuture<'static, Option<i32>>,
55    /// Cancel to kill the process tree.
56    pub kill: CancellationToken,
57}
58
59impl std::fmt::Debug for DetachedBash {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.debug_struct("DetachedBash")
62            .field("command", &self.command)
63            .field("pid", &self.pid)
64            .finish_non_exhaustive()
65    }
66}
67
68/// One extension as frontends see it (`_oino/extensions`).
69#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct ExtensionInfo {
72    /// Manifest id.
73    pub id: String,
74    /// Provider component or extension.
75    pub kind: crate::extensions::api::ExtensionKind,
76    /// Not listed in `disabledExtensions`.
77    pub enabled: bool,
78    /// Loaded in this session (disabled ones are not).
79    pub loaded: bool,
80    /// Package path (loaded extensions).
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub path: Option<String>,
83}
84
85/// Frontend-facing state of one session.
86pub(crate) struct FrontendState {
87    /// Runtime the session was created on; background work from calls made off the runtime
88    /// (GPUI executors) is spawned here (GA-40).
89    runtime: Option<tokio::runtime::Handle>,
90    topics: Mutex<Vec<String>>,
91    frontends: Mutex<Vec<String>>,
92    running_bash: Mutex<IndexMap<String, RunningBash>>,
93    bash_detach: OnceLock<BashDetachRegistry>,
94    pub(crate) checkpoints: Mutex<CheckpointStore>,
95}
96
97impl FrontendState {
98    pub(crate) fn new() -> Self {
99        Self {
100            runtime: tokio::runtime::Handle::try_current().ok(),
101            topics: Mutex::new(Vec::new()),
102            frontends: Mutex::new(Vec::new()),
103            running_bash: Mutex::new(IndexMap::new()),
104            bash_detach: OnceLock::new(),
105            checkpoints: Mutex::new(CheckpointStore::default()),
106        }
107    }
108}
109
110fn now_ms() -> i64 {
111    std::time::SystemTime::now()
112        .duration_since(std::time::UNIX_EPOCH)
113        .ok()
114        .and_then(|d| i64::try_from(d.as_millis()).ok())
115        .unwrap_or_default()
116}
117
118/// Tools whose file changes are checkpointed.
119const CHECKPOINTED_TOOLS: [&str; 2] = ["write", "edit"];
120/// Shell tools listed by `running-bash`.
121const SHELL_TOOLS: [&str; 2] = ["bash", "powershell"];
122
123impl AgentSession {
124    /// Spawns `task` on the current tokio runtime, or on the runtime the session was created
125    /// on when called from another executor. Dropped (with a warning) when neither exists.
126    pub(crate) fn spawn_task(&self, task: impl Future<Output = ()> + Send + 'static) {
127        let handle = tokio::runtime::Handle::try_current()
128            .ok()
129            .or_else(|| self.inner.frontend.runtime.clone());
130        if let Some(handle) = handle {
131            handle.spawn(task);
132        } else {
133            tracing::warn!("no tokio runtime for a background session task");
134        }
135    }
136
137    /// The runtime the session was created on.
138    #[must_use]
139    pub fn runtime_handle(&self) -> Option<tokio::runtime::Handle> {
140        self.inner.frontend.runtime.clone()
141    }
142
143    // ── topics ──
144
145    /// Adds and removes `_oino/topics` patterns (exact, `prefix*`, `*`) and returns the
146    /// current sticky values matching the resulting subscription. Subscribing to
147    /// [`topics::GIT_TOPIC`] refreshes it first.
148    pub async fn subscribe_topics(
149        &self,
150        subscribe: &[String],
151        unsubscribe: &[String],
152    ) -> Vec<TopicValue> {
153        let patterns = {
154            let mut patterns = lock(&self.inner.frontend.topics);
155            patterns.retain(|p| !unsubscribe.contains(p));
156            for pattern in subscribe {
157                if !patterns.contains(pattern) {
158                    patterns.push(pattern.clone());
159                }
160            }
161            patterns.clone()
162        };
163        if topics::any_matches(&patterns, topics::GIT_TOPIC) {
164            self.refresh_git_topic().await;
165        }
166        self.inner
167            .agents
168            .router()
169            .map(|router| router.topics().snapshot(&patterns))
170            .unwrap_or_default()
171    }
172
173    /// The current `_oino/topics` patterns.
174    #[must_use]
175    pub fn topic_patterns(&self) -> Vec<String> {
176        lock(&self.inner.frontend.topics).clone()
177    }
178
179    /// Last sticky value of `topic` (`_oino/bus_get`).
180    #[must_use]
181    pub fn bus_get(&self, topic: &str) -> Option<TopicValue> {
182        self.inner
183            .agents
184            .router()
185            .and_then(|router| router.topics().get(topic))
186    }
187
188    /// Publishes `value` to this session's frontends when it matches the subscription.
189    pub(crate) fn offer_topic(&self, value: &TopicValue) {
190        if topics::any_matches(&lock(&self.inner.frontend.topics), &value.topic) {
191            self.inner.hub.publish(OinoEvent::from(value.clone()));
192        }
193    }
194
195    /// Recomputes [`topics::GIT_TOPIC`] for the session cwd.
196    pub async fn refresh_git_topic(&self) {
197        let Some(router) = self.inner.agents.router() else {
198            return;
199        };
200        let cwd = self.inner.cwd.clone();
201        let info = tokio::task::spawn_blocking(move || topics::git_info(&cwd))
202            .await
203            .unwrap_or(JsonValue::Null);
204        router.publish_topic(topics::CORE_PUBLISHER, topics::GIT_TOPIC, info, true);
205    }
206
207    /// Refreshes the git topic in the background when a frontend follows it (after runs).
208    pub(crate) fn refresh_git_topic_if_followed(&self) {
209        if self.inner.agents.link.is_none()
210            && topics::any_matches(&lock(&self.inner.frontend.topics), topics::GIT_TOPIC)
211        {
212            let session = self.clone();
213            self.spawn_task(async move { session.refresh_git_topic().await });
214        }
215    }
216
217    // ── frontends ──
218
219    /// Records a frontend attached through `_oino/hello` (`client.name`).
220    pub fn register_frontend(&self, name: &str) {
221        let name = name.trim();
222        if name.is_empty() {
223            return;
224        }
225        let mut frontends = lock(&self.inner.frontend.frontends);
226        if !frontends.iter().any(|f| f == name) {
227            frontends.push(name.to_owned());
228        }
229    }
230
231    /// Attached frontends for `host-info` (`tui`, `desktop`, `mobile`, `rpc`, `acp`, plus
232    /// `_oino/hello` client names).
233    #[must_use]
234    pub fn frontends(&self) -> Vec<String> {
235        let base = match self.inner.mode {
236            ExtensionMode::Tui => Some("tui"),
237            ExtensionMode::Desktop => Some("desktop"),
238            ExtensionMode::Mobile => Some("mobile"),
239            ExtensionMode::Rpc => Some("rpc"),
240            ExtensionMode::Acp => Some("acp"),
241            ExtensionMode::Print | ExtensionMode::Json => None,
242        };
243        let mut out: Vec<String> = base.into_iter().map(str::to_owned).collect();
244        for name in lock(&self.inner.frontend.frontends).iter() {
245            if !out.contains(name) {
246                out.push(name.clone());
247            }
248        }
249        out
250    }
251
252    /// Carries the frontend subscriptions over to the session that replaced `old`.
253    pub(crate) fn inherit_frontend_state(&self, old: &Self) {
254        lock(&self.inner.frontend.topics).clone_from(&lock(&old.inner.frontend.topics));
255        lock(&self.inner.frontend.frontends).clone_from(&lock(&old.inner.frontend.frontends));
256        self.follow_agents(old.follows_agents());
257    }
258
259    // ── extensions ──
260
261    /// Loaded extensions plus the ids listed in `disabledExtensions` (`_oino/extensions`).
262    #[must_use]
263    pub fn extension_infos(&self) -> Vec<ExtensionInfo> {
264        let disabled = self.with_settings(|s| s.settings().disabled_extensions());
265        let mut out: Vec<ExtensionInfo> = self
266            .inner
267            .agents
268            .all_extensions
269            .extensions()
270            .iter()
271            .map(|extension| ExtensionInfo {
272                id: extension.instance.id().to_owned(),
273                kind: extension.instance.kind(),
274                enabled: !disabled.iter().any(|d| d == extension.instance.id()),
275                loaded: true,
276                path: Some(extension.instance.path()),
277            })
278            .collect();
279        for id in disabled {
280            if !out.iter().any(|e| e.id == id) {
281                out.push(ExtensionInfo {
282                    id,
283                    kind: crate::extensions::api::ExtensionKind::Extension,
284                    enabled: false,
285                    loaded: false,
286                    path: None,
287                });
288            }
289        }
290        out
291    }
292
293    /// Adds `id` to or removes it from `disabledExtensions` (global settings). Takes effect
294    /// for sessions created afterwards (`_oino/set_extension_enabled`).
295    ///
296    /// # Errors
297    ///
298    /// The settings file cannot be written.
299    pub fn set_extension_enabled(&self, id: &str, enabled: bool) -> Result<(), SessionOpError> {
300        self.with_settings(|settings| {
301            let mut disabled = settings.settings().disabled_extensions();
302            disabled.retain(|d| d != id);
303            if !enabled {
304                disabled.push(id.to_owned());
305            }
306            settings.set_disabled_extensions(&disabled)
307        })
308        .map_err(|e| SessionOpError::Failed(e.to_string()))
309    }
310
311    // ── bash ──
312
313    /// Binds the detach registry of the session's built-in shell tools (once).
314    pub fn bind_bash_detach(&self, registry: BashDetachRegistry) {
315        let _ = self.inner.frontend.bash_detach.set(registry);
316    }
317
318    /// Tracks tool executions for `running-bash` and snapshots files for checkpoints.
319    pub(crate) fn note_tool_start(&self, tool_call_id: &str, tool_name: &str, args: &JsonObject) {
320        if SHELL_TOOLS.contains(&tool_name) {
321            let command = args
322                .get("command")
323                .and_then(JsonValue::as_str)
324                .unwrap_or_default()
325                .to_owned();
326            lock(&self.inner.frontend.running_bash).insert(
327                tool_call_id.to_owned(),
328                RunningBash {
329                    tool_call_id: tool_call_id.to_owned(),
330                    command,
331                    started_at: now_ms(),
332                    pid: None,
333                },
334            );
335        }
336    }
337
338    /// Forgets a finished tool execution.
339    pub(crate) fn note_tool_end(&self, tool_call_id: &str) {
340        lock(&self.inner.frontend.running_bash).shift_remove(tool_call_id);
341    }
342
343    /// Foreground `bash` calls still running (`control.running-bash`), oldest first.
344    pub async fn running_bash(&self) -> Vec<RunningBash> {
345        let registry = self.inner.frontend.bash_detach.get().cloned();
346        let mut list: Vec<RunningBash> = lock(&self.inner.frontend.running_bash)
347            .values()
348            .cloned()
349            .collect();
350        if let Some(registry) = registry {
351            list.retain(|call| registry.is_running(&call.tool_call_id));
352            for call in &mut list {
353                call.pid = registry.pid(&call.tool_call_id).await;
354            }
355        }
356        list
357    }
358
359    /// Detaches the running `bash` call `tool_call_id` into background task `task_id`: the
360    /// call returns at once with the output so far plus a detach marker, the process keeps
361    /// running and is handed over.
362    ///
363    /// # Errors
364    ///
365    /// [`HostError::Failed`] when no such call runs (or it finished meanwhile).
366    pub async fn detach_bash(
367        &self,
368        tool_call_id: &str,
369        task_id: &str,
370        reason: &str,
371    ) -> Result<DetachedBash, HostError> {
372        let not_running = || HostError::Failed(format!("No running bash call {tool_call_id}"));
373        let registry = self
374            .inner
375            .frontend
376            .bash_detach
377            .get()
378            .cloned()
379            .ok_or_else(not_running)?;
380        let command = lock(&self.inner.frontend.running_bash)
381            .get(tool_call_id)
382            .map(|call| call.command.clone())
383            .unwrap_or_default();
384        let detached = registry
385            .detach(tool_call_id, task_id, reason)
386            .await
387            .ok_or_else(not_running)?;
388        let mut process = detached.process;
389        let pid = process.control.pid();
390        let kill = CancellationToken::new();
391        let killed = kill.clone();
392        let mut control = process.control;
393        let exit = Box::pin(async move {
394            let status = tokio::select! {
395                status = control.wait() => status,
396                () = killed.cancelled() => {
397                    control.kill_tree();
398                    control.wait().await
399                }
400            };
401            status.ok().and_then(|s| s.code)
402        });
403        let output = futures::stream::poll_fn(move |cx| process.output.poll_recv(cx)).boxed();
404        Ok(DetachedBash {
405            command,
406            pid,
407            output_so_far: detached.output_so_far,
408            full_output_path: detached.full_output_path,
409            output,
410            exit,
411            kill,
412        })
413    }
414
415    // ── checkpoints ──
416
417    fn checkpoint_dir(&self) -> Option<PathBuf> {
418        let persisted = self.with_manager(|m| m.is_persisted());
419        persisted.then(|| {
420            self.inner
421                .agent_dir
422                .join("checkpoints")
423                .join(self.session_id())
424        })
425    }
426
427    fn checkpoints_store(&self) -> std::sync::MutexGuard<'_, CheckpointStore> {
428        let dir = self.checkpoint_dir();
429        let mut store = lock(&self.inner.frontend.checkpoints);
430        store.bind(dir);
431        store
432    }
433
434    /// Entry id of the latest user message on the current branch.
435    fn current_user_entry(&self) -> Option<String> {
436        self.with_manager(|m| {
437            m.branch(None)
438                .iter()
439                .rev()
440                .find(|entry| matches!(entry.message(), Some(oino_ai::AgentMessage::User(_))))
441                .map(|entry| entry.id.as_str().to_owned())
442        })
443    }
444
445    /// Snapshots the file a built-in `write` / `edit` call is about to change.
446    pub(crate) fn checkpoint_tool_call(&self, tool_name: &str, args: &JsonObject) {
447        if !CHECKPOINTED_TOOLS.contains(&tool_name) {
448            return;
449        }
450        let Some(path) = args.get("path").and_then(JsonValue::as_str) else {
451            return;
452        };
453        let Some(entry) = self.current_user_entry() else {
454            return;
455        };
456        let cwd = self.inner.cwd.display().to_string();
457        let Ok(resolved) = oino_tools::path_utils::resolve_to_cwd(path, &cwd) else {
458            return;
459        };
460        self.checkpoints_store()
461            .record(&entry, std::path::Path::new(&resolved));
462    }
463
464    /// File checkpoints of this session, oldest first (`_oino/checkpoints`).
465    #[must_use]
466    pub fn checkpoints(&self) -> Vec<Checkpoint> {
467        self.checkpoints_store().list()
468    }
469
470    /// Restores the files changed at or after the user message `entry_id` (file part of
471    /// `_oino/rewind`) and drops those checkpoints.
472    ///
473    /// # Errors
474    ///
475    /// [`SessionOpError::Failed`] while streaming or without a checkpoint for the entry.
476    pub fn rewind_files(&self, entry_id: &str) -> Result<RewindResult, SessionOpError> {
477        if self.is_streaming() {
478            return Err(SessionOpError::Failed(
479                "Cannot rewind files while the agent is running".to_owned(),
480            ));
481        }
482        self.checkpoints_store().rewind(entry_id).ok_or_else(|| {
483            SessionOpError::Failed(format!("No file checkpoint for entry {entry_id}"))
484        })
485    }
486
487    /// Whether a checkpoint exists for the user message `entry_id`.
488    #[must_use]
489    pub fn has_checkpoint(&self, entry_id: &str) -> bool {
490        self.checkpoints_store().contains(entry_id)
491    }
492
493    /// Copies the checkpoints of `source` whose user message is on this session's branch
494    /// (a fork keeps the file history of the conversation it kept).
495    pub(crate) fn inherit_checkpoints(&self, source: &Self) {
496        let kept: std::collections::HashSet<String> = self.with_manager(|m| {
497            m.branch(None)
498                .iter()
499                .map(|entry| entry.id.as_str().to_owned())
500                .collect()
501        });
502        let source_store = source.checkpoints_store();
503        let mut target = self.checkpoints_store();
504        source_store.copy_matching(|id| kept.contains(id), &mut target);
505    }
506}