1use 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct RunningBash {
29 pub tool_call_id: String,
31 pub command: String,
33 pub started_at: i64,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub pid: Option<u32>,
38}
39
40pub struct DetachedBash {
43 pub command: String,
45 pub pid: Option<u32>,
47 pub output_so_far: String,
49 pub full_output_path: Option<String>,
51 pub output: BoxStream<'static, Vec<u8>>,
53 pub exit: BoxFuture<'static, Option<i32>>,
55 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct ExtensionInfo {
72 pub id: String,
74 pub kind: crate::extensions::api::ExtensionKind,
76 pub enabled: bool,
78 pub loaded: bool,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub path: Option<String>,
83}
84
85pub(crate) struct FrontendState {
87 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
118const CHECKPOINTED_TOOLS: [&str; 2] = ["write", "edit"];
120const SHELL_TOOLS: [&str; 2] = ["bash", "powershell"];
122
123impl AgentSession {
124 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 #[must_use]
139 pub fn runtime_handle(&self) -> Option<tokio::runtime::Handle> {
140 self.inner.frontend.runtime.clone()
141 }
142
143 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 #[must_use]
175 pub fn topic_patterns(&self) -> Vec<String> {
176 lock(&self.inner.frontend.topics).clone()
177 }
178
179 #[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 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 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 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 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 #[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 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 #[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 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 pub fn bind_bash_detach(&self, registry: BashDetachRegistry) {
315 let _ = self.inner.frontend.bash_detach.set(registry);
316 }
317
318 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 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 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 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 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 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 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 #[must_use]
466 pub fn checkpoints(&self) -> Vec<Checkpoint> {
467 self.checkpoints_store().list()
468 }
469
470 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 #[must_use]
489 pub fn has_checkpoint(&self, entry_id: &str) -> bool {
490 self.checkpoints_store().contains(entry_id)
491 }
492
493 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}