1use std::collections::{HashMap, HashSet};
7use std::fs::{self, File};
8use std::io::{BufRead, BufReader, Read};
9use std::path::{Component, Path, PathBuf};
10use std::time::UNIX_EPOCH;
11
12use rusqlite::Connection;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16use crate::native_store::load_native_store_family;
17use crate::session::percent_decode_path;
18use crate::{Error, Fidelity, Result, Session, SessionFollower};
19
20#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
22#[serde(transparent)]
23pub struct HarnessId(pub String);
24
25impl HarnessId {
26 pub const CLAUDE_CODE: &'static str = "claude-code";
28 pub const CODEX: &'static str = "codex";
30 pub const PI: &'static str = "pi";
32 pub const OPENCODE: &'static str = "opencode";
34 pub const GROK: &'static str = "grok";
36 pub const GEMINI: &'static str = "gemini";
38 pub const GOOSE: &'static str = "goose";
40 pub const SUPERCODE: &'static str = "supercode";
42
43 pub fn new(value: impl Into<String>) -> Self {
45 Self(value.into())
46 }
47
48 pub fn as_str(&self) -> &str {
50 &self.0
51 }
52}
53
54impl From<&str> for HarnessId {
55 fn from(value: &str) -> Self {
56 Self::new(value)
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
62#[serde(tag = "kind", rename_all = "snake_case")]
63pub enum StorageLocator {
64 File {
66 path: PathBuf,
68 },
69 Sqlite {
71 path: PathBuf,
73 selector: String,
75 },
76}
77
78impl StorageLocator {
79 pub fn path(&self) -> &Path {
81 match self {
82 Self::File { path } | Self::Sqlite { path, .. } => path,
83 }
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
89pub struct SessionLocator {
90 pub harness: HarnessId,
92 pub session_id: String,
94 pub storage: StorageLocator,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100pub struct SessionDescriptor {
101 pub locator: SessionLocator,
104 pub cwd: Option<PathBuf>,
106 pub title: Option<String>,
108 pub updated_at_ms: Option<u64>,
110 pub message_count: Option<usize>,
112 pub model: Option<String>,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct DiscoveryPage {
119 pub sessions: Vec<SessionDescriptor>,
121 pub next_cursor: Option<String>,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(default)]
128pub struct HarnessHomes {
129 pub claude_code: PathBuf,
131 pub codex: PathBuf,
133 pub pi: PathBuf,
135 pub opencode: PathBuf,
137 pub grok: PathBuf,
139 pub gemini: PathBuf,
141 pub goose: PathBuf,
143 pub supercode: PathBuf,
145}
146
147impl Default for HarnessHomes {
148 fn default() -> Self {
149 let home = std::env::var_os("HOME")
150 .map(PathBuf::from)
151 .unwrap_or_else(|| PathBuf::from("."));
152 let claude_root = std::env::var_os("CLAUDE_CONFIG_DIR")
153 .map(PathBuf::from)
154 .unwrap_or_else(|| home.join(".claude"));
155 let codex_root = std::env::var_os("CODEX_HOME")
156 .map(PathBuf::from)
157 .unwrap_or_else(|| home.join(".codex"));
158 let pi = std::env::var_os("PI_CODING_AGENT_SESSION_DIR")
159 .map(PathBuf::from)
160 .unwrap_or_else(|| {
161 std::env::var_os("PI_CODING_AGENT_DIR")
162 .map(PathBuf::from)
163 .unwrap_or_else(|| home.join(".pi/agent"))
164 .join("sessions")
165 });
166 let opencode = std::env::var_os("OPENCODE_DB")
167 .map(PathBuf::from)
168 .unwrap_or_else(|| {
169 std::env::var_os("XDG_DATA_HOME")
170 .map(PathBuf::from)
171 .unwrap_or_else(|| home.join(".local/share"))
172 .join("opencode")
173 });
174 let grok = std::env::var_os("GROK_HOME")
175 .map(PathBuf::from)
176 .unwrap_or_else(|| home.join(".grok"))
177 .join("sessions");
178 let gemini = std::env::var_os("GEMINI_CLI_HOME")
179 .map(PathBuf::from)
180 .unwrap_or_else(|| home.join(".gemini"));
181 let goose = std::env::var_os("GOOSE_PATH_ROOT")
182 .map(PathBuf::from)
183 .map(|root| root.join("data/sessions/sessions.db"))
184 .unwrap_or_else(|| {
185 #[cfg(target_os = "macos")]
186 {
187 home.join("Library/Application Support/Block/goose/sessions/sessions.db")
188 }
189 #[cfg(target_os = "windows")]
190 {
191 std::env::var_os("APPDATA")
192 .map(PathBuf::from)
193 .unwrap_or_else(|| home.join("AppData/Roaming"))
194 .join("Block/goose/sessions/sessions.db")
195 }
196 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
197 {
198 std::env::var_os("XDG_DATA_HOME")
199 .map(PathBuf::from)
200 .unwrap_or_else(|| home.join(".local/share"))
201 .join("goose/sessions/sessions.db")
202 }
203 });
204 let supercode = std::env::var_os("SUPERCODE_HOME")
205 .map(PathBuf::from)
206 .unwrap_or_else(|| {
207 std::env::var_os("XDG_CONFIG_HOME")
208 .map(PathBuf::from)
209 .unwrap_or_else(|| home.join(".config"))
210 .join("supercode")
211 })
212 .join("sessions");
213 Self {
214 claude_code: claude_root.join("projects"),
215 codex: codex_root.join("sessions"),
216 gemini,
217 goose,
218 supercode,
219 pi,
220 opencode,
221 grok,
222 }
223 }
224}
225
226#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(default)]
229pub struct DiscoveryQuery {
230 pub workspace: Option<PathBuf>,
232 pub harnesses: Vec<HarnessId>,
234 pub homes: HarnessHomes,
236 pub query: Option<String>,
238 pub cursor: Option<String>,
240 pub limit: Option<usize>,
242}
243
244#[derive(Debug, Default, Clone, Copy)]
247pub struct HarnessCatalog;
248
249impl HarnessCatalog {
250 pub fn new() -> Self {
252 Self
253 }
254
255 pub fn discover(&self, query: &DiscoveryQuery) -> Result<Vec<SessionDescriptor>> {
259 Ok(self.discover_page(query)?.sessions)
260 }
261
262 pub fn discover_page(&self, query: &DiscoveryQuery) -> Result<DiscoveryPage> {
264 let selected: HashSet<&str> = if query.harnesses.is_empty() {
265 [
266 HarnessId::CLAUDE_CODE,
267 HarnessId::CODEX,
268 HarnessId::PI,
269 HarnessId::OPENCODE,
270 HarnessId::GROK,
271 HarnessId::GEMINI,
272 HarnessId::GOOSE,
273 HarnessId::SUPERCODE,
274 ]
275 .into_iter()
276 .collect()
277 } else {
278 query.harnesses.iter().map(HarnessId::as_str).collect()
279 };
280 let mut found = Vec::new();
281 if selected.contains(HarnessId::CLAUDE_CODE) {
282 discover_jsonl(
283 &query.homes.claude_code,
284 HarnessId::CLAUDE_CODE,
285 query.workspace.as_deref(),
286 &mut found,
287 );
288 }
289 if selected.contains(HarnessId::CODEX) {
290 discover_jsonl(
291 &query.homes.codex,
292 HarnessId::CODEX,
293 query.workspace.as_deref(),
294 &mut found,
295 );
296 }
297 if selected.contains(HarnessId::PI) {
298 discover_jsonl(
299 &query.homes.pi,
300 HarnessId::PI,
301 query.workspace.as_deref(),
302 &mut found,
303 );
304 }
305 if selected.contains(HarnessId::OPENCODE) {
306 discover_opencode(
307 &query.homes.opencode,
308 query.workspace.as_deref(),
309 &mut found,
310 );
311 }
312 if selected.contains(HarnessId::GROK) {
313 discover_grok(&query.homes.grok, query.workspace.as_deref(), &mut found);
314 }
315 if selected.contains(HarnessId::GEMINI) {
316 discover_gemini(&query.homes.gemini, query.workspace.as_deref(), &mut found);
317 }
318 if selected.contains(HarnessId::GOOSE) {
319 discover_goose(&query.homes.goose, query.workspace.as_deref(), &mut found);
320 }
321 if selected.contains(HarnessId::SUPERCODE) {
322 discover_supercode(
323 &query.homes.supercode,
324 query.workspace.as_deref(),
325 &mut found,
326 );
327 }
328 found.sort_by(|a, b| {
329 b.updated_at_ms
330 .cmp(&a.updated_at_ms)
331 .then_with(|| a.locator.harness.cmp(&b.locator.harness))
332 .then_with(|| a.locator.session_id.cmp(&b.locator.session_id))
333 });
334 if let Some(search) = query
335 .query
336 .as_deref()
337 .map(str::trim)
338 .filter(|q| !q.is_empty())
339 {
340 let search = search.to_lowercase();
341 found.retain(|descriptor| descriptor_matches(descriptor, &search));
342 }
343 let start = match query.cursor.as_deref() {
344 Some(cursor) => {
345 let key = decode_cursor(cursor)?;
346 found
347 .iter()
348 .position(|descriptor| descriptor_cursor_key(descriptor) == key)
349 .map(|index| index + 1)
350 .ok_or_else(|| Error::Other("discovery cursor is stale or invalid".into()))?
351 }
352 None => 0,
353 };
354 let end = query
355 .limit
356 .map(|limit| start.saturating_add(limit).min(found.len()))
357 .unwrap_or(found.len());
358 let sessions = found[start.min(found.len())..end].to_vec();
359 let next_cursor = (end < found.len())
360 .then(|| sessions.last().map(encode_cursor))
361 .flatten();
362 Ok(DiscoveryPage {
363 sessions,
364 next_cursor,
365 })
366 }
367
368 pub fn load(&self, locator: &SessionLocator) -> Result<Session> {
370 self.load_with_fidelity(locator, Fidelity::ByteLossless)
371 }
372
373 pub fn load_with_fidelity(
380 &self,
381 locator: &SessionLocator,
382 fidelity: Fidelity,
383 ) -> Result<Session> {
384 match &locator.storage {
385 StorageLocator::File { path } => {
386 if let Some(session) = load_native_store_family(path)? {
387 Ok(session)
388 } else {
389 Ok(Session::load_with_fidelity(path, fidelity)?)
390 }
391 }
392 StorageLocator::Sqlite { path, selector } => {
393 if locator.harness.as_str() == HarnessId::GOOSE {
394 Ok(Session::from_goose_sqlite(path, selector)?)
395 } else {
396 Ok(Session::from_opencode_sqlite(path, Some(selector))?)
397 }
398 }
399 }
400 }
401
402 #[doc(hidden)]
406 pub fn load_parent_with_fidelity(
407 &self,
408 locator: &SessionLocator,
409 fidelity: Fidelity,
410 ) -> Result<Session> {
411 match &locator.storage {
412 StorageLocator::File { path } => {
413 if let Some(session) = load_native_store_family(path)? {
414 Ok(session)
415 } else {
416 Ok(Session::load_parent_with_fidelity(path, fidelity)?)
417 }
418 }
419 StorageLocator::Sqlite { path, selector } => {
420 if locator.harness.as_str() == HarnessId::GOOSE {
421 Ok(Session::from_goose_sqlite(path, selector)?)
422 } else {
423 Ok(Session::from_opencode_sqlite(path, Some(selector))?)
424 }
425 }
426 }
427 }
428
429 #[doc(hidden)]
432 pub fn load_display_view(
433 &self,
434 locator: &SessionLocator,
435 fidelity: Fidelity,
436 message_limit: usize,
437 ) -> Result<Session> {
438 match &locator.storage {
439 StorageLocator::File { path } => {
440 if let Some(mut session) = load_native_store_family(path)? {
441 if session.messages.len() > message_limit.max(1) {
442 session
443 .messages
444 .drain(..session.messages.len() - message_limit.max(1));
445 }
446 Ok(session)
447 } else {
448 Ok(Session::load_display_view(path, fidelity, message_limit)?)
449 }
450 }
451 StorageLocator::Sqlite { path, selector } => {
452 let mut session = if locator.harness.as_str() == HarnessId::GOOSE {
453 Session::from_goose_sqlite_display(path, selector, message_limit)?
454 } else {
455 Session::from_opencode_sqlite(path, Some(selector))?
456 };
457 if session.messages.len() > message_limit.max(1) {
458 session
459 .messages
460 .drain(..session.messages.len() - message_limit.max(1));
461 }
462 Ok(session)
463 }
464 }
465 }
466
467 pub fn follow(&self, locator: &SessionLocator) -> Result<SessionFollower> {
469 self.follow_with_fidelity(locator, Fidelity::ByteLossless)
470 }
471
472 pub fn follow_with_fidelity(
474 &self,
475 locator: &SessionLocator,
476 fidelity: Fidelity,
477 ) -> Result<SessionFollower> {
478 SessionFollower::open_locator_with_fidelity(locator, fidelity)
479 }
480
481 #[doc(hidden)]
483 pub fn follow_read_view(
484 &self,
485 locator: &SessionLocator,
486 fidelity: Fidelity,
487 include_subagents: bool,
488 message_limit: Option<usize>,
489 max_message_chars: Option<usize>,
490 display_history: bool,
491 ) -> Result<SessionFollower> {
492 SessionFollower::open_locator_with_view(
493 locator,
494 fidelity,
495 include_subagents,
496 message_limit,
497 max_message_chars,
498 display_history,
499 )
500 }
501}
502
503fn descriptor_matches(descriptor: &SessionDescriptor, search: &str) -> bool {
504 [
505 Some(descriptor.locator.harness.as_str()),
506 Some(descriptor.locator.session_id.as_str()),
507 descriptor.title.as_deref(),
508 descriptor.cwd.as_ref().and_then(|path| path.to_str()),
509 descriptor.model.as_deref(),
510 ]
511 .into_iter()
512 .flatten()
513 .any(|value| value.to_lowercase().contains(search))
514}
515
516fn descriptor_cursor_key(descriptor: &SessionDescriptor) -> (Option<u64>, String, String) {
517 (
518 descriptor.updated_at_ms,
519 descriptor.locator.harness.as_str().to_string(),
520 descriptor.locator.session_id.clone(),
521 )
522}
523
524fn encode_cursor(descriptor: &SessionDescriptor) -> String {
525 let json = serde_json::to_vec(&descriptor_cursor_key(descriptor)).unwrap_or_default();
526 let mut encoded = String::with_capacity(json.len() * 2);
527 for byte in json {
528 use std::fmt::Write;
529 let _ = write!(&mut encoded, "{byte:02x}");
530 }
531 encoded
532}
533
534fn decode_cursor(cursor: &str) -> Result<(Option<u64>, String, String)> {
535 if cursor.len() % 2 != 0 {
536 return Err(Error::Other("discovery cursor is invalid".into()));
537 }
538 let bytes = (0..cursor.len())
539 .step_by(2)
540 .map(|index| u8::from_str_radix(&cursor[index..index + 2], 16))
541 .collect::<std::result::Result<Vec<_>, _>>()
542 .map_err(|_| Error::Other("discovery cursor is invalid".into()))?;
543 serde_json::from_slice(&bytes).map_err(|_| Error::Other("discovery cursor is invalid".into()))
544}
545
546#[derive(Default)]
547struct HeaderMeta {
548 session_id: Option<String>,
549 cwd: Option<PathBuf>,
550 model: Option<String>,
551}
552
553fn discover_jsonl(
554 root: &Path,
555 harness: &str,
556 workspace: Option<&Path>,
557 found: &mut Vec<SessionDescriptor>,
558) {
559 let mut files = Vec::new();
560 collect_jsonl(root, harness, &mut files);
561 for path in files {
562 let Ok(meta) = read_header(&path, harness) else {
563 continue;
564 };
565 if workspace.is_some_and(|wanted| {
566 meta.cwd
567 .as_deref()
568 .is_none_or(|cwd| !recorded_cwd_matches(cwd, wanted))
569 }) {
570 continue;
571 }
572 let session_id = meta.session_id.unwrap_or_else(|| {
573 path.file_stem()
574 .and_then(|value| value.to_str())
575 .unwrap_or("unknown")
576 .to_string()
577 });
578 found.push(SessionDescriptor {
579 locator: SessionLocator {
580 harness: HarnessId::new(harness),
581 session_id,
582 storage: StorageLocator::File { path: path.clone() },
583 },
584 cwd: meta.cwd,
585 title: None,
586 updated_at_ms: modified_ms(&path),
587 message_count: None,
588 model: meta.model,
589 });
590 }
591}
592
593fn collect_jsonl(root: &Path, harness: &str, out: &mut Vec<PathBuf>) {
594 let Ok(entries) = fs::read_dir(root) else {
595 return;
596 };
597 for entry in entries.flatten() {
598 let Ok(kind) = entry.file_type() else {
599 continue;
600 };
601 let path = entry.path();
602 if kind.is_dir() {
603 if harness == HarnessId::CLAUDE_CODE
604 && path.file_name().and_then(|v| v.to_str()) == Some("subagents")
605 {
606 continue;
607 }
608 collect_jsonl(&path, harness, out);
609 } else if kind.is_file() && path.extension().and_then(|v| v.to_str()) == Some("jsonl") {
610 out.push(path);
611 }
612 }
613}
614
615fn read_header(path: &Path, harness: &str) -> Result<HeaderMeta> {
616 let file = File::open(path)?;
617 let mut result = HeaderMeta::default();
618 let mut bytes = 0usize;
619 for line in BufReader::new(file).lines().take(32) {
620 let line = line?;
621 bytes += line.len();
622 if bytes > 256 * 1024 {
623 break;
624 }
625 let Ok(value) = serde_json::from_str::<Value>(&line) else {
626 continue;
627 };
628 match harness {
629 HarnessId::CLAUDE_CODE => {
630 fill_string(&mut result.session_id, value.get("sessionId"));
631 fill_path(&mut result.cwd, value.get("cwd"));
632 fill_string(
633 &mut result.model,
634 value.get("message").and_then(|v| v.get("model")),
635 );
636 }
637 HarnessId::CODEX => {
638 let payload = value.get("payload").unwrap_or(&Value::Null);
639 if value.get("type").and_then(Value::as_str) == Some("session_meta") {
640 fill_string(&mut result.session_id, payload.get("id"));
641 fill_path(&mut result.cwd, payload.get("cwd"));
642 }
643 if value.get("type").and_then(Value::as_str) == Some("turn_context") {
644 fill_path(&mut result.cwd, payload.get("cwd"));
645 fill_string(&mut result.model, payload.get("model"));
646 }
647 }
648 HarnessId::PI => {
649 if value.get("type").and_then(Value::as_str) == Some("session") {
650 fill_string(&mut result.session_id, value.get("id"));
651 fill_path(&mut result.cwd, value.get("cwd"));
652 }
653 fill_string(
654 &mut result.model,
655 value.get("message").and_then(|v| v.get("model")),
656 );
657 }
658 _ => {}
659 }
660 if result.session_id.is_some() && result.cwd.is_some() && result.model.is_some() {
661 break;
662 }
663 }
664 if result.session_id.is_none() && result.cwd.is_none() {
665 return Err(Error::Other(format!(
666 "{} has no recognizable {harness} session header",
667 path.display()
668 )));
669 }
670 Ok(result)
671}
672
673fn discover_gemini(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
674 let slug_to_cwd = std::fs::read_to_string(root.join("projects.json"))
675 .ok()
676 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
677 .and_then(|value| value.get("projects").and_then(Value::as_object).cloned())
678 .map(|projects| {
679 projects
680 .into_iter()
681 .filter_map(|(cwd, slug)| Some((slug.as_str()?.to_string(), PathBuf::from(cwd))))
682 .collect::<HashMap<_, _>>()
683 })
684 .unwrap_or_default();
685 let mut files = Vec::new();
686 collect_jsonl(&root.join("tmp"), HarnessId::GEMINI, &mut files);
687 for path in files {
688 if path
689 .parent()
690 .and_then(Path::file_name)
691 .and_then(|name| name.to_str())
692 != Some("chats")
693 {
694 continue;
695 }
696 let slug = path
697 .parent()
698 .and_then(Path::parent)
699 .and_then(Path::file_name)
700 .and_then(|name| name.to_str());
701 let cwd = slug.and_then(|slug| slug_to_cwd.get(slug)).cloned();
702 if workspace.is_some_and(|wanted| {
703 cwd.as_deref()
704 .is_none_or(|actual| !recorded_cwd_matches(actual, wanted))
705 }) {
706 continue;
707 }
708 let Ok(file) = File::open(&path) else {
709 continue;
710 };
711 let mut session_id = None;
712 let mut model = None;
713 let mut title = None;
714 for line in BufReader::new(file.take(32 * 1024))
720 .lines()
721 .map_while(std::result::Result::ok)
722 {
723 let Ok(value) = serde_json::from_str::<Value>(&line) else {
724 continue;
725 };
726 if session_id.is_none() {
727 session_id = value
728 .get("sessionId")
729 .and_then(Value::as_str)
730 .map(str::to_string);
731 }
732 let kind = value.get("type").and_then(Value::as_str);
733 if kind != Some("user") && kind != Some("gemini") {
734 continue;
735 }
736 if model.is_none() {
737 model = value
738 .get("model")
739 .and_then(Value::as_str)
740 .map(str::to_string);
741 }
742 if title.is_none() && kind == Some("user") {
743 title = gemini_text(value.get("content")).filter(|text| !text.is_empty());
744 }
745 }
746 let Some(session_id) = session_id else {
747 continue;
748 };
749 found.push(SessionDescriptor {
750 locator: SessionLocator {
751 harness: HarnessId::from(HarnessId::GEMINI),
752 session_id,
753 storage: StorageLocator::File { path: path.clone() },
754 },
755 cwd,
756 title: title.map(|title| truncate_title(&title)),
757 updated_at_ms: modified_ms(&path),
758 message_count: None,
759 model,
760 });
761 }
762}
763
764fn discover_supercode(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
765 for info in list_native_store(root) {
766 let path = if info.archived {
767 root.join("archived").join(format!("{}.jsonl", info.name))
768 } else {
769 root.join(format!("{}.jsonl", info.name))
770 };
771 let loaded = workspace
772 .is_some()
773 .then(|| load_native_store_family(&path))
774 .transpose()
775 .ok()
776 .flatten()
777 .flatten();
778 if workspace.is_some_and(|wanted| {
779 loaded
780 .as_ref()
781 .and_then(|session| session.meta.cwd.as_deref())
782 .is_none_or(|cwd| !recorded_cwd_matches(cwd, wanted))
783 }) {
784 continue;
785 }
786 let title = (!info.title.trim().is_empty()).then_some(info.title);
787 let updated_at_ms =
788 modified_ms(&path).or_else(|| modified_ms(&path.with_extension("sidecar.jsonl")));
789 found.push(SessionDescriptor {
790 locator: SessionLocator {
791 harness: HarnessId::from(HarnessId::SUPERCODE),
792 session_id: info.name,
793 storage: StorageLocator::File { path: path.clone() },
794 },
795 cwd: loaded.as_ref().and_then(|session| session.meta.cwd.clone()),
796 title,
797 updated_at_ms,
798 message_count: loaded.as_ref().map(|session| session.messages.len()),
799 model: loaded.and_then(|session| session.meta.model),
800 });
801 }
802}
803
804#[derive(Deserialize)]
805struct NativeStoreInfo {
806 name: String,
807 #[serde(default)]
808 title: String,
809 #[serde(skip)]
810 archived: bool,
811}
812
813fn list_native_store(root: &Path) -> Vec<NativeStoreInfo> {
814 let mut sessions = Vec::new();
815 for archived in [false, true] {
816 let directory = if archived {
817 root.join("archived")
818 } else {
819 root.to_path_buf()
820 };
821 let Ok(entries) = fs::read_dir(directory) else {
822 continue;
823 };
824 for entry in entries.flatten() {
825 let path = entry.path();
826 if !path.to_string_lossy().ends_with(".meta.json") {
827 continue;
828 }
829 let Ok(text) = fs::read_to_string(path) else {
830 continue;
831 };
832 let Ok(mut info) = serde_json::from_str::<NativeStoreInfo>(&text) else {
833 continue;
834 };
835 info.archived = archived;
836 sessions.push(info);
837 }
838 }
839 sessions.sort_by(|left, right| left.name.cmp(&right.name));
840 sessions
841}
842
843fn gemini_text(content: Option<&Value>) -> Option<String> {
844 match content? {
845 Value::String(text) => Some(text.clone()),
846 Value::Array(parts) => Some(
847 parts
848 .iter()
849 .filter_map(|part| part.get("text").and_then(Value::as_str))
850 .collect::<Vec<_>>()
851 .join(" ")
852 .trim()
853 .to_string(),
854 ),
855 _ => None,
856 }
857}
858
859fn truncate_title(title: &str) -> String {
860 const MAX_CHARS: usize = 120;
861 let mut value = title.chars().take(MAX_CHARS).collect::<String>();
862 if title.chars().count() > MAX_CHARS {
863 value.push('…');
864 }
865 value
866}
867
868fn discover_grok(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
869 let Ok(workspaces) = fs::read_dir(root) else {
870 return;
871 };
872 for workspace_entry in workspaces.flatten() {
873 let encoded = workspace_entry.file_name();
874 let Some(cwd) = encoded
875 .to_str()
876 .and_then(percent_decode_path)
877 .map(PathBuf::from)
878 else {
879 continue;
880 };
881 if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
882 continue;
883 }
884 let Ok(sessions) = fs::read_dir(workspace_entry.path()) else {
885 continue;
886 };
887 for session_entry in sessions.flatten() {
888 let session_dir = session_entry.path();
889 if !session_dir.is_dir() {
890 continue;
891 }
892 let transcript = session_dir.join("chat_history.jsonl");
893 if !transcript.is_file() {
894 continue;
895 }
896 let Some(session_id) = session_dir
897 .file_name()
898 .and_then(|name| name.to_str())
899 .map(str::to_string)
900 else {
901 continue;
902 };
903 let summary = fs::read_to_string(session_dir.join("summary.json"))
904 .ok()
905 .and_then(|text| serde_json::from_str::<Value>(&text).ok());
906 let title = summary
907 .as_ref()
908 .and_then(|value| value.get("generated_title"))
909 .and_then(Value::as_str)
910 .filter(|title| !title.is_empty())
911 .map(str::to_string);
912 let model = summary
913 .as_ref()
914 .and_then(|value| value.get("current_model_id"))
915 .and_then(Value::as_str)
916 .map(str::to_string);
917 let message_count = summary
918 .as_ref()
919 .and_then(|value| value.get("num_chat_messages"))
920 .and_then(Value::as_u64)
921 .and_then(|count| usize::try_from(count).ok());
922 let updated_at_ms = summary
923 .as_ref()
924 .and_then(|value| value.get("updated_at"))
925 .and_then(Value::as_str)
926 .and_then(crate::sidecar::rfc3339_to_ms)
927 .and_then(|millis| u64::try_from(millis).ok())
928 .or_else(|| modified_ms(&transcript));
929 found.push(SessionDescriptor {
930 locator: SessionLocator {
931 harness: HarnessId::from(HarnessId::GROK),
932 session_id,
933 storage: StorageLocator::File { path: transcript },
934 },
935 cwd: Some(cwd.clone()),
936 title,
937 updated_at_ms,
938 message_count,
939 model,
940 });
941 }
942 }
943}
944
945fn discover_opencode(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
946 let mut dbs = Vec::new();
947 if root.is_file() {
948 dbs.push(root.to_path_buf());
949 } else if let Ok(entries) = fs::read_dir(root) {
950 dbs.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
951 path.file_name()
952 .and_then(|v| v.to_str())
953 .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
954 }));
955 }
956 dbs.sort();
957 for db in dbs {
958 let Ok(conn) = Connection::open_with_flags(
959 &db,
960 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
961 ) else {
962 continue;
963 };
964 let has_model = conn.prepare("SELECT model FROM session LIMIT 0").is_ok();
965 let model_column = if has_model { "s.model" } else { "NULL" };
966 let query = format!(
967 "SELECT s.id, s.directory, s.title, s.time_updated, {model_column}, COUNT(m.id) \
968 FROM session s LEFT JOIN message m ON m.session_id = s.id \
969 GROUP BY s.id ORDER BY s.time_updated DESC"
970 );
971 let Ok(mut stmt) = conn.prepare(&query) else {
972 continue;
973 };
974 let Ok(rows) = stmt.query_map([], |row| {
975 Ok((
976 row.get::<_, String>(0)?,
977 row.get::<_, String>(1)?,
978 row.get::<_, String>(2)?,
979 row.get::<_, i64>(3)?,
980 row.get::<_, Option<String>>(4)?,
981 row.get::<_, i64>(5)?,
982 ))
983 }) else {
984 continue;
985 };
986 for row in rows.flatten() {
987 let (id, cwd, title, updated, model, messages) = row;
988 let cwd = PathBuf::from(cwd);
989 if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
990 continue;
991 }
992 found.push(SessionDescriptor {
993 locator: SessionLocator {
994 harness: HarnessId::from(HarnessId::OPENCODE),
995 session_id: id.clone(),
996 storage: StorageLocator::Sqlite {
997 path: db.clone(),
998 selector: id,
999 },
1000 },
1001 cwd: Some(cwd),
1002 title: (!title.is_empty()).then_some(title),
1003 updated_at_ms: u64::try_from(updated).ok(),
1004 message_count: usize::try_from(messages).ok(),
1005 model,
1006 });
1007 }
1008 }
1009}
1010
1011fn discover_goose(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
1012 let db = if root.is_file() {
1013 root.to_path_buf()
1014 } else if root.join("sessions.db").is_file() {
1015 root.join("sessions.db")
1016 } else {
1017 root.join("sessions/sessions.db")
1018 };
1019 let Ok(connection) = Connection::open_with_flags(
1020 &db,
1021 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
1022 ) else {
1023 return;
1024 };
1025 let Ok(mut statement) = connection.prepare(
1026 "SELECT s.id, s.working_dir, s.name, s.updated_at, s.model_config_json, \
1027 COUNT(m.id) \
1028 FROM sessions s LEFT JOIN messages m ON m.session_id = s.id \
1029 WHERE s.archived_at IS NULL \
1030 GROUP BY s.id ORDER BY s.updated_at DESC",
1031 ) else {
1032 return;
1033 };
1034 let Ok(rows) = statement.query_map([], |row| {
1035 Ok((
1036 row.get::<_, String>(0)?,
1037 row.get::<_, String>(1)?,
1038 row.get::<_, String>(2)?,
1039 row.get::<_, String>(3)?,
1040 row.get::<_, Option<String>>(4)?,
1041 row.get::<_, i64>(5)?,
1042 ))
1043 }) else {
1044 return;
1045 };
1046 for row in rows.flatten() {
1047 let (id, cwd, title, updated_at, model_config, message_count) = row;
1048 let cwd = PathBuf::from(cwd);
1049 if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
1050 continue;
1051 }
1052 let model = model_config
1053 .as_deref()
1054 .and_then(|value| serde_json::from_str::<Value>(value).ok())
1055 .and_then(|value| {
1056 value
1057 .get("model_name")
1058 .or_else(|| value.get("modelName"))
1059 .and_then(Value::as_str)
1060 .map(str::to_string)
1061 });
1062 let updated_at_ms = crate::sidecar::rfc3339_to_ms(&updated_at)
1063 .or_else(|| {
1064 crate::sidecar::rfc3339_to_ms(&format!("{}Z", updated_at.replace(' ', "T")))
1066 })
1067 .and_then(|value| u64::try_from(value).ok());
1068 found.push(SessionDescriptor {
1069 locator: SessionLocator {
1070 harness: HarnessId::from(HarnessId::GOOSE),
1071 session_id: id.clone(),
1072 storage: StorageLocator::Sqlite {
1073 path: db.clone(),
1074 selector: id,
1075 },
1076 },
1077 cwd: Some(cwd),
1078 title: (!title.trim().is_empty()).then_some(title),
1079 updated_at_ms,
1080 message_count: usize::try_from(message_count).ok(),
1081 model,
1082 });
1083 }
1084}
1085
1086fn fill_string(target: &mut Option<String>, value: Option<&Value>) {
1087 if target.is_none() {
1088 *target = value.and_then(Value::as_str).map(str::to_owned);
1089 }
1090}
1091
1092fn fill_path(target: &mut Option<PathBuf>, value: Option<&Value>) {
1093 if target.is_none() {
1094 *target = value.and_then(Value::as_str).map(PathBuf::from);
1095 }
1096}
1097
1098fn modified_ms(path: &Path) -> Option<u64> {
1099 fs::metadata(path)
1100 .ok()?
1101 .modified()
1102 .ok()?
1103 .duration_since(UNIX_EPOCH)
1104 .ok()
1105 .and_then(|duration| u64::try_from(duration.as_millis()).ok())
1106}
1107
1108fn recorded_cwd_matches(recorded: &Path, wanted: &Path) -> bool {
1114 recorded.is_absolute() && same_path(recorded, wanted)
1115}
1116
1117fn same_path(left: &Path, right: &Path) -> bool {
1118 match (fs::canonicalize(left), fs::canonicalize(right)) {
1119 (Ok(left), Ok(right)) => left == right,
1120 _ => normalize_path(left) == normalize_path(right),
1121 }
1122}
1123
1124fn normalize_path(path: &Path) -> PathBuf {
1125 let absolute = if path.is_absolute() {
1126 path.to_path_buf()
1127 } else {
1128 std::env::current_dir()
1129 .unwrap_or_else(|_| PathBuf::from("."))
1130 .join(path)
1131 };
1132 let mut normalized = PathBuf::new();
1133 for component in absolute.components() {
1134 match component {
1135 Component::CurDir => {}
1136 Component::ParentDir => {
1137 normalized.pop();
1138 }
1139 other => normalized.push(other.as_os_str()),
1140 }
1141 }
1142 normalized
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147 use super::*;
1148 use std::time::{SystemTime, UNIX_EPOCH};
1149
1150 fn temp_dir(label: &str) -> PathBuf {
1151 let nonce = SystemTime::now()
1152 .duration_since(UNIX_EPOCH)
1153 .unwrap()
1154 .as_nanos();
1155 let path = std::env::temp_dir().join(format!(
1156 "supercode-catalog-{label}-{}-{nonce}",
1157 std::process::id()
1158 ));
1159 fs::create_dir_all(&path).unwrap();
1160 path
1161 }
1162
1163 #[test]
1164 fn locator_json_round_trip_preserves_sqlite_selector() {
1165 let locator = SessionLocator {
1166 harness: HarnessId::from(HarnessId::OPENCODE),
1167 session_id: "ses_123".into(),
1168 storage: StorageLocator::Sqlite {
1169 path: PathBuf::from("/tmp/opencode-dev.db"),
1170 selector: "ses_123".into(),
1171 },
1172 };
1173 let encoded = serde_json::to_string(&locator).unwrap();
1174 assert_eq!(
1175 serde_json::from_str::<SessionLocator>(&encoded).unwrap(),
1176 locator
1177 );
1178 }
1179
1180 #[test]
1181 fn discovers_filters_loads_and_follows_three_jsonl_harnesses() {
1182 let root = temp_dir("jsonl");
1183 let workspace = root.join("workspace");
1184 let other = root.join("other");
1185 fs::create_dir_all(&workspace).unwrap();
1186 fs::create_dir_all(&other).unwrap();
1187
1188 let claude = root.join("claude");
1189 let codex = root.join("codex");
1190 let pi = root.join("pi");
1191 fs::create_dir_all(&claude).unwrap();
1192 fs::create_dir_all(&codex).unwrap();
1193 fs::create_dir_all(&pi).unwrap();
1194 fs::write(
1195 claude.join("claude.jsonl"),
1196 format!(
1197 "{{\"type\":\"user\",\"sessionId\":\"cc-1\",\"cwd\":{},\"message\":{{\"role\":\"user\",\"content\":\"hi\"}}}}\n",
1198 serde_json::to_string(&workspace.to_string_lossy()).unwrap()
1199 ),
1200 )
1201 .unwrap();
1202 fs::write(
1203 codex.join("rollout.jsonl"),
1204 format!(
1205 "{{\"timestamp\":\"2026-01-01T00:00:00Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"cx-1\",\"cwd\":{}}}}}\n",
1206 serde_json::to_string(&workspace.to_string_lossy()).unwrap()
1207 ),
1208 )
1209 .unwrap();
1210 fs::write(
1211 pi.join("pi.jsonl"),
1212 format!(
1213 "{{\"type\":\"session\",\"version\":3,\"id\":\"pi-1\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":{}}}\n",
1214 serde_json::to_string(&workspace.to_string_lossy()).unwrap()
1215 ),
1216 )
1217 .unwrap();
1218 fs::write(
1219 pi.join("unrelated.jsonl"),
1220 format!(
1221 "{{\"type\":\"session\",\"version\":3,\"id\":\"pi-2\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":{}}}\n",
1222 serde_json::to_string(&other.to_string_lossy()).unwrap()
1223 ),
1224 )
1225 .unwrap();
1226 fs::write(claude.join("partial.jsonl"), "{truncated").unwrap();
1227
1228 let query = DiscoveryQuery {
1229 workspace: Some(workspace),
1230 homes: HarnessHomes {
1231 claude_code: claude,
1232 codex,
1233 pi,
1234 opencode: root.join("missing-opencode"),
1235 grok: root.join("missing-grok"),
1236 gemini: root.join("missing-gemini"),
1237 goose: root.join("missing-goose"),
1238 supercode: root.join("missing-supercode"),
1239 },
1240 ..DiscoveryQuery::default()
1241 };
1242 let catalog = HarnessCatalog::new();
1243 let found = catalog.discover(&query).unwrap();
1244 assert_eq!(found.len(), 3);
1245 assert_eq!(
1246 found
1247 .iter()
1248 .map(|item| item.locator.harness.as_str())
1249 .collect::<HashSet<_>>(),
1250 HashSet::from([HarnessId::CLAUDE_CODE, HarnessId::CODEX, HarnessId::PI])
1251 );
1252 for descriptor in found {
1253 let loaded = catalog.load(&descriptor.locator).unwrap();
1254 assert_eq!(
1255 loaded.meta.session_id.as_deref(),
1256 Some(descriptor.locator.session_id.as_str())
1257 );
1258 let mut follower = catalog.follow(&descriptor.locator).unwrap();
1259 assert!(matches!(
1260 follower.poll().unwrap(),
1261 Some(crate::SessionWatchEvent::SessionSnapshot { .. })
1262 ));
1263 }
1264 fs::remove_dir_all(root).ok();
1265 }
1266
1267 #[test]
1268 fn discovers_loads_and_follows_opencode_sqlite() {
1269 let db = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1270 .join("../harness/tests/fixtures/opencode_fixture/opencode.db");
1271 let catalog = HarnessCatalog::new();
1272 let found = catalog
1273 .discover(&DiscoveryQuery {
1274 harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
1275 homes: HarnessHomes {
1276 opencode: db,
1277 ..HarnessHomes::default()
1278 },
1279 ..DiscoveryQuery::default()
1280 })
1281 .unwrap();
1282 assert!(!found.is_empty());
1283 for descriptor in found {
1284 assert_eq!(descriptor.locator.harness.as_str(), HarnessId::OPENCODE);
1285 assert_eq!(
1286 catalog.load(&descriptor.locator).unwrap().meta.session_id,
1287 Some(descriptor.locator.session_id.clone())
1288 );
1289 assert!(catalog.follow(&descriptor.locator).is_ok());
1290 }
1291 }
1292
1293 #[test]
1294 fn discovers_loads_and_follows_gemini_conversation_records() {
1295 let root = temp_dir("gemini");
1296 let workspace = root.join("workspace");
1297 let chats = root.join("gemini/tmp/demo/chats");
1298 fs::create_dir_all(&workspace).unwrap();
1299 fs::create_dir_all(&chats).unwrap();
1300 fs::write(
1301 root.join("gemini/projects.json"),
1302 serde_json::json!({
1303 "projects": {workspace.to_string_lossy(): "demo"}
1304 })
1305 .to_string(),
1306 )
1307 .unwrap();
1308 let transcript = chats.join("gemini-id.jsonl");
1309 fs::write(
1310 &transcript,
1311 include_str!("../../harness/tests/fixtures/gemini_session.jsonl"),
1312 )
1313 .unwrap();
1314
1315 let catalog = HarnessCatalog::new();
1316 let found = catalog
1317 .discover(&DiscoveryQuery {
1318 harnesses: vec![HarnessId::from(HarnessId::GEMINI)],
1319 homes: HarnessHomes {
1320 gemini: root.join("gemini"),
1321 ..HarnessHomes::default()
1322 },
1323 workspace: Some(workspace.clone()),
1324 ..DiscoveryQuery::default()
1325 })
1326 .unwrap();
1327
1328 assert_eq!(found.len(), 1);
1329 assert_eq!(found[0].cwd.as_deref(), Some(workspace.as_path()));
1330 assert_eq!(found[0].message_count, None);
1331 assert_eq!(found[0].model.as_deref(), Some("gemini-2.5-pro"));
1332 assert_eq!(found[0].title.as_deref(), Some("Inspect the fixture."));
1333 let loaded = catalog.load(&found[0].locator).unwrap();
1334 assert_eq!(
1335 loaded.meta.session_id.as_deref(),
1336 Some("11111111-1111-4111-8111-111111111111")
1337 );
1338 assert_eq!(loaded.messages.len(), 4);
1339 assert!(matches!(
1340 catalog.follow(&found[0].locator).unwrap().poll().unwrap(),
1341 Some(crate::SessionWatchEvent::SessionSnapshot { .. })
1342 ));
1343 fs::remove_dir_all(root).ok();
1344 }
1345
1346 #[test]
1347 fn discovers_native_store_and_pages_search_results() {
1348 let root = temp_dir("supercode");
1349 let store_root = root.join("sessions");
1350 fs::create_dir_all(&store_root).unwrap();
1351 for (name, title) in [
1352 ("alpha", "Alpha planning"),
1353 ("beta", "Beta implementation"),
1354 ("gamma", "Gamma review"),
1355 ] {
1356 fs::write(
1357 store_root.join(format!("{name}.jsonl")),
1358 format!("{{\"role\":\"user\",\"content\":\"{title}\"}}\n"),
1359 )
1360 .unwrap();
1361 fs::write(
1362 store_root.join(format!("{name}.meta.json")),
1363 serde_json::json!({"name": name, "title": title}).to_string(),
1364 )
1365 .unwrap();
1366 }
1367 let catalog = HarnessCatalog::new();
1368 let base = DiscoveryQuery {
1369 harnesses: vec![HarnessId::from(HarnessId::SUPERCODE)],
1370 homes: HarnessHomes {
1371 supercode: store_root,
1372 ..HarnessHomes::default()
1373 },
1374 limit: Some(1),
1375 ..DiscoveryQuery::default()
1376 };
1377
1378 let first = catalog.discover_page(&base).unwrap();
1379 assert_eq!(first.sessions.len(), 1);
1380 assert!(first.next_cursor.is_some());
1381 let second = catalog
1382 .discover_page(&DiscoveryQuery {
1383 cursor: first.next_cursor,
1384 ..base.clone()
1385 })
1386 .unwrap();
1387 assert_eq!(second.sessions.len(), 1);
1388 assert_ne!(
1389 first.sessions[0].locator.session_id,
1390 second.sessions[0].locator.session_id
1391 );
1392 let search = catalog
1393 .discover_page(&DiscoveryQuery {
1394 limit: None,
1395 query: Some("implementation".into()),
1396 ..base
1397 })
1398 .unwrap();
1399 assert_eq!(search.sessions.len(), 1);
1400 assert_eq!(search.sessions[0].locator.session_id, "beta");
1401 assert_eq!(search.sessions[0].message_count, None);
1402 assert_eq!(
1403 catalog
1404 .load(&search.sessions[0].locator)
1405 .unwrap()
1406 .messages
1407 .len(),
1408 1
1409 );
1410 fs::remove_dir_all(root).ok();
1411 }
1412
1413 #[test]
1414 fn discovers_current_opencode_schema_without_a_session_model_column() {
1415 let root = temp_dir("opencode-current");
1416 let db = root.join("opencode.db");
1417 let conn = Connection::open(&db).unwrap();
1418 conn.execute_batch(
1419 "CREATE TABLE session (
1420 id TEXT PRIMARY KEY,
1421 directory TEXT NOT NULL,
1422 title TEXT NOT NULL,
1423 time_updated INTEGER NOT NULL
1424 );
1425 CREATE TABLE message (
1426 id TEXT PRIMARY KEY,
1427 session_id TEXT NOT NULL
1428 );
1429 INSERT INTO session VALUES ('ses_current', '/tmp/work', 'Current', 42);
1430 INSERT INTO message VALUES ('msg_current', 'ses_current');",
1431 )
1432 .unwrap();
1433 drop(conn);
1434
1435 let found = HarnessCatalog::new()
1436 .discover(&DiscoveryQuery {
1437 harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
1438 homes: HarnessHomes {
1439 opencode: db,
1440 ..HarnessHomes::default()
1441 },
1442 ..DiscoveryQuery::default()
1443 })
1444 .unwrap();
1445
1446 assert_eq!(found.len(), 1);
1447 assert_eq!(found[0].locator.session_id, "ses_current");
1448 assert_eq!(found[0].message_count, Some(1));
1449 assert_eq!(found[0].model, None);
1450 fs::remove_dir_all(root).ok();
1451 }
1452
1453 #[test]
1454 fn workspace_filter_never_matches_a_relative_recorded_cwd() {
1455 let root = temp_dir("opencode-relative-cwd");
1460 let db = root.join("opencode.db");
1461 let conn = Connection::open(&db).unwrap();
1462 let here = std::env::current_dir().unwrap();
1463 conn.execute_batch(&format!(
1464 "CREATE TABLE session (
1465 id TEXT PRIMARY KEY,
1466 directory TEXT NOT NULL,
1467 title TEXT NOT NULL,
1468 time_updated INTEGER NOT NULL
1469 );
1470 CREATE TABLE message (
1471 id TEXT PRIMARY KEY,
1472 session_id TEXT NOT NULL
1473 );
1474 INSERT INTO session VALUES ('ses_relative', '.', 'Ghost', 41);
1475 INSERT INTO session VALUES ('ses_here', '{}', 'Real', 42);",
1476 here.display()
1477 ))
1478 .unwrap();
1479 drop(conn);
1480
1481 let found = HarnessCatalog::new()
1482 .discover(&DiscoveryQuery {
1483 workspace: Some(here),
1484 harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
1485 homes: HarnessHomes {
1486 opencode: db,
1487 ..HarnessHomes::default()
1488 },
1489 ..DiscoveryQuery::default()
1490 })
1491 .unwrap();
1492
1493 assert_eq!(found.len(), 1);
1494 assert_eq!(found[0].locator.session_id, "ses_here");
1495 fs::remove_dir_all(root).ok();
1496 }
1497}